feat: add Redis for OTP, auth tokens, rate limiting, lead dedup and marketplace cache

- Add crates/cache with client, otp, rate_limit, token, lead, jobs modules
- OTP tokens stored in Redis (15-min TTL, single-use GETDEL on verify)
- Refresh tokens stored in Redis (30-day TTL) — removed DB storage
- Password reset tokens stored in Redis (1-hour TTL, single-use)
- Rate limiting: register (10/hr), login (10/15min), OTP resend (3/hr), lead (5/hr), job post (20/hr)
- Lead request deduplication: 24-hour Redis lock per professional+requirement pair
- Marketplace listings cached in Redis (5-min TTL per profession+page+limit)
- Add ProfessionState{pool, redis} to contracts crate, replacing bare PgPool in all 9 profession apps
- All profession handlers and main.rs updated to use ProfessionState
- REDIS_URL env var (default: redis://127.0.0.1:6379) used across all services
- Fix profession model struct name mangling in 6 handlers (MakeupArtistRepository etc.)
- Add custom_data JSONB migration for all 9 profession profile tables
- Add onboarding_state model and repository (save_progress, complete, is_complete)
- Add onboarding handler accepting roleKey:String (not role_id:UUID) for frontend compat

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar 2026-03-18 22:58:42 +01:00
parent 5640cd4ee5
commit bb8155dd27
60 changed files with 2856 additions and 1070 deletions

21
.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
# Environment variables — NEVER commit
.env
.env.local
.env.production
# Rust build artifacts
/target
Cargo.lock
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log

View file

@ -16,11 +16,10 @@ members = [
"apps/fitness_trainers",
"apps/catering_services",
"crates/contracts",
"crates/config",
"crates/errors",
"crates/db",
"crates/observability",
"crates/auth",
"crates/storage",
"crates/cache",
]
[workspace.package]
@ -42,5 +41,6 @@ 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"] }
lettre = { version = "0.11", features = ["tokio1-rustls-tls", "serde"] }
redis = { version = "0.27", features = ["tokio-comp"] }

View file

@ -15,4 +15,5 @@ chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }

View file

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

View file

@ -1,11 +1,9 @@
mod handlers;
use axum::{Router, http::Method};
use axum::{routing::get, Router};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
use contracts::ProfessionState;
#[tokio::main]
async fn main() {
@ -23,20 +21,29 @@ async fn main() {
.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 redis_url = std::env::var("REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
let redis = cache::connect(&redis_url)
.await
.expect("Failed to connect to Redis");
tracing::info!("Catering Services service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Catering Services OK" }))
.nest("/api/catering-services", handlers::router())
.layer(cors)
.with_state(pool);
.route("/health", get(|| async { "Catering Services OK" }))
.with_state(state);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8093".to_string()).parse().unwrap();
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8093".to_string())
.parse()
.expect("PORT must be a number");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Catering Services listening on {}", addr);
tracing::info!("Catering Services service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View file

@ -15,4 +15,5 @@ chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }

View file

@ -1,39 +1,28 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use sqlx::PgPool;
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use db::models::developer::{DeveloperRepository, UpsertDeveloperProfilePayload};
use contracts::auth_middleware::AuthUser;
use contracts::{auth_middleware::AuthUser, ProfessionState};
pub fn router() -> Router<PgPool> {
pub fn router() -> Router<ProfessionState> {
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 get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match DeveloperRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(p)) => (StatusCode::OK, Json(p)).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>,
State(state): State<ProfessionState>,
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(),
match DeveloperRepository::upsert(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -1,11 +1,9 @@
mod handlers;
use axum::{Router, http::Method};
use axum::{routing::get, Router};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
use contracts::ProfessionState;
#[tokio::main]
async fn main() {
@ -23,20 +21,29 @@ async fn main() {
.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 redis_url = std::env::var("REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
let redis = cache::connect(&redis_url)
.await
.expect("Failed to connect to Redis");
tracing::info!("Developers service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Developers Service OK" }))
.nest("/api/developers", handlers::router())
.layer(cors)
.with_state(pool);
.route("/health", get(|| async { "Developers OK" }))
.with_state(state);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8088".to_string()).parse().unwrap();
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8088".to_string())
.parse()
.expect("PORT must be a number");
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

@ -15,4 +15,5 @@ chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }

View file

@ -1,38 +1,28 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use sqlx::PgPool;
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use db::models::fitness_trainer::{FitnessTrainerRepository, UpsertFitnessTrainerProfilePayload};
use contracts::auth_middleware::AuthUser;
use contracts::{auth_middleware::AuthUser, ProfessionState};
pub fn router() -> Router<PgPool> {
pub fn router() -> Router<ProfessionState> {
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 get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match FitnessTrainerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(p)) => (StatusCode::OK, Json(p)).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>,
State(state): State<ProfessionState>,
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(),
match FitnessTrainerRepository::upsert(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -1,11 +1,9 @@
mod handlers;
use axum::{Router, http::Method};
use axum::{routing::get, Router};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
use contracts::ProfessionState;
#[tokio::main]
async fn main() {
@ -23,20 +21,29 @@ async fn main() {
.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 redis_url = std::env::var("REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
let redis = cache::connect(&redis_url)
.await
.expect("Failed to connect to Redis");
tracing::info!("Fitness Trainers service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
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);
.route("/health", get(|| async { "Fitness Trainers OK" }))
.with_state(state);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8092".to_string()).parse().unwrap();
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8092".to_string())
.parse()
.expect("PORT must be a number");
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();
}

View file

@ -15,4 +15,5 @@ chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }

View file

@ -1,38 +1,28 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, patch},
Json, Router,
};
use sqlx::PgPool;
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use db::models::graphic_designer::{GraphicDesignerRepository, UpsertGraphicDesignerProfilePayload};
use contracts::auth_middleware::AuthUser;
use contracts::{auth_middleware::AuthUser, ProfessionState};
pub fn router() -> Router<PgPool> {
pub fn router() -> Router<ProfessionState> {
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 get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match GraphicDesignerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(p)) => (StatusCode::OK, Json(p)).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>,
State(state): State<ProfessionState>,
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(),
match GraphicDesignerRepository::upsert(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -1,11 +1,9 @@
mod handlers;
use axum::{Router, http::Method};
use axum::{routing::get, Router};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
use contracts::ProfessionState;
#[tokio::main]
async fn main() {
@ -23,20 +21,29 @@ async fn main() {
.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 redis_url = std::env::var("REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
let redis = cache::connect(&redis_url)
.await
.expect("Failed to connect to Redis");
tracing::info!("Graphic Designers service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
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);
.route("/health", get(|| async { "Graphic Designers OK" }))
.with_state(state);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8090".to_string()).parse().unwrap();
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8090".to_string())
.parse()
.expect("PORT must be a number");
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

@ -15,4 +15,5 @@ chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }

View file

@ -1,39 +1,28 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use sqlx::PgPool;
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use db::models::makeup_artist::{MakeupArtistRepository, UpsertMakeupArtistProfilePayload};
use contracts::auth_middleware::AuthUser;
use contracts::{auth_middleware::AuthUser, ProfessionState};
pub fn router() -> Router<PgPool> {
pub fn router() -> Router<ProfessionState> {
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 get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match MakeupArtistRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(p)) => (StatusCode::OK, Json(p)).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>,
State(state): State<ProfessionState>,
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(),
match MakeupArtistRepository::upsert(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -1,11 +1,9 @@
mod handlers;
use axum::{Router, http::Method};
use axum::{routing::get, Router};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
use contracts::ProfessionState;
#[tokio::main]
async fn main() {
@ -23,20 +21,29 @@ async fn main() {
.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 redis_url = std::env::var("REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
let redis = cache::connect(&redis_url)
.await
.expect("Failed to connect to Redis");
tracing::info!("Makeup Artists service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
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);
.route("/health", get(|| async { "Makeup Artists OK" }))
.with_state(state);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8086".to_string()).parse().unwrap();
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8087".to_string())
.parse()
.expect("PORT must be a number");
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

@ -15,4 +15,5 @@ chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }

View file

@ -1,41 +1,28 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use sqlx::PgPool;
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use db::models::photographer::{PhotographerRepository, UpsertPhotographerProfilePayload};
use db::models::professional::ProfessionalRepository;
use contracts::auth_middleware::AuthUser;
use contracts::{auth_middleware::AuthUser, ProfessionState};
pub fn router() -> Router<PgPool> {
pub fn router() -> Router<ProfessionState> {
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 get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match PhotographerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(p)) => (StatusCode::OK, Json(p)).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>,
State(state): State<ProfessionState>,
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(),
match PhotographerRepository::upsert(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -1,11 +1,9 @@
mod handlers;
use axum::{Router, http::Method};
use axum::{routing::get, Router};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
use contracts::ProfessionState;
#[tokio::main]
async fn main() {
@ -23,23 +21,29 @@ async fn main() {
.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 redis_url = std::env::var("REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
let redis = cache::connect(&redis_url)
.await
.expect("Failed to connect to Redis");
tracing::info!("Photographers service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Photographers Service OK" }))
.nest("/api/photographers", handlers::router())
.layer(cors)
.with_state(pool);
.route("/health", get(|| async { "Photographers OK" }))
.with_state(state);
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

@ -15,4 +15,5 @@ chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }

View file

@ -1,38 +1,28 @@
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 axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use db::models::social_media_manager::{SocialMediaManagerRepository, UpsertSocialMediaManagerProfilePayload};
use contracts::auth_middleware::AuthUser;
use contracts::{auth_middleware::AuthUser, ProfessionState};
pub fn router() -> Router<PgPool> {
pub fn router() -> Router<ProfessionState> {
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 get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match SocialMediaManagerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(p)) => (StatusCode::OK, Json(p)).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>,
State(state): State<ProfessionState>,
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(),
match SocialMediaManagerRepository::upsert(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -1,11 +1,9 @@
mod handlers;
use axum::{Router, http::Method};
use axum::{routing::get, Router};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
use contracts::ProfessionState;
#[tokio::main]
async fn main() {
@ -23,20 +21,29 @@ async fn main() {
.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 redis_url = std::env::var("REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
let redis = cache::connect(&redis_url)
.await
.expect("Failed to connect to Redis");
tracing::info!("Social Media Managers service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
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);
.route("/health", get(|| async { "Social Media Managers OK" }))
.with_state(state);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8091".to_string()).parse().unwrap();
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8091".to_string())
.parse()
.expect("PORT must be a number");
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();
}

View file

@ -15,4 +15,5 @@ chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }

View file

@ -1,40 +1,28 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use sqlx::PgPool;
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use db::models::tutor::{TutorRepository, UpsertTutorProfilePayload};
use contracts::auth_middleware::AuthUser;
use contracts::{auth_middleware::AuthUser, ProfessionState};
pub fn router() -> Router<PgPool> {
pub fn router() -> Router<ProfessionState> {
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 get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match TutorRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(p)) => (StatusCode::OK, Json(p)).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>,
State(state): State<ProfessionState>,
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(),
match TutorRepository::upsert(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -1,11 +1,9 @@
mod handlers;
use axum::{Router, http::Method};
use axum::{routing::get, Router};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
use contracts::ProfessionState;
#[tokio::main]
async fn main() {
@ -23,20 +21,29 @@ async fn main() {
.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 redis_url = std::env::var("REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
let redis = cache::connect(&redis_url)
.await
.expect("Failed to connect to Redis");
tracing::info!("Tutors service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let app = Router::new()
.route("/health", axum::routing::get(|| async { "Tutors Service OK" }))
.nest("/api/tutors", handlers::router())
.layer(cors)
.with_state(pool);
.route("/health", get(|| async { "Tutors OK" }))
.with_state(state);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8087".to_string()).parse().unwrap();
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8086".to_string())
.parse()
.expect("PORT must be a number");
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();
}

View file

@ -17,5 +17,7 @@ db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
lettre = { workspace = true }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }
rand = "0.8"

View file

@ -2,7 +2,6 @@ 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},
@ -10,7 +9,6 @@ use axum::{
routing::{get, post},
Json, Router,
};
use chrono::{Duration, Utc};
use db::models::user::{CreateUserPayload, UserRepository};
use serde::{Deserialize, Serialize};
use contracts::auth_middleware::AuthUser;
@ -18,32 +16,32 @@ 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("/register", post(register))
.route("/login", post(login))
.route("/logout", post(logout))
.route("/refresh", post(refresh))
.route("/session", get(session))
.route("/switch-role", post(switch_role))
.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("/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,
pub email: String,
pub phone: Option<String>,
pub password: String,
}
#[derive(Deserialize)]
pub struct LoginPayload {
pub email: String,
pub email: String,
pub password: String,
}
@ -52,6 +50,11 @@ pub struct VerifyEmailPayload {
pub otp: String,
}
#[derive(Deserialize)]
pub struct ResendOtpPayload {
pub email: String,
}
#[derive(Deserialize)]
pub struct ForgotPasswordPayload {
pub email: String,
@ -59,256 +62,277 @@ pub struct ForgotPasswordPayload {
#[derive(Deserialize)]
pub struct ResetPasswordPayload {
pub token: String,
pub token: String,
pub new_password: String,
}
#[derive(Deserialize)]
pub struct ChangePasswordPayload {
pub current_password: String,
pub new_password: String,
pub new_password: String,
}
#[derive(Deserialize)]
pub struct SwitchRolePayload {
pub role_key: String,
}
#[derive(Serialize)]
pub struct RegisterResponse {
pub user_id: String,
pub email: String,
pub phone: String,
pub full_name: String,
pub status: String,
pub user_id: String,
pub email: String,
pub phone: Option<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,
pub created_at: String,
}
#[derive(Serialize)]
pub struct SessionUser {
pub id: String,
pub email: String,
pub full_name: String,
pub id: String,
pub email: String,
pub full_name: String,
pub email_verified: bool,
pub roles: Vec<String>,
pub roles: Vec<String>,
pub active_role: Option<String>,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
pub code: String,
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(),
}),
)
(status, Json(ErrorResponse {
error: msg.to_string(),
code: code.to_string(),
status_code: status.as_u16(),
}))
}
// ── Handlers ──────────────────────────────────────────────────────────────────
/// POST /api/auth/register
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 email = payload.email.to_lowercase();
let mut redis = state.redis.clone();
// Rate limit: max 10 registrations per hour per email
if !cache::rate_limit::check_register(&mut redis, &email).await.unwrap_or(true) {
return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many registration attempts. Try again later.", "RATE_LIMITED"));
}
let password_hash = hash_password(&payload.password).map_err(|e| {
err(
StatusCode::INTERNAL_SERVER_ERROR,
&e.to_string(),
"INTERNAL_ERROR",
)
})?;
if payload.password.len() < 8 {
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR"));
}
let user = UserRepository::create(
&state.pool,
CreateUserPayload {
full_name: payload.full_name,
email: payload.email.to_lowercase(),
phone: payload.phone,
password_hash,
},
)
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: email.clone(),
phone: payload.phone.filter(|p| !p.trim().is_empty()),
password_hash,
})
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("users_email_key") || msg.contains("email") && msg.contains("unique") {
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") {
} 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)
// Store OTP in Redis (15-min TTL, keyed by code → user_id)
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
cache::otp::set(&mut redis, &otp, &user.id.to_string())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok();
let _ = state.mail.send_verification_email(&user.email, &user.full_name.unwrap_or_default(), &otp).await;
let _ = state.mail.send_verification_email(&user.email, &user.full_name.clone().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(),
}),
))
Ok((StatusCode::CREATED, Json(RegisterResponse {
user_id: user.id.to_string(),
email: user.email,
phone: user.phone,
full_name: user.full_name.unwrap_or_default(),
status: user.status,
email_verified: user.email_verified,
created_at: user.created_at.to_rfc3339(),
})))
}
/// POST /api/auth/login
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())
let email = payload.email.to_lowercase();
let mut redis = state.redis.clone();
// Rate limit: max 10 login attempts per 15 min per email
if !cache::rate_limit::check_login(&mut redis, &email).await.unwrap_or(true) {
return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many login attempts. Try again in 15 minutes.", "RATE_LIMITED"));
}
let user = UserRepository::get_by_email(&state.pool, &email)
.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"));
return Err(err(StatusCode::UNAUTHORIZED, "Email not verified. Check your inbox.", "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")
})?;
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),
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let tokens = generate_tokens(
user.id.to_string(),
user.email.clone(),
user_roles.clone(),
user_roles.first().cloned(),
&jwt_secret,
)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
// Refresh token → Redis (30-day TTL)
cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_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 active_role = user_roles.first().cloned();
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,
))
Ok((StatusCode::OK, [(SET_COOKIE, cookie)], Json(serde_json::json!({
"access_token": tokens.access_token,
"token_type": "Bearer",
"expires_in": 900,
"user": {
"id": user.id.to_string(),
"email": user.email,
"full_name": user.full_name.unwrap_or_default(),
"email_verified": user.email_verified,
"active_role": active_role,
"roles": user_roles,
}
}))))
}
/// POST /api/auth/logout
async fn logout(
State(state): State<AppState>,
// In real implementation: extract refresh token from cookie header
req: axum::http::Request<axum::body::Body>,
) -> impl IntoResponse {
// TODO: Revoke refresh token from cookie
let _ = &state.pool;
(StatusCode::OK, Json(serde_json::json!({ "message": "Logged out successfully" })))
let cookie_header = req
.headers()
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if let Some(token) = cookie_header
.split(';')
.map(str::trim)
.find_map(|p| p.strip_prefix("nxtgauge_refresh_token="))
{
let mut redis = state.redis.clone();
let _ = cache::token::revoke_refresh(&mut redis, token).await;
}
let clear = "nxtgauge_refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0";
(StatusCode::OK, [(SET_COOKIE, clear)], Json(serde_json::json!({ "message": "Logged out" })))
}
/// POST /api/auth/refresh
async fn refresh(
State(state): State<AppState>,
// In real impl: read httpOnly cookie, not body
Json(payload): Json<serde_json::Value>,
req: axum::http::Request<axum::body::Body>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let token = payload["refresh_token"]
.as_str()
let cookie_header = req
.headers()
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let token = cookie_header
.split(';')
.map(str::trim)
.find_map(|p| p.strip_prefix("nxtgauge_refresh_token="))
.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 mut redis = state.redis.clone();
let user = UserRepository::get_by_id(&state.pool, rt.user_id)
let user_id_str = cache::token::get_refresh(&mut redis, token)
.await
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token invalid", "REFRESH_TOKEN_INVALID"))?
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token expired", "REFRESH_TOKEN_INVALID"))?;
let user_id = user_id_str
.parse::<uuid::Uuid>()
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token corrupt", "REFRESH_TOKEN_INVALID"))?;
let user = UserRepository::get_by_id(&state.pool, user_id)
.await
.map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "INVALID_CREDENTIALS"))?;
let _ = UserRepository::revoke_refresh_token(&state.pool, token).await;
// Rotate: revoke old, issue new
let _ = cache::token::revoke_refresh(&mut redis, 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),
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let tokens = generate_tokens(
user.id.to_string(),
user.email.clone(),
user_roles.clone(),
user_roles.first().cloned(),
&jwt_secret,
)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
Ok((
StatusCode::OK,
Json(serde_json::json!({
"access_token": tokens.access_token,
"expires_in": 900
})),
))
cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
let new_cookie = format!(
"nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000",
tokens.refresh_token
);
Ok((StatusCode::OK, [(SET_COOKIE, new_cookie)], Json(serde_json::json!({
"access_token": tokens.access_token,
"expires_in": 900
}))))
}
/// GET /api/auth/session
async fn session(
auth: AuthUser,
State(state): State<AppState>,
@ -322,113 +346,131 @@ async fn session(
.unwrap_or_default();
Ok(Json(SessionUser {
id: user.id.to_string(),
email: user.email,
full_name: user.full_name.unwrap_or_default(),
id: user.id.to_string(),
email: user.email,
full_name: user.full_name.unwrap_or_default(),
email_verified: user.email_verified,
roles: user_roles,
active_role: user_roles.first().cloned(),
roles: user_roles,
}))
}
/// POST /api/auth/verify-email { "otp": "123456" }
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)
let mut redis = state.redis.clone();
// Atomically consume OTP from Redis (GETDEL — single use, auto-expiry)
let user_id_str = cache::otp::consume(&mut redis, &payload.otp)
.await
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "Cache error", "CACHE_ERROR"))?
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired verification code", "INVALID_CODE"))?;
let user_id = user_id_str
.parse::<uuid::Uuid>()
.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)
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,
}
/// POST /api/auth/resend-otp
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 silent_ok = (StatusCode::OK, Json(serde_json::json!({
"message": "If the email is registered, a new code has been sent"
})));
let otp = format!("{:06}", rand::random::<u32>() % 1000000);
let expires_at = Utc::now() + Duration::minutes(15);
let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await {
Ok(u) => u,
Err(_) => return Ok(silent_ok),
};
if user.email_verified {
return Ok(silent_ok);
}
UserRepository::set_email_verification_token(&state.pool, user.id, &otp, expires_at)
let mut redis = state.redis.clone();
// Rate limit: max 3 resends per hour per user
if !cache::otp::resend_allowed(&mut redis, &user.id.to_string()).await.unwrap_or(true) {
return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many OTP requests. Try again in an hour.", "RATE_LIMITED"));
}
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
cache::otp::set(&mut redis, &otp, &user.id.to_string())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok();
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" }))))
Ok(silent_ok)
}
/// POST /api/auth/forgot-password
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 silent_ok = (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);
let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await {
Ok(u) => u,
Err(_) => return Ok(silent_ok),
};
UserRepository::set_reset_token(&state.pool, user.id, &token, expires_at)
let token = uuid::Uuid::new_v4().to_string();
let mut redis = state.redis.clone();
// Store reset token in Redis (1-hour TTL, consumed single-use on reset)
cache::token::store_reset(&mut redis, &token, &user.id.to_string())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_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" }))))
Ok(silent_ok)
}
/// POST /api/auth/reset-password
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"))?;
let mut redis = state.redis.clone();
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"));
}
}
// Consume reset token from Redis (single-use GETDEL)
let user_id_str = cache::token::consume_reset(&mut redis, &payload.token)
.await
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "Cache error", "CACHE_ERROR"))?
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired reset token", "INVALID_TOKEN"))?;
let user_id = user_id_str
.parse::<uuid::Uuid>()
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid reset token", "INVALID_TOKEN"))?;
if payload.new_password.len() < 8 {
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password minimum 8 characters", "VALIDATION_ERROR"));
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 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")
})?;
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)
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 reset successfully" }))))
}
/// POST /api/auth/change-password
async fn change_password(
auth: AuthUser,
State(state): State<AppState>,
@ -438,17 +480,18 @@ async fn change_password(
.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"))? {
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"));
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 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")
})?;
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
@ -456,3 +499,34 @@ async fn change_password(
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password changed successfully" }))))
}
/// POST /api/auth/switch-role
async fn switch_role(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<SwitchRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let user_roles = UserRepository::get_user_role_keys(&state.pool, auth.user_id)
.await
.unwrap_or_default();
let requested = payload.role_key.to_uppercase();
if !user_roles.contains(&requested) {
return Err(err(StatusCode::FORBIDDEN, "You do not have this role", "ROLE_NOT_FOUND"));
}
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let tokens = generate_tokens(
auth.user_id.to_string(),
auth.email.clone(),
user_roles,
Some(requested),
&jwt_secret,
)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
Ok((StatusCode::OK, Json(serde_json::json!({
"access_token": tokens.access_token,
"expires_in": 900
}))))
}

View file

@ -0,0 +1,194 @@
use crate::AppState;
use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use db::models::{
onboarding_state::OnboardingStateRepository,
role::RoleRepository,
};
use serde::{Deserialize, Serialize};
// ── Routers ───────────────────────────────────────────────────────────────────
pub fn onboarding_router() -> Router<AppState> {
Router::new()
.route("/state", get(get_state))
.route("/save-progress", post(save_progress))
.route("/submit", post(submit))
}
pub fn me_router() -> Router<AppState> {
Router::new()
.route("/profile-status", get(profile_status))
}
// ── DTOs ──────────────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct RoleKeyQuery {
#[serde(rename = "roleKey")]
pub role_key: Option<String>,
}
// Accept role_key (string) so the frontend never has to know the internal UUID.
#[derive(Deserialize)]
pub struct SaveProgressInput {
#[serde(rename = "roleKey", alias = "role_key")]
pub role_key: String,
pub progress_json: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct SubmitInput {
#[serde(rename = "roleKey", alias = "role_key")]
pub role_key: String,
pub progress_json: Option<serde_json::Value>,
}
#[derive(Serialize)]
pub struct ProfileStatusResponse {
pub onboarding_complete: bool,
pub active_role: Option<String>,
pub roles: Vec<String>,
pub email_verified: bool,
}
// ── Handlers ──────────────────────────────────────────────────────────────────
/// GET /api/onboarding/state?roleKey=COMPANY
async fn get_state(
auth: AuthUser,
State(state): State<AppState>,
Query(query): Query<RoleKeyQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let role_key = query
.role_key
.filter(|k| !k.is_empty())
.unwrap_or_else(|| auth.claims.active_role.clone());
let role = RoleRepository::get_by_key(&state.pool, &role_key)
.await
.map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", role_key)))?;
let onboarding_state =
OnboardingStateRepository::get(&state.pool, auth.user_id, role.id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let response = match onboarding_state {
Some(s) => serde_json::json!({
"status": s.status,
"currentStep": s.progress_json.as_ref()
.and_then(|p| p.get("step"))
.and_then(|v| v.as_i64())
.unwrap_or(0),
"progress": s.progress_json,
"completed_at": s.completed_at,
"role_key": role_key,
}),
None => serde_json::json!({
"status": "NOT_STARTED",
"currentStep": 0,
"progress": null,
"completed_at": null,
"role_key": role_key,
}),
};
Ok((StatusCode::OK, Json(response)))
}
/// POST /api/onboarding/save-progress
/// Body: { roleKey: "PHOTOGRAPHER", progress_json: { step: 2, total: 6, data: {...} } }
async fn save_progress(
auth: AuthUser,
State(state): State<AppState>,
Json(input): Json<SaveProgressInput>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let role = RoleRepository::get_by_key(&state.pool, &input.role_key)
.await
.map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", input.role_key)))?;
let progress = input.progress_json.unwrap_or(serde_json::Value::Object(Default::default()));
let saved = OnboardingStateRepository::save_progress(
&state.pool,
auth.user_id,
role.id,
&progress,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((StatusCode::OK, Json(saved)))
}
/// POST /api/onboarding/submit
/// Body: { roleKey: "PHOTOGRAPHER", progress_json: { ...all form values... } }
async fn submit(
auth: AuthUser,
State(state): State<AppState>,
Json(input): Json<SubmitInput>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let role = RoleRepository::get_by_key(&state.pool, &input.role_key)
.await
.map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", input.role_key)))?;
let progress = input.progress_json.unwrap_or(serde_json::Value::Object(Default::default()));
let completed = OnboardingStateRepository::complete(
&state.pool,
auth.user_id,
role.id,
&progress,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((StatusCode::OK, Json(completed)))
}
/// GET /api/me/profile-status
async fn profile_status(
auth: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
use db::models::user::UserRepository;
let user = UserRepository::get_by_id(&state.pool, auth.user_id)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
let roles = UserRepository::get_user_role_keys(&state.pool, auth.user_id)
.await
.unwrap_or_default();
let active_role = auth.claims.active_role.clone();
let active_role_opt = if active_role.is_empty() { None } else { Some(active_role.clone()) };
let onboarding_complete = if let Some(ref role_key) = active_role_opt {
match RoleRepository::get_by_key(&state.pool, role_key).await {
Ok(role) => OnboardingStateRepository::is_complete(&state.pool, auth.user_id, role.id)
.await
.unwrap_or(false),
Err(_) => false,
}
} else {
false
};
Ok((
StatusCode::OK,
Json(ProfileStatusResponse {
onboarding_complete,
active_role: active_role_opt,
roles,
email_verified: user.email_verified,
}),
))
}

View file

@ -1,18 +1,18 @@
mod handlers;
mod mail;
use axum::{Router, http::Method};
use axum::{routing::get, Router};
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>,
pub pool: PgPool,
pub mail: Arc<Mailer>,
pub redis: cache::RedisPool,
}
#[tokio::main]
@ -24,6 +24,9 @@ async fn main() {
.with(tracing_subscriber::fmt::layer())
.init();
// Fail fast — critical env vars must be present before binding any port
std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
@ -33,18 +36,21 @@ async fn main() {
tracing::info!("Connected to the database");
let redis_url = std::env::var("REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
let redis = cache::connect(&redis_url)
.await
.expect("Failed to connect to Redis");
tracing::info!("Connected to Redis");
let mailer = Arc::new(Mailer::new());
let state = AppState {
pool,
mail: mailer,
mail: mailer,
redis,
};
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())
@ -52,6 +58,10 @@ async fn main() {
.nest("/api/admin/roles", handlers::roles::router())
// ── Notifications ─────────────────────────────────────────────────
.nest("/api/me/notifications", handlers::notifications::router())
// ── Me: Profile Status ─────────────────────────────────────────────
.nest("/api/me", handlers::onboarding::me_router())
// ── Onboarding State (user-facing) ────────────────────────────────
.nest("/api/onboarding", handlers::onboarding::onboarding_router())
// ── Admin: Onboarding + Dashboard Config ──────────────────────────
.nest("/api/admin/onboarding-config", handlers::config::onboarding_router())
.nest("/api/admin/dashboard-config", handlers::config::dashboard_router())
@ -59,7 +69,7 @@ async fn main() {
.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)
.route("/health", get(|| async { "Users OK" }))
.with_state(state);

View file

@ -15,4 +15,5 @@ chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }

View file

@ -1,32 +1,28 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::{get, patch}, Json, Router};
use sqlx::PgPool;
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use db::models::video_editor::{VideoEditorRepository, UpsertVideoEditorProfilePayload};
use contracts::auth_middleware::AuthUser;
use contracts::{auth_middleware::AuthUser, ProfessionState};
pub fn router() -> Router<PgPool> {
pub fn router() -> Router<ProfessionState> {
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 get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match VideoEditorRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(p)) => (StatusCode::OK, Json(p)).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>,
State(state): State<ProfessionState>,
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(),
match VideoEditorRepository::upsert(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -1,11 +1,9 @@
mod handlers;
use axum::{Router, http::Method};
use axum::{routing::get, Router};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::profession_shared::shared_routes;
use contracts::ProfessionState;
#[tokio::main]
async fn main() {
@ -23,20 +21,29 @@ async fn main() {
.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 redis_url = std::env::var("REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
let redis = cache::connect(&redis_url)
.await
.expect("Failed to connect to Redis");
tracing::info!("Video Editors service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
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);
.route("/health", get(|| async { "Video Editors OK" }))
.with_state(state);
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8089".to_string()).parse().unwrap();
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8089".to_string())
.parse()
.expect("PORT must be a number");
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();
}

13
crates/cache/Cargo.toml vendored Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "cache"
version = "0.1.0"
edition = "2021"
[dependencies]
redis = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }

10
crates/cache/src/client.rs vendored Normal file
View file

@ -0,0 +1,10 @@
use redis::aio::ConnectionManager;
/// Thread-safe, auto-reconnecting Redis handle.
/// Clone is cheap — all clones share the same underlying multiplexed connection.
pub type RedisPool = ConnectionManager;
pub async fn connect(url: &str) -> Result<RedisPool, redis::RedisError> {
let client = redis::Client::open(url)?;
ConnectionManager::new(client).await
}

42
crates/cache/src/jobs.rs vendored Normal file
View file

@ -0,0 +1,42 @@
//! Marketplace listing cache.
//!
//! Key: `jobs:marketplace:{profession}:{page}:{limit}` → JSON, TTL 5 min
use redis::AsyncCommands;
use crate::RedisPool;
const CACHE_TTL: u64 = 300; // 5 minutes
pub async fn get_marketplace(
redis: &mut RedisPool,
profession: &str,
page: i64,
limit: i64,
) -> Result<Option<String>, redis::RedisError> {
let key = format!("jobs:marketplace:{profession}:{page}:{limit}");
redis.get(key).await
}
pub async fn set_marketplace(
redis: &mut RedisPool,
profession: &str,
page: i64,
limit: i64,
data: &str,
) -> Result<(), redis::RedisError> {
let key = format!("jobs:marketplace:{profession}:{page}:{limit}");
redis.set_ex(key, data, CACHE_TTL).await
}
/// Invalidate all marketplace caches for a profession (call when a profile is updated).
pub async fn invalidate_marketplace(
redis: &mut RedisPool,
profession: &str,
) -> Result<(), redis::RedisError> {
let pattern = format!("jobs:marketplace:{profession}:*");
let keys: Vec<String> = redis.keys(pattern).await?;
if !keys.is_empty() {
redis.del(keys).await?;
}
Ok(())
}

33
crates/cache/src/lead.rs vendored Normal file
View file

@ -0,0 +1,33 @@
//! Lead request deduplication.
//!
//! Prevents a professional from sending more than one lead request
//! to the same requirement within 24 hours.
//!
//! Key: `lead_dedup:{professional_id}:{requirement_id}` → "1", TTL 24 h
use redis::AsyncCommands;
use crate::RedisPool;
const DEDUP_TTL: u64 = 24 * 3_600; // 24 hours
/// Returns `true` if the professional has already sent a lead request
/// for this requirement in the last 24 hours.
pub async fn is_duplicate(
redis: &mut RedisPool,
professional_id: &str,
requirement_id: &str,
) -> Result<bool, redis::RedisError> {
let key = format!("lead_dedup:{professional_id}:{requirement_id}");
let exists: bool = redis.exists(key).await?;
Ok(exists)
}
/// Mark a lead request as sent. Call after successfully creating it in the DB.
pub async fn mark_sent(
redis: &mut RedisPool,
professional_id: &str,
requirement_id: &str,
) -> Result<(), redis::RedisError> {
let key = format!("lead_dedup:{professional_id}:{requirement_id}");
redis.set_ex(key, "1", DEDUP_TTL).await
}

8
crates/cache/src/lib.rs vendored Normal file
View file

@ -0,0 +1,8 @@
pub mod client;
pub mod otp;
pub mod rate_limit;
pub mod token;
pub mod lead;
pub mod jobs;
pub use client::{RedisPool, connect};

49
crates/cache/src/otp.rs vendored Normal file
View file

@ -0,0 +1,49 @@
//! OTP storage and rate-limiting.
//!
//! Keys
//! ────
//! `otp:code:{6-digit-code}` → user_id string, TTL 15 min
//! `otp:resend:{user_id}` → resend attempt counter, TTL 1 hour (max 3)
use redis::AsyncCommands;
use crate::RedisPool;
const OTP_TTL_SECS: u64 = 900; // 15 minutes
const RESEND_WINDOW_SECS: i64 = 3_600; // 1 hour
const RESEND_MAX: i64 = 3;
// ── Store / verify ────────────────────────────────────────────────────────────
/// Store OTP code keyed by the code itself → user_id. TTL 15 min.
pub async fn set(redis: &mut RedisPool, code: &str, user_id: &str) -> Result<(), redis::RedisError> {
let key = format!("otp:code:{code}");
redis.set_ex(key, user_id, OTP_TTL_SECS).await
}
/// Atomically fetch the user_id for this OTP and delete it (single-use).
/// Returns `None` if the code doesn't exist or has expired.
pub async fn consume(redis: &mut RedisPool, code: &str) -> Result<Option<String>, redis::RedisError> {
let key = format!("otp:code:{code}");
// GETDEL: atomic get + delete (Redis ≥ 6.2)
redis.get_del(key).await
}
// ── Resend rate limit ─────────────────────────────────────────────────────────
/// Returns `true` if the user is allowed to request another OTP (< 3 in last hour).
pub async fn resend_allowed(redis: &mut RedisPool, user_id: &str) -> Result<bool, redis::RedisError> {
let key = format!("otp:resend:{user_id}");
let count: i64 = redis.get(&key).await.unwrap_or(0);
Ok(count < RESEND_MAX)
}
/// Increment the resend counter. Call after sending the OTP.
pub async fn record_resend(redis: &mut RedisPool, user_id: &str) -> Result<(), redis::RedisError> {
let key = format!("otp:resend:{user_id}");
let count: i64 = redis.incr(&key, 1i64).await?;
// Only set expiry on first increment so window is fixed from first request
if count == 1 {
redis.expire(&key, RESEND_WINDOW_SECS).await?;
}
Ok(())
}

52
crates/cache/src/rate_limit.rs vendored Normal file
View file

@ -0,0 +1,52 @@
//! Generic sliding-window rate limiter.
//!
//! Key pattern: `rate:{namespace}:{identifier}`
//! Returns `Ok(true)` if the request is allowed, `Ok(false)` if rate-limited.
use redis::AsyncCommands;
use crate::RedisPool;
/// Check + increment a rate-limit counter.
///
/// * `namespace` e.g. `"login"`, `"register"`, `"lead"`
/// * `identifier` e.g. email, IP, user_id
/// * `max` maximum requests allowed in `window_secs`
/// * `window_secs` sliding window length in seconds
///
/// Returns `Ok(true)` = allowed, `Ok(false)` = blocked.
pub async fn check(
redis: &mut RedisPool,
namespace: &str,
identifier: &str,
max: i64,
window_secs: i64,
) -> Result<bool, redis::RedisError> {
let key = format!("rate:{namespace}:{identifier}");
let count: i64 = redis.incr(&key, 1i64).await?;
if count == 1 {
redis.expire(&key, window_secs).await?;
}
Ok(count <= max)
}
/// Convenience wrappers ───────────────────────────────────────────────────────
/// Register: max 10 per hour per email
pub async fn check_register(redis: &mut RedisPool, email: &str) -> Result<bool, redis::RedisError> {
check(redis, "register", email, 10, 3_600).await
}
/// Login: max 10 attempts per 15 min per email
pub async fn check_login(redis: &mut RedisPool, email: &str) -> Result<bool, redis::RedisError> {
check(redis, "login", email, 10, 900).await
}
/// Lead request: max 5 per hour per professional
pub async fn check_lead(redis: &mut RedisPool, professional_id: &str) -> Result<bool, redis::RedisError> {
check(redis, "lead", professional_id, 5, 3_600).await
}
/// Job post: max 20 per hour per company
pub async fn check_job_post(redis: &mut RedisPool, company_id: &str) -> Result<bool, redis::RedisError> {
check(redis, "job_post", company_id, 20, 3_600).await
}

64
crates/cache/src/token.rs vendored Normal file
View file

@ -0,0 +1,64 @@
//! Auth token management in Redis.
//!
//! Refresh tokens
//! ──────────────
//! `refresh:{token}` → user_id, TTL 30 days
//!
//! Password-reset tokens
//! ─────────────────────
//! `reset:{token}` → user_id, TTL 1 hour
use redis::AsyncCommands;
use crate::RedisPool;
const REFRESH_TTL: u64 = 30 * 24 * 3_600; // 30 days in seconds
const RESET_TTL: u64 = 3_600; // 1 hour
// ── Refresh tokens ────────────────────────────────────────────────────────────
pub async fn store_refresh(
redis: &mut RedisPool,
token: &str,
user_id: &str,
) -> Result<(), redis::RedisError> {
let key = format!("refresh:{token}");
redis.set_ex(key, user_id, REFRESH_TTL).await
}
/// Returns the user_id string associated with this token, or `None` if expired/invalid.
pub async fn get_refresh(
redis: &mut RedisPool,
token: &str,
) -> Result<Option<String>, redis::RedisError> {
let key = format!("refresh:{token}");
redis.get(key).await
}
/// Delete refresh token (logout / rotation).
pub async fn revoke_refresh(
redis: &mut RedisPool,
token: &str,
) -> Result<(), redis::RedisError> {
let key = format!("refresh:{token}");
redis.del(key).await
}
// ── Password-reset tokens ─────────────────────────────────────────────────────
pub async fn store_reset(
redis: &mut RedisPool,
token: &str,
user_id: &str,
) -> Result<(), redis::RedisError> {
let key = format!("reset:{token}");
redis.set_ex(key, user_id, RESET_TTL).await
}
/// Atomically fetch and delete the reset token (single-use).
pub async fn consume_reset(
redis: &mut RedisPool,
token: &str,
) -> Result<Option<String>, redis::RedisError> {
let key = format!("reset:{token}");
redis.get_del(key).await
}

View file

@ -0,0 +1,16 @@
[package]
name = "contracts"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
jsonwebtoken = "9.3"
db = { path = "../db" }
cache = { path = "../cache" }

View file

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

View file

@ -5,13 +5,14 @@ use axum::{
routing::{delete, get, patch, post},
Json, Router,
};
use chrono::Utc;
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use db::models::lead_request::{CreateLeadRequestPayload, LeadRequestRepository};
use db::models::professional::ProfessionalRepository;
use db::models::requirement::RequirementRepository;
use db::models::lead_request::{LeadRequestRepository, CreateLeadRequestPayload};
use crate::auth_middleware::AuthUser;
use crate::ProfessionState;
#[derive(Deserialize)]
pub struct PaginationQuery {
@ -24,76 +25,126 @@ pub struct LeadRequestPayload {
pub requirement_id: Uuid,
}
pub fn shared_routes(profession_key: &'static str) -> Router<PgPool> {
/// Build the shared Router that every profession service merges into its own Router.
/// `profession_key` must be a `'static str` matching the role key, e.g. `"PHOTOGRAPHER"`.
pub fn shared_routes(profession_key: &'static str) -> Router<ProfessionState> {
Router::new()
// ── Marketplace (Redis-cached) ────────────────────────────────────────
.route(
"/marketplace",
get(move |state, query| browse_marketplace(state, query, profession_key)),
get(move |State(state): State<ProfessionState>, Query(q): Query<PaginationQuery>| async move {
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
// Try cache first
let mut redis = state.redis.clone();
if let Ok(Some(cached)) = cache::jobs::get_marketplace(&mut redis, profession_key, page, limit).await {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&cached) {
return (StatusCode::OK, Json(parsed)).into_response();
}
}
match ProfessionalRepository::get_marketplace(&state.pool, profession_key, page, limit).await {
Ok(items) => {
let body = serde_json::json!({
"data": items,
"pagination": { "page": page, "limit": limit }
});
// Write to cache (best-effort)
let _ = cache::jobs::set_marketplace(
&mut redis,
profession_key,
page,
limit,
&body.to_string(),
).await;
(StatusCode::OK, Json(body)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}),
)
.route("/marketplace/:id", get(get_requirement))
// ── 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))
.route("/portfolio/me", get(list_portfolio))
// ... (other routes remain same for now)
// ── Portfolio ────────────────────────────────────────────────────────
.route("/portfolio/me", get(list_portfolio).post(create_portfolio_item))
.route("/portfolio/me/:id", patch(update_portfolio_item).delete(delete_portfolio_item))
// ── Services ─────────────────────────────────────────────────────────
.route("/services/me", get(list_services).post(create_service))
.route("/services/me/:id", patch(update_service).delete(delete_service))
// ── Wallet ───────────────────────────────────────────────────────────
.route("/wallet/me", get(wallet_balance))
.route("/wallet/me/ledger", get(wallet_ledger))
.route("/wallet/me/invoices", get(wallet_invoices))
.route("/wallet/me/invoices/:id", get(wallet_invoice_detail))
}
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(),
}
}
// ── Handlers ──────────────────────────────────────────────────────────────────
async fn get_requirement(
State(pool): State<PgPool>,
State(state): State<ProfessionState>,
_auth: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match RequirementRepository::get_by_id(&pool, id).await {
match RequirementRepository::get_by_id(&state.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(),
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>,
State(state): State<ProfessionState>,
auth: AuthUser,
Json(payload): Json<LeadRequestPayload>,
) -> impl IntoResponse {
let prof = match ProfessionalRepository::get_by_user_id(&pool, auth.user_id).await {
Ok(p) => p,
let mut redis = state.redis.clone();
// ── Rate limit: max 5 lead requests per hour per professional ─────────────
let allowed = cache::rate_limit::check_lead(&mut redis, &auth.user_id.to_string())
.await
.unwrap_or(true);
if !allowed {
return (StatusCode::TOO_MANY_REQUESTS, "Too many lead requests. Try again later.").into_response();
}
let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
};
let req = match RequirementRepository::get_by_id(&pool, payload.requirement_id).await {
// ── Deduplication: one lead per requirement per professional (24 h) ────────
let duplicate = cache::lead::is_duplicate(
&mut redis,
&prof.id.to_string(),
&payload.requirement_id.to_string(),
)
.await
.unwrap_or(false);
if duplicate {
return (StatusCode::CONFLICT, "You have already sent a lead request for this requirement").into_response();
}
let req = match RequirementRepository::get_by_id(&state.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(),
_ => 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(),
let wallet = match ProfessionalRepository::get_wallet(&state.pool, auth.user_id).await {
Ok(w) => w,
Err(_) => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(),
};
if wallet.balance < 25 {
@ -101,17 +152,23 @@ async fn send_lead_request(
}
let db_payload = CreateLeadRequestPayload {
requirement_id: req.id,
requirement_id: req.id,
professional_id: prof.id,
expires_at: Utc::now() + chrono::Duration::hours(24),
};
match LeadRequestRepository::create(&pool, db_payload).await {
match LeadRequestRepository::create(&state.pool, db_payload).await {
Ok(lead) => {
let _ = RequirementRepository::increment_request_count(&pool, req.id).await;
// TODO: Debit/Reserve Tracecoins in wallet ledger
let _ = RequirementRepository::increment_request_count(&state.pool, req.id).await;
// Mark dedup in Redis so this professional can't spam the same requirement
let _ = cache::lead::mark_sent(
&mut redis,
&prof.id.to_string(),
&payload.requirement_id.to_string(),
)
.await;
(StatusCode::CREATED, Json(lead)).into_response()
},
}
Err(e) => {
if e.to_string().contains("unique") {
(StatusCode::CONFLICT, "Already requested this lead").into_response()
@ -122,58 +179,137 @@ async fn send_lead_request(
}
}
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(),
}
async fn list_portfolio(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(prof) => match ProfessionalRepository::get_portfolio(&state.pool, prof.id).await {
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(),
}
async fn list_services(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(prof) => match ProfessionalRepository::get_services(&state.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(),
async fn wallet_balance(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
match ProfessionalRepository::get_wallet(&state.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()}))) }
// ── Stub handlers ─────────────────────────────────────────────────────────────
async fn my_requests(
_s: State<ProfessionState>,
_a: AuthUser,
_q: Query<PaginationQuery>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
}
async fn cancel_request(
_s: State<ProfessionState>,
_a: AuthUser,
_p: Path<Uuid>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "message": "Cancelled" })))
}
async fn accepted_leads(
_s: State<ProfessionState>,
_a: AuthUser,
_q: Query<PaginationQuery>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
}
async fn accepted_lead_detail(
_s: State<ProfessionState>,
_a: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() })))
}
async fn create_portfolio_item(
_s: State<ProfessionState>,
_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<ProfessionState>,
_a: AuthUser,
_id: Path<Uuid>,
_p: Json<serde_json::Value>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "message": "Updated" })))
}
async fn delete_portfolio_item(
_s: State<ProfessionState>,
_a: AuthUser,
_id: Path<Uuid>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "message": "Deleted" })))
}
async fn create_service(
_s: State<ProfessionState>,
_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<ProfessionState>,
_a: AuthUser,
_id: Path<Uuid>,
_p: Json<serde_json::Value>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "message": "Updated" })))
}
async fn delete_service(
_s: State<ProfessionState>,
_a: AuthUser,
_id: Path<Uuid>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "message": "Deleted" })))
}
async fn wallet_ledger(
_s: State<ProfessionState>,
_a: AuthUser,
_q: Query<PaginationQuery>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
}
async fn wallet_invoices(
_s: State<ProfessionState>,
_a: AuthUser,
_q: Query<PaginationQuery>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
}
async fn wallet_invoice_detail(
_s: State<ProfessionState>,
_a: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() })))
}

View file

@ -0,0 +1,10 @@
use sqlx::PgPool;
use cache::RedisPool;
/// Shared state for all 9 profession micro-services.
/// Passed as the Axum router state — replaces the bare `PgPool`.
#[derive(Clone)]
pub struct ProfessionState {
pub pool: PgPool,
pub redis: RedisPool,
}

View file

@ -0,0 +1,9 @@
ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS custom_data;
ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS custom_data;
ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS custom_data;
ALTER TABLE developer_profiles DROP COLUMN IF EXISTS custom_data;
ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS custom_data;
ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS custom_data;
ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS custom_data;
ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS custom_data;
ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS custom_data;

View file

@ -0,0 +1,24 @@
-- Make display_name / business_name nullable so upserts can work
-- without forcing the name on every call.
-- Add custom_data JSONB to every profession table so all onboarding
-- form fields are preserved even if they don't have a dedicated column.
ALTER TABLE photographer_profiles ALTER COLUMN display_name DROP NOT NULL;
ALTER TABLE tutor_profiles ALTER COLUMN display_name DROP NOT NULL;
ALTER TABLE makeup_artist_profiles ALTER COLUMN display_name DROP NOT NULL;
ALTER TABLE developer_profiles ALTER COLUMN display_name DROP NOT NULL;
ALTER TABLE video_editor_profiles ALTER COLUMN display_name DROP NOT NULL;
ALTER TABLE graphic_designer_profiles ALTER COLUMN display_name DROP NOT NULL;
ALTER TABLE social_media_manager_profiles ALTER COLUMN display_name DROP NOT NULL;
ALTER TABLE fitness_trainer_profiles ALTER COLUMN display_name DROP NOT NULL;
ALTER TABLE catering_service_profiles ALTER COLUMN business_name DROP NOT NULL;
ALTER TABLE photographer_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB;
ALTER TABLE tutor_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB;
ALTER TABLE makeup_artist_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB;
ALTER TABLE developer_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB;
ALTER TABLE video_editor_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB;
ALTER TABLE graphic_designer_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB;
ALTER TABLE social_media_manager_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB;
ALTER TABLE fitness_trainer_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB;
ALTER TABLE catering_service_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB;

View file

@ -3,77 +3,58 @@ use serde::{Deserialize, Serialize};
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
// catering_service_profiles uses "business_name" instead of "display_name"
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct CateringServiceProfile {
pub id: Uuid,
pub user_id: Uuid,
pub business_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertCateringServiceProfilePayload {
pub business_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
}
pub struct CateringServiceRepository;
impl CateringServiceRepository {
pub async fn get_by_user_id(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<CateringServiceProfile>, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<CateringServiceProfile>, sqlx::Error> {
sqlx::query_as!(
CateringServiceProfile,
r#"
SELECT
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
FROM catering_service_profiles
WHERE user_id = $1
"#,
r#"SELECT id, user_id, business_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at
FROM catering_service_profiles WHERE user_id = $1"#,
user_id
)
.fetch_optional(pool)
.await?;
Ok(profile)
).fetch_optional(pool).await
}
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
payload: UpsertCateringServiceProfilePayload,
) -> Result<CateringServiceProfile, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertCateringServiceProfilePayload) -> Result<CateringServiceProfile, sqlx::Error> {
sqlx::query_as!(
CateringServiceProfile,
r#"
INSERT INTO catering_service_profiles (
user_id, bio, experience_years, custom_data
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET
bio = EXCLUDED.bio,
experience_years = EXCLUDED.experience_years,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
"#,
user_id,
payload.bio,
payload.experience_years,
payload.custom_data
)
.fetch_one(pool)
.await?;
Ok(profile)
r#"INSERT INTO catering_service_profiles (user_id, business_name, bio, location, custom_data)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id) DO UPDATE SET
business_name = COALESCE(EXCLUDED.business_name, catering_service_profiles.business_name),
bio = EXCLUDED.bio,
location = EXCLUDED.location,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING id, user_id, business_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at"#,
user_id, p.business_name, p.bio, p.location, p.custom_data
).fetch_one(pool).await
}
}

View file

@ -7,73 +7,52 @@ use uuid::Uuid;
pub struct DeveloperProfile {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertDeveloperProfilePayload {
pub display_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
}
pub struct DeveloperRepository;
impl DeveloperRepository {
pub async fn get_by_user_id(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<DeveloperProfile>, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<DeveloperProfile>, sqlx::Error> {
sqlx::query_as!(
DeveloperProfile,
r#"
SELECT
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
FROM developer_profiles
WHERE user_id = $1
"#,
r#"SELECT id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at
FROM developer_profiles WHERE user_id = $1"#,
user_id
)
.fetch_optional(pool)
.await?;
Ok(profile)
).fetch_optional(pool).await
}
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
payload: UpsertDeveloperProfilePayload,
) -> Result<DeveloperProfile, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertDeveloperProfilePayload) -> Result<DeveloperProfile, sqlx::Error> {
sqlx::query_as!(
DeveloperProfile,
r#"
INSERT INTO developer_profiles (
user_id, bio, experience_years, custom_data
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET
bio = EXCLUDED.bio,
experience_years = EXCLUDED.experience_years,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
"#,
user_id,
payload.bio,
payload.experience_years,
payload.custom_data
)
.fetch_one(pool)
.await?;
Ok(profile)
r#"INSERT INTO developer_profiles (user_id, display_name, bio, location, custom_data)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id) DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, developer_profiles.display_name),
bio = EXCLUDED.bio,
location = EXCLUDED.location,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at"#,
user_id, p.display_name, p.bio, p.location, p.custom_data
).fetch_one(pool).await
}
}

View file

@ -7,73 +7,52 @@ use uuid::Uuid;
pub struct FitnessTrainerProfile {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertFitnessTrainerProfilePayload {
pub display_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
}
pub struct FitnessTrainerRepository;
impl FitnessTrainerRepository {
pub async fn get_by_user_id(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<FitnessTrainerProfile>, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<FitnessTrainerProfile>, sqlx::Error> {
sqlx::query_as!(
FitnessTrainerProfile,
r#"
SELECT
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
FROM fitness_trainer_profiles
WHERE user_id = $1
"#,
r#"SELECT id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at
FROM fitness_trainer_profiles WHERE user_id = $1"#,
user_id
)
.fetch_optional(pool)
.await?;
Ok(profile)
).fetch_optional(pool).await
}
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
payload: UpsertFitnessTrainerProfilePayload,
) -> Result<FitnessTrainerProfile, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertFitnessTrainerProfilePayload) -> Result<FitnessTrainerProfile, sqlx::Error> {
sqlx::query_as!(
FitnessTrainerProfile,
r#"
INSERT INTO fitness_trainer_profiles (
user_id, bio, experience_years, custom_data
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET
bio = EXCLUDED.bio,
experience_years = EXCLUDED.experience_years,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
"#,
user_id,
payload.bio,
payload.experience_years,
payload.custom_data
)
.fetch_one(pool)
.await?;
Ok(profile)
r#"INSERT INTO fitness_trainer_profiles (user_id, display_name, bio, location, custom_data)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id) DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, fitness_trainer_profiles.display_name),
bio = EXCLUDED.bio,
location = EXCLUDED.location,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at"#,
user_id, p.display_name, p.bio, p.location, p.custom_data
).fetch_one(pool).await
}
}

View file

@ -7,73 +7,52 @@ use uuid::Uuid;
pub struct GraphicDesignerProfile {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertGraphicDesignerProfilePayload {
pub display_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
}
pub struct GraphicDesignerRepository;
impl GraphicDesignerRepository {
pub async fn get_by_user_id(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<GraphicDesignerProfile>, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<GraphicDesignerProfile>, sqlx::Error> {
sqlx::query_as!(
GraphicDesignerProfile,
r#"
SELECT
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
FROM graphic_designer_profiles
WHERE user_id = $1
"#,
r#"SELECT id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at
FROM graphic_designer_profiles WHERE user_id = $1"#,
user_id
)
.fetch_optional(pool)
.await?;
Ok(profile)
).fetch_optional(pool).await
}
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
payload: UpsertGraphicDesignerProfilePayload,
) -> Result<GraphicDesignerProfile, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertGraphicDesignerProfilePayload) -> Result<GraphicDesignerProfile, sqlx::Error> {
sqlx::query_as!(
GraphicDesignerProfile,
r#"
INSERT INTO graphic_designer_profiles (
user_id, bio, experience_years, custom_data
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET
bio = EXCLUDED.bio,
experience_years = EXCLUDED.experience_years,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
"#,
user_id,
payload.bio,
payload.experience_years,
payload.custom_data
)
.fetch_one(pool)
.await?;
Ok(profile)
r#"INSERT INTO graphic_designer_profiles (user_id, display_name, bio, location, custom_data)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id) DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, graphic_designer_profiles.display_name),
bio = EXCLUDED.bio,
location = EXCLUDED.location,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at"#,
user_id, p.display_name, p.bio, p.location, p.custom_data
).fetch_one(pool).await
}
}

View file

@ -7,73 +7,52 @@ use uuid::Uuid;
pub struct MakeupArtistProfile {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertMakeupArtistProfilePayload {
pub display_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
}
pub struct MakeupArtistRepository;
impl MakeupArtistRepository {
pub async fn get_by_user_id(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<MakeupArtistProfile>, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<MakeupArtistProfile>, sqlx::Error> {
sqlx::query_as!(
MakeupArtistProfile,
r#"
SELECT
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
FROM makeup_artist_profiles
WHERE user_id = $1
"#,
r#"SELECT id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at
FROM makeup_artist_profiles WHERE user_id = $1"#,
user_id
)
.fetch_optional(pool)
.await?;
Ok(profile)
).fetch_optional(pool).await
}
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
payload: UpsertMakeupArtistProfilePayload,
) -> Result<MakeupArtistProfile, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertMakeupArtistProfilePayload) -> Result<MakeupArtistProfile, sqlx::Error> {
sqlx::query_as!(
MakeupArtistProfile,
r#"
INSERT INTO makeup_artist_profiles (
user_id, bio, experience_years, custom_data
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET
bio = EXCLUDED.bio,
experience_years = EXCLUDED.experience_years,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
"#,
user_id,
payload.bio,
payload.experience_years,
payload.custom_data
)
.fetch_one(pool)
.await?;
Ok(profile)
r#"INSERT INTO makeup_artist_profiles (user_id, display_name, bio, location, custom_data)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id) DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, makeup_artist_profiles.display_name),
bio = EXCLUDED.bio,
location = EXCLUDED.location,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at"#,
user_id, p.display_name, p.bio, p.location, p.custom_data
).fetch_one(pool).await
}
}

View file

@ -0,0 +1,135 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
// ── Structs ───────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct OnboardingState {
pub id: Uuid,
pub user_id: Uuid,
pub role_id: Uuid,
pub status: String, // NOT_STARTED | IN_PROGRESS | COMPLETED
pub progress_json: Value,
pub completed_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct SaveProgressPayload {
pub role_id: Uuid,
pub progress_json: Value,
}
#[derive(Debug, Deserialize)]
pub struct SubmitOnboardingPayload {
pub role_id: Uuid,
pub progress_json: Value,
}
// ── Repository ────────────────────────────────────────────────────────────────
pub struct OnboardingStateRepository;
impl OnboardingStateRepository {
/// Fetch onboarding state for a user + role. Returns None if no record exists yet.
pub async fn get(
pool: &PgPool,
user_id: Uuid,
role_id: Uuid,
) -> Result<Option<OnboardingState>, sqlx::Error> {
sqlx::query_as!(
OnboardingState,
r#"
SELECT id, user_id, role_id, status, progress_json,
completed_at, created_at, updated_at
FROM onboarding_states
WHERE user_id = $1 AND role_id = $2
"#,
user_id,
role_id,
)
.fetch_optional(pool)
.await
}
/// Upsert progress — inserts a new record or updates progress on conflict.
pub async fn save_progress(
pool: &PgPool,
user_id: Uuid,
role_id: Uuid,
progress: &Value,
) -> Result<OnboardingState, sqlx::Error> {
sqlx::query_as!(
OnboardingState,
r#"
INSERT INTO onboarding_states (user_id, role_id, status, progress_json)
VALUES ($1, $2, 'IN_PROGRESS', $3)
ON CONFLICT (user_id, role_id) DO UPDATE
SET status = CASE
WHEN onboarding_states.status = 'COMPLETED' THEN 'COMPLETED'
ELSE 'IN_PROGRESS'
END,
progress_json = EXCLUDED.progress_json,
updated_at = NOW()
RETURNING id, user_id, role_id, status, progress_json,
completed_at, created_at, updated_at
"#,
user_id,
role_id,
progress,
)
.fetch_one(pool)
.await
}
/// Mark onboarding as COMPLETED and freeze the final answers.
pub async fn complete(
pool: &PgPool,
user_id: Uuid,
role_id: Uuid,
final_answers: &Value,
) -> Result<OnboardingState, sqlx::Error> {
sqlx::query_as!(
OnboardingState,
r#"
INSERT INTO onboarding_states (user_id, role_id, status, progress_json, completed_at)
VALUES ($1, $2, 'COMPLETED', $3, NOW())
ON CONFLICT (user_id, role_id) DO UPDATE
SET status = 'COMPLETED',
progress_json = EXCLUDED.progress_json,
completed_at = NOW(),
updated_at = NOW()
RETURNING id, user_id, role_id, status, progress_json,
completed_at, created_at, updated_at
"#,
user_id,
role_id,
final_answers,
)
.fetch_one(pool)
.await
}
/// Check whether onboarding is complete for a user+role. Returns false if no record.
pub async fn is_complete(
pool: &PgPool,
user_id: Uuid,
role_id: Uuid,
) -> Result<bool, sqlx::Error> {
let row = sqlx::query!(
r#"
SELECT status FROM onboarding_states
WHERE user_id = $1 AND role_id = $2
"#,
user_id,
role_id,
)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| r.status == "COMPLETED").unwrap_or(false))
}
}

View file

@ -7,83 +7,52 @@ use uuid::Uuid;
pub struct PhotographerProfile {
pub id: Uuid,
pub user_id: Uuid,
pub portfolio_url: Option<String>,
pub equipment_list: Option<String>,
pub years_of_experience: Option<i32>,
pub hourly_rate: Option<sqlx::types::BigDecimal>,
pub specialties: Option<Vec<String>>,
pub display_name: Option<String>,
pub bio: Option<String>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertPhotographerProfilePayload {
pub portfolio_url: Option<String>,
pub equipment_list: Option<String>,
pub years_of_experience: Option<i32>,
pub hourly_rate: Option<f64>,
pub specialties: Option<Vec<String>>,
pub display_name: Option<String>,
pub bio: Option<String>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
}
pub struct PhotographerRepository;
impl PhotographerRepository {
pub async fn get_by_user_id(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<PhotographerProfile>, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<PhotographerProfile>, sqlx::Error> {
sqlx::query_as!(
PhotographerProfile,
r#"
SELECT
id, user_id, portfolio_url, equipment_list, years_of_experience,
hourly_rate, specialties, created_at, updated_at
FROM photographer_profiles
WHERE user_id = $1
"#,
r#"SELECT id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at
FROM photographer_profiles WHERE user_id = $1"#,
user_id
)
.fetch_optional(pool)
.await?;
Ok(profile)
).fetch_optional(pool).await
}
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
payload: UpsertPhotographerProfilePayload,
) -> Result<PhotographerProfile, sqlx::Error> {
let hourly_rate_bd = payload.hourly_rate.map(|v| sqlx::types::BigDecimal::try_from(v).unwrap_or_default());
let profile = sqlx::query_as!(
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertPhotographerProfilePayload) -> Result<PhotographerProfile, sqlx::Error> {
sqlx::query_as!(
PhotographerProfile,
r#"
INSERT INTO photographer_profiles (
user_id, portfolio_url, equipment_list, years_of_experience, hourly_rate, specialties
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id) DO UPDATE SET
portfolio_url = EXCLUDED.portfolio_url,
equipment_list = EXCLUDED.equipment_list,
years_of_experience = EXCLUDED.years_of_experience,
hourly_rate = EXCLUDED.hourly_rate,
specialties = EXCLUDED.specialties,
updated_at = NOW()
RETURNING
id, user_id, portfolio_url, equipment_list, years_of_experience,
hourly_rate, specialties, created_at, updated_at
"#,
user_id,
payload.portfolio_url,
payload.equipment_list,
payload.years_of_experience,
hourly_rate_bd,
payload.specialties.as_deref()
)
.fetch_one(pool)
.await?;
Ok(profile)
r#"INSERT INTO photographer_profiles (user_id, display_name, bio, location, custom_data)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id) DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, photographer_profiles.display_name),
bio = EXCLUDED.bio,
location = EXCLUDED.location,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at"#,
user_id, p.display_name, p.bio, p.location, p.custom_data
).fetch_one(pool).await
}
}

View file

@ -7,73 +7,52 @@ use uuid::Uuid;
pub struct SocialMediaManagerProfile {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertSocialMediaManagerProfilePayload {
pub display_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
}
pub struct SocialMediaManagerRepository;
impl SocialMediaManagerRepository {
pub async fn get_by_user_id(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<SocialMediaManagerProfile>, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<SocialMediaManagerProfile>, sqlx::Error> {
sqlx::query_as!(
SocialMediaManagerProfile,
r#"
SELECT
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
FROM social_media_manager_profiles
WHERE user_id = $1
"#,
r#"SELECT id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at
FROM social_media_manager_profiles WHERE user_id = $1"#,
user_id
)
.fetch_optional(pool)
.await?;
Ok(profile)
).fetch_optional(pool).await
}
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
payload: UpsertSocialMediaManagerProfilePayload,
) -> Result<SocialMediaManagerProfile, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertSocialMediaManagerProfilePayload) -> Result<SocialMediaManagerProfile, sqlx::Error> {
sqlx::query_as!(
SocialMediaManagerProfile,
r#"
INSERT INTO social_media_manager_profiles (
user_id, bio, experience_years, custom_data
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET
bio = EXCLUDED.bio,
experience_years = EXCLUDED.experience_years,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
"#,
user_id,
payload.bio,
payload.experience_years,
payload.custom_data
)
.fetch_one(pool)
.await?;
Ok(profile)
r#"INSERT INTO social_media_manager_profiles (user_id, display_name, bio, location, custom_data)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id) DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, social_media_manager_profiles.display_name),
bio = EXCLUDED.bio,
location = EXCLUDED.location,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at"#,
user_id, p.display_name, p.bio, p.location, p.custom_data
).fetch_one(pool).await
}
}

View file

@ -7,83 +7,52 @@ use uuid::Uuid;
pub struct TutorProfile {
pub id: Uuid,
pub user_id: Uuid,
pub subjects_taught: Option<Vec<String>>,
pub education_level: Option<String>,
pub certifications: Option<String>,
pub years_of_experience: Option<i32>,
pub hourly_rate: Option<sqlx::types::BigDecimal>,
pub display_name: Option<String>,
pub bio: Option<String>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertTutorProfilePayload {
pub subjects_taught: Option<Vec<String>>,
pub education_level: Option<String>,
pub certifications: Option<String>,
pub years_of_experience: Option<i32>,
pub hourly_rate: Option<f64>,
pub display_name: Option<String>,
pub bio: Option<String>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
}
pub struct TutorRepository;
impl TutorRepository {
pub async fn get_by_user_id(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<TutorProfile>, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<TutorProfile>, sqlx::Error> {
sqlx::query_as!(
TutorProfile,
r#"
SELECT
id, user_id, subjects_taught, education_level, certifications,
years_of_experience, hourly_rate, created_at, updated_at
FROM tutor_profiles
WHERE user_id = $1
"#,
r#"SELECT id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at
FROM tutor_profiles WHERE user_id = $1"#,
user_id
)
.fetch_optional(pool)
.await?;
Ok(profile)
).fetch_optional(pool).await
}
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
payload: UpsertTutorProfilePayload,
) -> Result<TutorProfile, sqlx::Error> {
let hourly_rate_bd = payload.hourly_rate.map(|v| sqlx::types::BigDecimal::try_from(v).unwrap_or_default());
let profile = sqlx::query_as!(
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertTutorProfilePayload) -> Result<TutorProfile, sqlx::Error> {
sqlx::query_as!(
TutorProfile,
r#"
INSERT INTO tutor_profiles (
user_id, subjects_taught, education_level, certifications, years_of_experience, hourly_rate
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id) DO UPDATE SET
subjects_taught = EXCLUDED.subjects_taught,
education_level = EXCLUDED.education_level,
certifications = EXCLUDED.certifications,
years_of_experience = EXCLUDED.years_of_experience,
hourly_rate = EXCLUDED.hourly_rate,
updated_at = NOW()
RETURNING
id, user_id, subjects_taught, education_level, certifications,
years_of_experience, hourly_rate, created_at, updated_at
"#,
user_id,
payload.subjects_taught.as_deref(),
payload.education_level,
payload.certifications,
payload.years_of_experience,
hourly_rate_bd
)
.fetch_one(pool)
.await?;
Ok(profile)
r#"INSERT INTO tutor_profiles (user_id, display_name, bio, location, custom_data)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id) DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, tutor_profiles.display_name),
bio = EXCLUDED.bio,
location = EXCLUDED.location,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at"#,
user_id, p.display_name, p.bio, p.location, p.custom_data
).fetch_one(pool).await
}
}

View file

@ -7,73 +7,52 @@ use uuid::Uuid;
pub struct VideoEditorProfile {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertVideoEditorProfilePayload {
pub display_name: Option<String>,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
}
pub struct VideoEditorRepository;
impl VideoEditorRepository {
pub async fn get_by_user_id(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<VideoEditorProfile>, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<VideoEditorProfile>, sqlx::Error> {
sqlx::query_as!(
VideoEditorProfile,
r#"
SELECT
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
FROM video_editor_profiles
WHERE user_id = $1
"#,
r#"SELECT id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at
FROM video_editor_profiles WHERE user_id = $1"#,
user_id
)
.fetch_optional(pool)
.await?;
Ok(profile)
).fetch_optional(pool).await
}
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
payload: UpsertVideoEditorProfilePayload,
) -> Result<VideoEditorProfile, sqlx::Error> {
let profile = sqlx::query_as!(
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertVideoEditorProfilePayload) -> Result<VideoEditorProfile, sqlx::Error> {
sqlx::query_as!(
VideoEditorProfile,
r#"
INSERT INTO video_editor_profiles (
user_id, bio, experience_years, custom_data
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET
bio = EXCLUDED.bio,
experience_years = EXCLUDED.experience_years,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING
id, user_id, bio, experience_years, custom_data,
created_at, updated_at
"#,
user_id,
payload.bio,
payload.experience_years,
payload.custom_data
)
.fetch_one(pool)
.await?;
Ok(profile)
r#"INSERT INTO video_editor_profiles (user_id, display_name, bio, location, custom_data)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id) DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, video_editor_profiles.display_name),
bio = EXCLUDED.bio,
location = EXCLUDED.location,
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING id, user_id, display_name, bio, location,
custom_data as "custom_data: Option<serde_json::Value>",
status, created_at, updated_at"#,
user_id, p.display_name, p.bio, p.location, p.custom_data
).fetch_one(pool).await
}
}

17
crates/storage/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "storage"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
reqwest = { version = "0.12", features = ["json", "multipart"] }
aws-sdk-s3 = "1"
aws-config = "1"
aws-credential-types = "1"
bytes = "1"
mime_guess = "2"

85
crates/storage/src/lib.rs Normal file
View file

@ -0,0 +1,85 @@
//! Backblaze B2 file storage via S3-compatible API.
//!
//! Configuration (environment variables):
//! B2_KEY_ID — Application Key ID
//! B2_APPLICATION_KEY — Application Key secret
//! B2_BUCKET_NAME — Bucket name (e.g. Nxtgauge-object)
//! B2_ENDPOINT — S3 endpoint (e.g. s3.eu-central-003.backblazeb2.com)
//! B2_REGION — Region (e.g. eu-central-003)
use anyhow::{Context, Result};
use aws_config::Region;
use aws_credential_types::Credentials;
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{Builder as S3ConfigBuilder, SharedCredentialsProvider};
use aws_sdk_s3::primitives::ByteStream;
use bytes::Bytes;
use uuid::Uuid;
#[derive(Clone)]
pub struct StorageClient {
client: Client,
bucket: String,
public_base_url: String,
}
impl StorageClient {
/// Build from environment variables. Panics if required vars are missing.
pub async fn from_env() -> Self {
let key_id = std::env::var("B2_KEY_ID").expect("B2_KEY_ID must be set");
let app_key = std::env::var("B2_APPLICATION_KEY").expect("B2_APPLICATION_KEY must be set");
let bucket = std::env::var("B2_BUCKET_NAME").expect("B2_BUCKET_NAME must be set");
let endpoint = std::env::var("B2_ENDPOINT").expect("B2_ENDPOINT must be set");
let region = std::env::var("B2_REGION").expect("B2_REGION must be set");
let creds = Credentials::new(key_id, app_key, None, None, "nxtgauge-storage");
let endpoint_url = format!("https://{}", endpoint);
let public_base_url = format!("https://{}/{}", endpoint, bucket);
let s3_config = S3ConfigBuilder::new()
.endpoint_url(endpoint_url)
.region(Region::new(region))
.credentials_provider(SharedCredentialsProvider::new(creds))
.force_path_style(true)
.build();
let client = Client::from_conf(s3_config);
Self { client, bucket, public_base_url }
}
/// Upload bytes to B2. Returns the public URL.
///
/// `prefix` — e.g. "portfolio", "resume", "profile"
/// `ext` — file extension without dot, e.g. "jpg", "pdf"
pub async fn upload(&self, prefix: &str, ext: &str, data: Bytes, content_type: &str) -> Result<String> {
let key = format!("{}/{}.{}", prefix, Uuid::new_v4(), ext);
self.client
.put_object()
.bucket(&self.bucket)
.key(&key)
.body(ByteStream::from(data))
.content_type(content_type)
.send()
.await
.context("B2 upload failed")?;
Ok(format!("{}/{}", self.public_base_url, key))
}
/// Delete a file by its full public URL (best-effort — logs on failure).
pub async fn delete_by_url(&self, url: &str) {
let prefix = format!("{}/", self.public_base_url);
if let Some(key) = url.strip_prefix(&prefix) {
if let Err(e) = self.client
.delete_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
{
tracing::warn!("B2 delete failed for key={}: {}", key, e);
}
}
}
}

1009
scripts/seed.sql Normal file

File diff suppressed because it is too large Load diff