diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf2d49f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Cargo.toml b/Cargo.toml index fc95cf2..1ab7497 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/apps/catering_services/Cargo.toml b/apps/catering_services/Cargo.toml index 590cddd..8d07437 100644 --- a/apps/catering_services/Cargo.toml +++ b/apps/catering_services/Cargo.toml @@ -15,4 +15,5 @@ chrono = { workspace = true } db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } +cache = { path = "../../crates/cache" } diff --git a/apps/catering_services/src/handlers.rs b/apps/catering_services/src/handlers.rs index 0d9e8bb..7bd54fb 100644 --- a/apps/catering_services/src/handlers.rs +++ b/apps/catering_services/src/handlers.rs @@ -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 { +pub fn router() -> Router { 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, - 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, 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, + State(state): State, auth: AuthUser, Json(payload): Json, ) -> 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(), } } diff --git a/apps/catering_services/src/main.rs b/apps/catering_services/src/main.rs index 2bdbe37..bfca3f5 100644 --- a/apps/catering_services/src/main.rs +++ b/apps/catering_services/src/main.rs @@ -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(); } diff --git a/apps/developers/Cargo.toml b/apps/developers/Cargo.toml index 72ab2d0..7109de3 100644 --- a/apps/developers/Cargo.toml +++ b/apps/developers/Cargo.toml @@ -15,4 +15,5 @@ chrono = { workspace = true } db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } +cache = { path = "../../crates/cache" } diff --git a/apps/developers/src/handlers.rs b/apps/developers/src/handlers.rs index aaf1bef..c8f722d 100644 --- a/apps/developers/src/handlers.rs +++ b/apps/developers/src/handlers.rs @@ -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 { +pub fn router() -> Router { 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, - 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, 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, + State(state): State, auth: AuthUser, Json(payload): Json, ) -> 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(), } } - diff --git a/apps/developers/src/main.rs b/apps/developers/src/main.rs index 6e536fb..71f98e9 100644 --- a/apps/developers/src/main.rs +++ b/apps/developers/src/main.rs @@ -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(); } diff --git a/apps/fitness_trainers/Cargo.toml b/apps/fitness_trainers/Cargo.toml index 308392a..b2a3d79 100644 --- a/apps/fitness_trainers/Cargo.toml +++ b/apps/fitness_trainers/Cargo.toml @@ -15,4 +15,5 @@ chrono = { workspace = true } db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } +cache = { path = "../../crates/cache" } diff --git a/apps/fitness_trainers/src/handlers.rs b/apps/fitness_trainers/src/handlers.rs index 1850ecd..8cae4ee 100644 --- a/apps/fitness_trainers/src/handlers.rs +++ b/apps/fitness_trainers/src/handlers.rs @@ -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 { +pub fn router() -> Router { 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, - 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, 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, + State(state): State, auth: AuthUser, Json(payload): Json, ) -> 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(), } } diff --git a/apps/fitness_trainers/src/main.rs b/apps/fitness_trainers/src/main.rs index fae194a..63ca2c9 100644 --- a/apps/fitness_trainers/src/main.rs +++ b/apps/fitness_trainers/src/main.rs @@ -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(); } diff --git a/apps/graphic_designers/Cargo.toml b/apps/graphic_designers/Cargo.toml index b92f0e2..8b89f61 100644 --- a/apps/graphic_designers/Cargo.toml +++ b/apps/graphic_designers/Cargo.toml @@ -15,4 +15,5 @@ chrono = { workspace = true } db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } +cache = { path = "../../crates/cache" } diff --git a/apps/graphic_designers/src/handlers.rs b/apps/graphic_designers/src/handlers.rs index 3c50e4e..49a25db 100644 --- a/apps/graphic_designers/src/handlers.rs +++ b/apps/graphic_designers/src/handlers.rs @@ -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 { +pub fn router() -> Router { 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, - 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, 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, + State(state): State, auth: AuthUser, Json(payload): Json, ) -> 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(), } } diff --git a/apps/graphic_designers/src/main.rs b/apps/graphic_designers/src/main.rs index e01bcfe..64b8606 100644 --- a/apps/graphic_designers/src/main.rs +++ b/apps/graphic_designers/src/main.rs @@ -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(); } diff --git a/apps/makeup_artists/Cargo.toml b/apps/makeup_artists/Cargo.toml index 3629c3b..4119f07 100644 --- a/apps/makeup_artists/Cargo.toml +++ b/apps/makeup_artists/Cargo.toml @@ -15,4 +15,5 @@ chrono = { workspace = true } db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } +cache = { path = "../../crates/cache" } diff --git a/apps/makeup_artists/src/handlers.rs b/apps/makeup_artists/src/handlers.rs index 3ffaa0d..db25439 100644 --- a/apps/makeup_artists/src/handlers.rs +++ b/apps/makeup_artists/src/handlers.rs @@ -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 { +pub fn router() -> Router { 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, - 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, 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, + State(state): State, auth: AuthUser, Json(payload): Json, ) -> 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(), } } - diff --git a/apps/makeup_artists/src/main.rs b/apps/makeup_artists/src/main.rs index 2c5d4ee..ba092d8 100644 --- a/apps/makeup_artists/src/main.rs +++ b/apps/makeup_artists/src/main.rs @@ -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(); } diff --git a/apps/photographers/Cargo.toml b/apps/photographers/Cargo.toml index 08c2e29..3d36a6d 100644 --- a/apps/photographers/Cargo.toml +++ b/apps/photographers/Cargo.toml @@ -15,4 +15,5 @@ chrono = { workspace = true } db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } +cache = { path = "../../crates/cache" } diff --git a/apps/photographers/src/handlers.rs b/apps/photographers/src/handlers.rs index 85bb035..05a1353 100644 --- a/apps/photographers/src/handlers.rs +++ b/apps/photographers/src/handlers.rs @@ -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 { +pub fn router() -> Router { 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, - 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, 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, + State(state): State, auth: AuthUser, Json(payload): Json, ) -> 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(), } } - diff --git a/apps/photographers/src/main.rs b/apps/photographers/src/main.rs index 851b4f1..bca0baf 100644 --- a/apps/photographers/src/main.rs +++ b/apps/photographers/src/main.rs @@ -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(); } diff --git a/apps/social_media_managers/Cargo.toml b/apps/social_media_managers/Cargo.toml index 85c7e85..23c3b0c 100644 --- a/apps/social_media_managers/Cargo.toml +++ b/apps/social_media_managers/Cargo.toml @@ -15,4 +15,5 @@ chrono = { workspace = true } db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } +cache = { path = "../../crates/cache" } diff --git a/apps/social_media_managers/src/handlers.rs b/apps/social_media_managers/src/handlers.rs index 062d3f3..b023a08 100644 --- a/apps/social_media_managers/src/handlers.rs +++ b/apps/social_media_managers/src/handlers.rs @@ -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 { +pub fn router() -> Router { 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, - 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, 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, + State(state): State, auth: AuthUser, Json(payload): Json, ) -> 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(), } } diff --git a/apps/social_media_managers/src/main.rs b/apps/social_media_managers/src/main.rs index 0f8b5c6..63d8cb1 100644 --- a/apps/social_media_managers/src/main.rs +++ b/apps/social_media_managers/src/main.rs @@ -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(); } diff --git a/apps/tutors/Cargo.toml b/apps/tutors/Cargo.toml index 192dd83..edd253a 100644 --- a/apps/tutors/Cargo.toml +++ b/apps/tutors/Cargo.toml @@ -15,4 +15,5 @@ chrono = { workspace = true } db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } +cache = { path = "../../crates/cache" } diff --git a/apps/tutors/src/handlers.rs b/apps/tutors/src/handlers.rs index 5e3f110..14aac07 100644 --- a/apps/tutors/src/handlers.rs +++ b/apps/tutors/src/handlers.rs @@ -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 { +pub fn router() -> Router { 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, - 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, 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, + State(state): State, auth: AuthUser, Json(payload): Json, ) -> 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(), } } - diff --git a/apps/tutors/src/main.rs b/apps/tutors/src/main.rs index 3a91a0c..920a39b 100644 --- a/apps/tutors/src/main.rs +++ b/apps/tutors/src/main.rs @@ -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(); } diff --git a/apps/users/Cargo.toml b/apps/users/Cargo.toml index dbeaade..23f5cd0 100644 --- a/apps/users/Cargo.toml +++ b/apps/users/Cargo.toml @@ -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" diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 2e1f4bb..498d8b3 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -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 { 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, + 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, + 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, + pub roles: Vec, + pub active_role: Option, } #[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) { - ( - 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, Json(payload): Json, ) -> Result)> { - // 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::() % 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::() % 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, Json(payload): Json, ) -> Result)> { - 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, - // In real implementation: extract refresh token from cookie header + req: axum::http::Request, ) -> 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, - // In real impl: read httpOnly cookie, not body - Json(payload): Json, + req: axum::http::Request, ) -> Result)> { - 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::() + .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, @@ -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, Json(payload): Json, ) -> Result)> { - 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::() .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, Json(payload): Json, ) -> Result)> { - 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::() % 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::() % 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, Json(payload): Json, ) -> Result)> { - 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, Json(payload): Json, ) -> Result)> { - 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::() + .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, @@ -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, + Json(payload): Json, +) -> Result)> { + 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 + })))) +} diff --git a/apps/users/src/handlers/onboarding.rs b/apps/users/src/handlers/onboarding.rs new file mode 100644 index 0000000..df02fd6 --- /dev/null +++ b/apps/users/src/handlers/onboarding.rs @@ -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 { + Router::new() + .route("/state", get(get_state)) + .route("/save-progress", post(save_progress)) + .route("/submit", post(submit)) +} + +pub fn me_router() -> Router { + Router::new() + .route("/profile-status", get(profile_status)) +} + +// ── DTOs ────────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct RoleKeyQuery { + #[serde(rename = "roleKey")] + pub role_key: Option, +} + +// 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, +} + +#[derive(Deserialize)] +pub struct SubmitInput { + #[serde(rename = "roleKey", alias = "role_key")] + pub role_key: String, + pub progress_json: Option, +} + +#[derive(Serialize)] +pub struct ProfileStatusResponse { + pub onboarding_complete: bool, + pub active_role: Option, + pub roles: Vec, + pub email_verified: bool, +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +/// GET /api/onboarding/state?roleKey=COMPANY +async fn get_state( + auth: AuthUser, + State(state): State, + Query(query): Query, +) -> Result { + 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, + Json(input): Json, +) -> Result { + 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, + Json(input): Json, +) -> Result { + 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, +) -> Result { + 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, + }), + )) +} diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 1080e8d..e978ea3 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -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, + pub pool: PgPool, + pub mail: Arc, + 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); diff --git a/apps/video_editors/Cargo.toml b/apps/video_editors/Cargo.toml index 7549711..f1d3044 100644 --- a/apps/video_editors/Cargo.toml +++ b/apps/video_editors/Cargo.toml @@ -15,4 +15,5 @@ chrono = { workspace = true } db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } +cache = { path = "../../crates/cache" } diff --git a/apps/video_editors/src/handlers.rs b/apps/video_editors/src/handlers.rs index 61b160b..7ddf86f 100644 --- a/apps/video_editors/src/handlers.rs +++ b/apps/video_editors/src/handlers.rs @@ -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 { +pub fn router() -> Router { 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, - 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, 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, + State(state): State, auth: AuthUser, Json(payload): Json, ) -> 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(), } } diff --git a/apps/video_editors/src/main.rs b/apps/video_editors/src/main.rs index 8e83cca..e409605 100644 --- a/apps/video_editors/src/main.rs +++ b/apps/video_editors/src/main.rs @@ -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(); } diff --git a/crates/cache/Cargo.toml b/crates/cache/Cargo.toml new file mode 100644 index 0000000..1b59e62 --- /dev/null +++ b/crates/cache/Cargo.toml @@ -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 } diff --git a/crates/cache/src/client.rs b/crates/cache/src/client.rs new file mode 100644 index 0000000..f687607 --- /dev/null +++ b/crates/cache/src/client.rs @@ -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 { + let client = redis::Client::open(url)?; + ConnectionManager::new(client).await +} diff --git a/crates/cache/src/jobs.rs b/crates/cache/src/jobs.rs new file mode 100644 index 0000000..72ded60 --- /dev/null +++ b/crates/cache/src/jobs.rs @@ -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, 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 = redis.keys(pattern).await?; + if !keys.is_empty() { + redis.del(keys).await?; + } + Ok(()) +} diff --git a/crates/cache/src/lead.rs b/crates/cache/src/lead.rs new file mode 100644 index 0000000..103dc83 --- /dev/null +++ b/crates/cache/src/lead.rs @@ -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 { + 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 +} diff --git a/crates/cache/src/lib.rs b/crates/cache/src/lib.rs new file mode 100644 index 0000000..3bb5462 --- /dev/null +++ b/crates/cache/src/lib.rs @@ -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}; diff --git a/crates/cache/src/otp.rs b/crates/cache/src/otp.rs new file mode 100644 index 0000000..fd921a8 --- /dev/null +++ b/crates/cache/src/otp.rs @@ -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, 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 { + 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(()) +} diff --git a/crates/cache/src/rate_limit.rs b/crates/cache/src/rate_limit.rs new file mode 100644 index 0000000..1d688bc --- /dev/null +++ b/crates/cache/src/rate_limit.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + check(redis, "job_post", company_id, 20, 3_600).await +} diff --git a/crates/cache/src/token.rs b/crates/cache/src/token.rs new file mode 100644 index 0000000..abf3587 --- /dev/null +++ b/crates/cache/src/token.rs @@ -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, 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, redis::RedisError> { + let key = format!("reset:{token}"); + redis.get_del(key).await +} diff --git a/crates/contracts/Cargo.toml b/crates/contracts/Cargo.toml new file mode 100644 index 0000000..a3e8a89 --- /dev/null +++ b/crates/contracts/Cargo.toml @@ -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" } diff --git a/crates/contracts/src/lib.rs b/crates/contracts/src/lib.rs index 7f0d0bc..70cbd94 100644 --- a/crates/contracts/src/lib.rs +++ b/crates/contracts/src/lib.rs @@ -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; diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 69a4405..513832b 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -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 { +/// 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 { Router::new() + // ── Marketplace (Redis-cached) ──────────────────────────────────────── .route( "/marketplace", - get(move |state, query| browse_marketplace(state, query, profession_key)), + get(move |State(state): State, Query(q): Query| 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::(&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, - Query(q): Query, - 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, + State(state): State, _auth: AuthUser, Path(id): Path, ) -> 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, + State(state): State, auth: AuthUser, Json(payload): Json, ) -> 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, - 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, 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, - 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, 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, - 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, 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, _a: AuthUser, _q: Query) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) } -async fn cancel_request(_s: State, _a: AuthUser, _p: Path) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Done"}))) } -async fn accepted_leads(_s: State, _a: AuthUser, _q: Query) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) } -async fn accepted_lead_detail(_s: State, _a: AuthUser, _p: Path) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"id":_p.to_string()}))) } -async fn create_portfolio_item(_s: State, _a: AuthUser, _p: Json) -> impl IntoResponse { (StatusCode::CREATED, Json(serde_json::json!({"id":Uuid::new_v4().to_string()}))) } -async fn update_portfolio_item(_s: State, _a: AuthUser, _p: Path, _v: Json) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Updated"}))) } -async fn delete_portfolio_item(_s: State, _a: AuthUser, _p: Path) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Deleted"}))) } -async fn create_service(_s: State, _a: AuthUser, _p: Json) -> impl IntoResponse { (StatusCode::CREATED, Json(serde_json::json!({"id":Uuid::new_v4().to_string()}))) } -async fn update_service(_s: State, _a: AuthUser, _p: Path, _v: Json) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Updated"}))) } -async fn delete_service(_s: State, _a: AuthUser, _p: Path) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Deleted"}))) } -async fn wallet_ledger(_s: State, _a: AuthUser, _q: Query) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) } -async fn wallet_invoices(_s: State, _a: AuthUser, _q: Query) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) } -async fn wallet_invoice_detail(_s: State, _a: AuthUser, _p: Path) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"id":_p.to_string()}))) } +// ── Stub handlers ───────────────────────────────────────────────────────────── +async fn my_requests( + _s: State, + _a: AuthUser, + _q: Query, +) -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "data": [] }))) +} + +async fn cancel_request( + _s: State, + _a: AuthUser, + _p: Path, +) -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "message": "Cancelled" }))) +} + +async fn accepted_leads( + _s: State, + _a: AuthUser, + _q: Query, +) -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "data": [] }))) +} + +async fn accepted_lead_detail( + _s: State, + _a: AuthUser, + Path(id): Path, +) -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() }))) +} + +async fn create_portfolio_item( + _s: State, + _a: AuthUser, + _p: Json, +) -> impl IntoResponse { + (StatusCode::CREATED, Json(serde_json::json!({ "id": Uuid::new_v4().to_string() }))) +} + +async fn update_portfolio_item( + _s: State, + _a: AuthUser, + _id: Path, + _p: Json, +) -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "message": "Updated" }))) +} + +async fn delete_portfolio_item( + _s: State, + _a: AuthUser, + _id: Path, +) -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "message": "Deleted" }))) +} + +async fn create_service( + _s: State, + _a: AuthUser, + _p: Json, +) -> impl IntoResponse { + (StatusCode::CREATED, Json(serde_json::json!({ "id": Uuid::new_v4().to_string() }))) +} + +async fn update_service( + _s: State, + _a: AuthUser, + _id: Path, + _p: Json, +) -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "message": "Updated" }))) +} + +async fn delete_service( + _s: State, + _a: AuthUser, + _id: Path, +) -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "message": "Deleted" }))) +} + +async fn wallet_ledger( + _s: State, + _a: AuthUser, + _q: Query, +) -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "data": [] }))) +} + +async fn wallet_invoices( + _s: State, + _a: AuthUser, + _q: Query, +) -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "data": [] }))) +} + +async fn wallet_invoice_detail( + _s: State, + _a: AuthUser, + Path(id): Path, +) -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() }))) +} diff --git a/crates/contracts/src/profession_state.rs b/crates/contracts/src/profession_state.rs new file mode 100644 index 0000000..b278326 --- /dev/null +++ b/crates/contracts/src/profession_state.rs @@ -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, +} diff --git a/crates/db/migrations/20260317202500_profession_profiles_custom_data.down.sql b/crates/db/migrations/20260317202500_profession_profiles_custom_data.down.sql new file mode 100644 index 0000000..22698bd --- /dev/null +++ b/crates/db/migrations/20260317202500_profession_profiles_custom_data.down.sql @@ -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; diff --git a/crates/db/migrations/20260317202500_profession_profiles_custom_data.up.sql b/crates/db/migrations/20260317202500_profession_profiles_custom_data.up.sql new file mode 100644 index 0000000..8fbda22 --- /dev/null +++ b/crates/db/migrations/20260317202500_profession_profiles_custom_data.up.sql @@ -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; diff --git a/crates/db/src/models/catering_service.rs b/crates/db/src/models/catering_service.rs index c911dff..f5f9eed 100644 --- a/crates/db/src/models/catering_service.rs +++ b/crates/db/src/models/catering_service.rs @@ -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, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, + pub status: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct UpsertCateringServiceProfilePayload { + pub business_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, } pub struct CateringServiceRepository; impl CateringServiceRepository { - pub async fn get_by_user_id( - pool: &PgPool, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let profile = sqlx::query_as!( + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, 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", + 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 { - let profile = sqlx::query_as!( + pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertCateringServiceProfilePayload) -> Result { + 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", + status, created_at, updated_at"#, + user_id, p.business_name, p.bio, p.location, p.custom_data + ).fetch_one(pool).await } } diff --git a/crates/db/src/models/developer.rs b/crates/db/src/models/developer.rs index 2a226df..baee1d4 100644 --- a/crates/db/src/models/developer.rs +++ b/crates/db/src/models/developer.rs @@ -7,73 +7,52 @@ use uuid::Uuid; pub struct DeveloperProfile { pub id: Uuid, pub user_id: Uuid, + pub display_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, + pub status: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct UpsertDeveloperProfilePayload { + pub display_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, } pub struct DeveloperRepository; impl DeveloperRepository { - pub async fn get_by_user_id( - pool: &PgPool, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let profile = sqlx::query_as!( + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, 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", + 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 { - let profile = sqlx::query_as!( + pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertDeveloperProfilePayload) -> Result { + 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", + status, created_at, updated_at"#, + user_id, p.display_name, p.bio, p.location, p.custom_data + ).fetch_one(pool).await } } diff --git a/crates/db/src/models/fitness_trainer.rs b/crates/db/src/models/fitness_trainer.rs index a13c548..f878dec 100644 --- a/crates/db/src/models/fitness_trainer.rs +++ b/crates/db/src/models/fitness_trainer.rs @@ -7,73 +7,52 @@ use uuid::Uuid; pub struct FitnessTrainerProfile { pub id: Uuid, pub user_id: Uuid, + pub display_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, + pub status: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct UpsertFitnessTrainerProfilePayload { + pub display_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, } pub struct FitnessTrainerRepository; impl FitnessTrainerRepository { - pub async fn get_by_user_id( - pool: &PgPool, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let profile = sqlx::query_as!( + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, 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", + 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 { - let profile = sqlx::query_as!( + pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertFitnessTrainerProfilePayload) -> Result { + 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", + status, created_at, updated_at"#, + user_id, p.display_name, p.bio, p.location, p.custom_data + ).fetch_one(pool).await } } diff --git a/crates/db/src/models/graphic_designer.rs b/crates/db/src/models/graphic_designer.rs index e16dbfd..26529e6 100644 --- a/crates/db/src/models/graphic_designer.rs +++ b/crates/db/src/models/graphic_designer.rs @@ -7,73 +7,52 @@ use uuid::Uuid; pub struct GraphicDesignerProfile { pub id: Uuid, pub user_id: Uuid, + pub display_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, + pub status: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct UpsertGraphicDesignerProfilePayload { + pub display_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, } pub struct GraphicDesignerRepository; impl GraphicDesignerRepository { - pub async fn get_by_user_id( - pool: &PgPool, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let profile = sqlx::query_as!( + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, 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", + 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 { - let profile = sqlx::query_as!( + pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertGraphicDesignerProfilePayload) -> Result { + 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", + status, created_at, updated_at"#, + user_id, p.display_name, p.bio, p.location, p.custom_data + ).fetch_one(pool).await } } diff --git a/crates/db/src/models/makeup_artist.rs b/crates/db/src/models/makeup_artist.rs index 1ff8a8a..0521f0e 100644 --- a/crates/db/src/models/makeup_artist.rs +++ b/crates/db/src/models/makeup_artist.rs @@ -7,73 +7,52 @@ use uuid::Uuid; pub struct MakeupArtistProfile { pub id: Uuid, pub user_id: Uuid, + pub display_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, + pub status: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct UpsertMakeupArtistProfilePayload { + pub display_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, } pub struct MakeupArtistRepository; impl MakeupArtistRepository { - pub async fn get_by_user_id( - pool: &PgPool, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let profile = sqlx::query_as!( + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, 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", + 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 { - let profile = sqlx::query_as!( + pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertMakeupArtistProfilePayload) -> Result { + 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", + status, created_at, updated_at"#, + user_id, p.display_name, p.bio, p.location, p.custom_data + ).fetch_one(pool).await } } diff --git a/crates/db/src/models/onboarding_state.rs b/crates/db/src/models/onboarding_state.rs new file mode 100644 index 0000000..e2a4e57 --- /dev/null +++ b/crates/db/src/models/onboarding_state.rs @@ -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>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[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, 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 { + 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 { + 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 { + 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)) + } +} diff --git a/crates/db/src/models/photographer.rs b/crates/db/src/models/photographer.rs index 75f70d6..ed78b24 100644 --- a/crates/db/src/models/photographer.rs +++ b/crates/db/src/models/photographer.rs @@ -7,83 +7,52 @@ use uuid::Uuid; pub struct PhotographerProfile { pub id: Uuid, pub user_id: Uuid, - pub portfolio_url: Option, - pub equipment_list: Option, - pub years_of_experience: Option, - pub hourly_rate: Option, - pub specialties: Option>, + pub display_name: Option, + pub bio: Option, + pub location: Option, + pub custom_data: Option, + pub status: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct UpsertPhotographerProfilePayload { - pub portfolio_url: Option, - pub equipment_list: Option, - pub years_of_experience: Option, - pub hourly_rate: Option, - pub specialties: Option>, + pub display_name: Option, + pub bio: Option, + pub location: Option, + pub custom_data: Option, } pub struct PhotographerRepository; impl PhotographerRepository { - pub async fn get_by_user_id( - pool: &PgPool, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let profile = sqlx::query_as!( + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, 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", + 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 { - 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 { + 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", + status, created_at, updated_at"#, + user_id, p.display_name, p.bio, p.location, p.custom_data + ).fetch_one(pool).await } } diff --git a/crates/db/src/models/social_media_manager.rs b/crates/db/src/models/social_media_manager.rs index 2e9ca70..78b14d7 100644 --- a/crates/db/src/models/social_media_manager.rs +++ b/crates/db/src/models/social_media_manager.rs @@ -7,73 +7,52 @@ use uuid::Uuid; pub struct SocialMediaManagerProfile { pub id: Uuid, pub user_id: Uuid, + pub display_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, + pub status: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct UpsertSocialMediaManagerProfilePayload { + pub display_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, } pub struct SocialMediaManagerRepository; impl SocialMediaManagerRepository { - pub async fn get_by_user_id( - pool: &PgPool, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let profile = sqlx::query_as!( + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, 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", + 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 { - let profile = sqlx::query_as!( + pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertSocialMediaManagerProfilePayload) -> Result { + 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", + status, created_at, updated_at"#, + user_id, p.display_name, p.bio, p.location, p.custom_data + ).fetch_one(pool).await } } diff --git a/crates/db/src/models/tutor.rs b/crates/db/src/models/tutor.rs index 386d426..409bdf4 100644 --- a/crates/db/src/models/tutor.rs +++ b/crates/db/src/models/tutor.rs @@ -7,83 +7,52 @@ use uuid::Uuid; pub struct TutorProfile { pub id: Uuid, pub user_id: Uuid, - pub subjects_taught: Option>, - pub education_level: Option, - pub certifications: Option, - pub years_of_experience: Option, - pub hourly_rate: Option, + pub display_name: Option, + pub bio: Option, + pub location: Option, + pub custom_data: Option, + pub status: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct UpsertTutorProfilePayload { - pub subjects_taught: Option>, - pub education_level: Option, - pub certifications: Option, - pub years_of_experience: Option, - pub hourly_rate: Option, + pub display_name: Option, + pub bio: Option, + pub location: Option, + pub custom_data: Option, } pub struct TutorRepository; impl TutorRepository { - pub async fn get_by_user_id( - pool: &PgPool, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let profile = sqlx::query_as!( + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, 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", + 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 { - 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 { + 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", + status, created_at, updated_at"#, + user_id, p.display_name, p.bio, p.location, p.custom_data + ).fetch_one(pool).await } } diff --git a/crates/db/src/models/video_editor.rs b/crates/db/src/models/video_editor.rs index cc208ec..94ba36c 100644 --- a/crates/db/src/models/video_editor.rs +++ b/crates/db/src/models/video_editor.rs @@ -7,73 +7,52 @@ use uuid::Uuid; pub struct VideoEditorProfile { pub id: Uuid, pub user_id: Uuid, + pub display_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, + pub status: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct UpsertVideoEditorProfilePayload { + pub display_name: Option, pub bio: Option, - pub experience_years: Option, + pub location: Option, pub custom_data: Option, } pub struct VideoEditorRepository; impl VideoEditorRepository { - pub async fn get_by_user_id( - pool: &PgPool, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let profile = sqlx::query_as!( + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, 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", + 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 { - let profile = sqlx::query_as!( + pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertVideoEditorProfilePayload) -> Result { + 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", + status, created_at, updated_at"#, + user_id, p.display_name, p.bio, p.location, p.custom_data + ).fetch_one(pool).await } } diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml new file mode 100644 index 0000000..460f95f --- /dev/null +++ b/crates/storage/Cargo.toml @@ -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" diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs new file mode 100644 index 0000000..7081298 --- /dev/null +++ b/crates/storage/src/lib.rs @@ -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 { + 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); + } + } + } +} diff --git a/scripts/seed.sql b/scripts/seed.sql new file mode 100644 index 0000000..179c72e --- /dev/null +++ b/scripts/seed.sql @@ -0,0 +1,1009 @@ +-- Nxtgauge Seed Script +-- Run: psql $DATABASE_URL -f scripts/seed.sql +-- Safe to re-run (uses INSERT ... ON CONFLICT DO UPDATE for configs) + +-- ── 1. Roles ───────────────────────────────────────────────────────────────── + +INSERT INTO roles (key, name, audience) VALUES + ('SUPER_ADMIN', 'Super Admin', 'INTERNAL'), + ('ADMIN', 'Admin', 'INTERNAL'), + ('SUPPORT', 'Support Agent', 'INTERNAL'), + ('COMPANY', 'Company', 'EXTERNAL'), + ('JOB_SEEKER', 'Job Seeker', 'EXTERNAL'), + ('CUSTOMER', 'Customer', 'EXTERNAL'), + ('PHOTOGRAPHER', 'Photographer', 'EXTERNAL'), + ('MAKEUP_ARTIST', 'Makeup Artist', 'EXTERNAL'), + ('TUTOR', 'Tutor', 'EXTERNAL'), + ('DEVELOPER', 'Developer', 'EXTERNAL'), + ('VIDEO_EDITOR', 'Video Editor', 'EXTERNAL'), + ('GRAPHIC_DESIGNER', 'Graphic Designer', 'EXTERNAL'), + ('SOCIAL_MEDIA_MANAGER', 'Social Media Manager', 'EXTERNAL'), + ('FITNESS_TRAINER', 'Fitness Trainer', 'EXTERNAL'), + ('CATERING_SERVICES', 'Catering Services', 'EXTERNAL') +ON CONFLICT (key) DO NOTHING; + +-- ── 2. Super Admin User ────────────────────────────────────────────────────── +-- Default password: Admin@nxtgauge1 (bcrypt hash) +-- CHANGE THIS PASSWORD IMMEDIATELY AFTER FIRST LOGIN + +DO $$ +DECLARE + super_admin_role_id UUID; + admin_user_id UUID; +BEGIN + SELECT id INTO super_admin_role_id FROM roles WHERE key = 'SUPER_ADMIN'; + + INSERT INTO users (email, password_hash, status, role_id, full_name, email_verified) + VALUES ( + 'admin@nxtgauge.com', + '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TiGniB9GSmJBGp0K7RqUi/4hY/Ii', + 'ACTIVE', + super_admin_role_id, + 'Super Admin', + true + ) + ON CONFLICT (email) DO NOTHING + RETURNING id INTO admin_user_id; + + IF admin_user_id IS NOT NULL THEN + INSERT INTO user_roles (user_id, role_id, status, approved_at) + VALUES (admin_user_id, super_admin_role_id, 'APPROVED', NOW()) + ON CONFLICT (user_id, role_id) DO NOTHING; + END IF; +END $$; + +-- ── 3. Onboarding Configs ───────────────────────────────────────────────────── +-- Each role gets its own INSERT for clarity and maintainability. +-- Fields use "id" (not "key") to match the frontend field binding. +-- All document uploads accept PDF + images. +-- City fields are readOnly and default to "Chennai, India". +-- version = 2 to force updates on re-run. + +-- CUSTOMER (14 steps: 1 service select + 9 profession-specific + 4 shared) +-- The frontend normalizeSchemaPayload() auto-adds visibleWhen to steps +-- matching /^customer_(requirements|budget)_([a-z_]+)$/. +INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) +SELECT id, $json${ + "steps": [ + { + "id": "step_1_service", + "title": "Select Service Category", + "fields": [ + { + "id": "profession", + "label": "Service Category", + "type": "select", + "required": true, + "options": [ + {"label": "Photographer", "value": "photographer"}, + {"label": "Makeup Artist", "value": "makeup_artist"}, + {"label": "Tutor", "value": "tutor"}, + {"label": "Developer", "value": "developer"}, + {"label": "Video Editor", "value": "video_editor"}, + {"label": "Graphic Designer", "value": "graphic_designer"}, + {"label": "Social Media Manager", "value": "social_media_manager"}, + {"label": "Fitness Trainer", "value": "fitness_trainer"}, + {"label": "Catering Services", "value": "catering_services"} + ] + } + ] + }, + { + "id": "customer_requirements_photographer", + "title": "Photography Requirements", + "fields": [ + {"id": "event_type", "label": "Event Type", "type": "select", "required": true, + "options": [{"label":"Wedding","value":"Wedding"},{"label":"Corporate Event","value":"Corporate Event"},{"label":"Birthday","value":"Birthday"},{"label":"Product Shoot","value":"Product Shoot"},{"label":"Portrait","value":"Portrait"}]}, + {"id": "coverage_hours", "label": "Coverage Hours", "type": "number", "required": true, "placeholder": "e.g., 4", "validation": {"min": 1}}, + {"id": "photo_style", "label": "Photo Style", "type": "select", "required": true, + "options": [{"label":"Traditional","value":"Traditional"},{"label":"Candid","value":"Candid"},{"label":"Cinematic","value":"Cinematic"},{"label":"Documentary","value":"Documentary"}]} + ] + }, + { + "id": "customer_requirements_makeup_artist", + "title": "Makeup Requirements", + "fields": [ + {"id": "occasion_type", "label": "Occasion Type", "type": "select", "required": true, + "options": [{"label":"Bridal","value":"Bridal"},{"label":"Party/Guest","value":"Party/Guest"},{"label":"Photoshoot","value":"Photoshoot"},{"label":"Editorial","value":"Editorial"}]}, + {"id": "people_count", "label": "Number of People", "type": "number", "required": true, "placeholder": "e.g., 2", "validation": {"min": 1}}, + {"id": "skin_preferences", "label": "Skin Preferences", "type": "textarea", "placeholder": "Any allergies or specific product requests?"} + ] + }, + { + "id": "customer_requirements_tutor", + "title": "Tutoring Requirements", + "fields": [ + {"id": "subject", "label": "Subject", "type": "text", "required": true, "placeholder": "e.g., Mathematics, Spoken English"}, + {"id": "grade_level", "label": "Grade Level", "type": "select", "required": true, + "options": [{"label":"Primary","value":"Primary"},{"label":"Middle School","value":"Middle School"},{"label":"High School","value":"High School"},{"label":"College","value":"College"},{"label":"Professional","value":"Professional"}]}, + {"id": "sessions_per_week", "label": "Sessions Per Week", "type": "number", "required": true, "placeholder": "e.g., 3", "validation": {"min": 1}} + ] + }, + { + "id": "customer_requirements_developer", + "title": "Development Requirements", + "fields": [ + {"id": "project_type", "label": "Project Type", "type": "select", "required": true, + "options": [{"label":"Website","value":"Website"},{"label":"Mobile App","value":"Mobile App"},{"label":"E-commerce","value":"E-commerce"},{"label":"Custom Software","value":"Custom Software"}]}, + {"id": "platform", "label": "Platform", "type": "select", "required": true, + "options": [{"label":"iOS","value":"iOS"},{"label":"Android","value":"Android"},{"label":"Web","value":"Web"},{"label":"Cross-platform","value":"Cross-platform"}]}, + {"id": "feature_summary", "label": "Feature Summary", "type": "textarea", "required": true, "placeholder": "Briefly describe what the app/website should do"} + ] + }, + { + "id": "customer_requirements_video_editor", + "title": "Video Editing Requirements", + "fields": [ + {"id": "video_type", "label": "Video Type", "type": "select", "required": true, + "options": [{"label":"YouTube Video","value":"YouTube Video"},{"label":"Instagram Reel/Shorts","value":"Instagram Reel/Shorts"},{"label":"Wedding Highlights","value":"Wedding Highlights"},{"label":"Corporate Promo","value":"Corporate Promo"}]}, + {"id": "video_duration", "label": "Video Duration", "type": "select", "required": true, + "options": [{"label":"Under 1 min","value":"Under 1 min"},{"label":"1-5 mins","value":"1-5 mins"},{"label":"5-15 mins","value":"5-15 mins"},{"label":"Over 15 mins","value":"Over 15 mins"}]}, + {"id": "editing_style", "label": "Editing Style", "type": "text", "required": true, "placeholder": "e.g., Fast-paced, Cinematic, Vlog style"} + ] + }, + { + "id": "customer_requirements_graphic_designer", + "title": "Design Requirements", + "fields": [ + {"id": "design_type", "label": "Design Type", "type": "select", "required": true, + "options": [{"label":"Logo/Branding","value":"Logo/Branding"},{"label":"Social Media Posts","value":"Social Media Posts"},{"label":"UI/UX","value":"UI/UX"},{"label":"Print Media","value":"Print Media"}]}, + {"id": "brand_guidelines", "label": "Brand Guidelines", "type": "select", "required": true, + "options": [{"label":"Yes - I have them","value":"Yes - I have them"},{"label":"No - Need to create them","value":"No - Need to create them"}]}, + {"id": "asset_count", "label": "Asset Count", "type": "number", "required": true, "placeholder": "How many images/screens?", "validation": {"min": 1}} + ] + }, + { + "id": "customer_requirements_social_media_manager", + "title": "Social Media Requirements", + "fields": [ + {"id": "platforms", "label": "Platforms", "type": "select", "required": true, "multiple": true, + "options": [{"label":"Instagram","value":"Instagram"},{"label":"LinkedIn","value":"LinkedIn"},{"label":"Facebook","value":"Facebook"},{"label":"X/Twitter","value":"X/Twitter"},{"label":"YouTube","value":"YouTube"}]}, + {"id": "posting_frequency", "label": "Posting Frequency", "type": "select", "required": true, + "options": [{"label":"1-2 times/week","value":"1-2 times/week"},{"label":"3-4 times/week","value":"3-4 times/week"},{"label":"Daily","value":"Daily"}]}, + {"id": "goal", "label": "Goal", "type": "select", "required": true, + "options": [{"label":"Brand Awareness","value":"Brand Awareness"},{"label":"Lead Generation","value":"Lead Generation"},{"label":"Sales/Conversions","value":"Sales/Conversions"},{"label":"Community Building","value":"Community Building"}]} + ] + }, + { + "id": "customer_requirements_fitness_trainer", + "title": "Fitness Training Requirements", + "fields": [ + {"id": "fitness_goal", "label": "Fitness Goal", "type": "select", "required": true, + "options": [{"label":"Weight Loss","value":"Weight Loss"},{"label":"Muscle Gain","value":"Muscle Gain"},{"label":"Flexibility/Yoga","value":"Flexibility/Yoga"},{"label":"General Fitness","value":"General Fitness"}]}, + {"id": "sessions_per_week", "label": "Sessions Per Week", "type": "number", "required": true, "placeholder": "e.g., 5", "validation": {"min": 1}}, + {"id": "training_mode", "label": "Training Mode", "type": "select", "required": true, + "options": [{"label":"Online/Virtual","value":"Online/Virtual"},{"label":"In-person","value":"In-person"}]} + ] + }, + { + "id": "customer_requirements_catering_services", + "title": "Catering Requirements", + "fields": [ + {"id": "event_size", "label": "Event Size (Guests)", "type": "number", "required": true, "placeholder": "Number of guests/plates", "validation": {"min": 1}}, + {"id": "menu_preference", "label": "Menu Preference", "type": "select", "required": true, + "options": [{"label":"Pure Veg","value":"Pure Veg"},{"label":"Non-Veg","value":"Non-Veg"},{"label":"Mixed","value":"Mixed"}]}, + {"id": "cuisine_type", "label": "Cuisine Type", "type": "text", "required": true, "placeholder": "e.g., South Indian, North Indian, Continental"} + ] + }, + { + "id": "step_3_budget_timeline", + "title": "Budget and Timeline", + "fields": [ + {"id": "budget_range", "label": "Budget Range", "type": "select", "required": true, + "options": [{"label":"Under \u20b95,000","value":"Under \u20b95,000"},{"label":"\u20b95,000 - \u20b915,000","value":"\u20b95,000 - \u20b915,000"},{"label":"\u20b915,000 - \u20b950,000","value":"\u20b915,000 - \u20b950,000"},{"label":"\u20b950,000 - \u20b91,00,000","value":"\u20b950,000 - \u20b91,00,000"},{"label":"\u20b91,00,000+","value":"\u20b91,00,000+"}]}, + {"id": "expected_start", "label": "Expected Start", "type": "date", "required": true}, + {"id": "urgency", "label": "Urgency", "type": "select", "required": true, + "options": [{"label":"Relaxed (No strict deadline)","value":"Relaxed (No strict deadline)"},{"label":"Standard (Within a few weeks)","value":"Standard (Within a few weeks)"},{"label":"ASAP (Urgent)","value":"ASAP (Urgent)"}]} + ] + }, + { + "id": "step_4_location", + "title": "Location and Preference", + "fields": [ + {"id": "service_mode", "label": "Service Mode", "type": "select", "required": true, + "options": [{"label":"Onsite (In-person)","value":"Onsite (In-person)"},{"label":"Remote (Online)","value":"Remote (Online)"},{"label":"Hybrid (Mix of both)","value":"Hybrid (Mix of both)"}]}, + {"id": "address_line", "label": "Address Line", "type": "text", "required": true, "placeholder": "Street address, Landmark"}, + {"id": "service_city", "label": "Service City", "type": "text", "required": true, "readOnly": true, "defaultValue": "Chennai, India"}, + {"id": "pin_code", "label": "PIN Code", "type": "text", "required": true, "placeholder": "e.g., 600001", "validation": {"pattern": "^[0-9]{6}$", "minLength": 6, "maxLength": 6}} + ] + }, + { + "id": "step_5_review", + "title": "Final Review", + "fields": [ + {"id": "summary_note", "label": "Additional Instructions", "type": "textarea", "placeholder": "Any additional instructions or context?"} + ] + }, + { + "id": "step_6_verification", + "title": "Identity Verification", + "fields": [ + {"id": "id_type", "label": "ID Type", "type": "select", "required": true, + "options": [{"label":"Aadhaar Card","value":"Aadhaar Card"},{"label":"PAN Card","value":"PAN Card"},{"label":"Driving License","value":"Driving License"},{"label":"Voter ID","value":"Voter ID"},{"label":"Passport","value":"Passport"}]}, + {"id": "id_number", "label": "ID Number", "type": "text", "required": true, "placeholder": "Enter ID Number"}, + {"id": "id_document_upload", "label": "Upload ID Document", "type": "file", "required": true, "multiple": true, "maxFiles": 2, "accept": "application/pdf,image/jpeg,image/jpg,image/png", "maxSizeMB": 2} + ] + } + ] +}$json$::jsonb, 2, true +FROM roles WHERE key = 'CUSTOMER' +ON CONFLICT (role_id, is_active) DO UPDATE SET schema_json = EXCLUDED.schema_json, version = EXCLUDED.version; + +-- COMPANY (6 steps) +INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) +SELECT id, $json${ + "steps": [ + { + "id": "step_1_identity", + "title": "Company Identity", + "fields": [ + {"id": "company_name", "label": "Company Name", "type": "text", "required": true, "placeholder": "Your registered company name"}, + {"id": "legal_name", "label": "Legal Name", "type": "text", "required": true, "placeholder": "As per registration documents"}, + {"id": "industry", "label": "Industry", "type": "select", "required": true, + "options": [{"label":"IT/Software","value":"IT/Software"},{"label":"Marketing/Advertising","value":"Marketing/Advertising"},{"label":"EdTech","value":"EdTech"},{"label":"Media/Entertainment","value":"Media/Entertainment"},{"label":"Health/Wellness","value":"Health/Wellness"},{"label":"Food/Beverage","value":"Food/Beverage"},{"label":"Other","value":"Other"}]} + ] + }, + { + "id": "step_2_contact", + "title": "Contact Details", + "fields": [ + {"id": "contact_name", "label": "Contact Person Name", "type": "text", "required": true, "placeholder": "HR Manager or Founder"}, + {"id": "contact_email", "label": "Contact Email", "type": "email", "required": true, "placeholder": "hr@yourcompany.com"}, + {"id": "contact_phone", "label": "Contact Phone", "type": "tel", "required": true, "placeholder": "10-digit mobile number", "validation": {"pattern": "^[0-9]{10}$", "minLength": 10, "maxLength": 10}} + ] + }, + { + "id": "step_3_presence", + "title": "Company Presence", + "fields": [ + {"id": "website", "label": "Website URL", "type": "url", "required": false, "placeholder": "https://yourcompany.com"}, + {"id": "hq_city", "label": "HQ City", "type": "text", "required": true, "readOnly": true, "defaultValue": "Chennai, India"}, + {"id": "team_size", "label": "Team Size", "type": "select", "required": true, + "options": [{"label":"1-10","value":"1-10"},{"label":"11-50","value":"11-50"},{"label":"51-200","value":"51-200"},{"label":"200+","value":"200+"}]} + ] + }, + { + "id": "step_4_hiring", + "title": "Hiring Preferences", + "fields": [ + {"id": "hiring_for", "label": "Currently Hiring For", "type": "text", "required": true, "placeholder": "e.g., Frontend Developer, Sales Executive"}, + {"id": "work_mode", "label": "Work Mode", "type": "select", "required": true, + "options": [{"label":"Onsite (Work from office)","value":"Onsite"},{"label":"Remote (Work from home)","value":"Remote"},{"label":"Hybrid","value":"Hybrid"}]}, + {"id": "monthly_openings", "label": "Monthly Openings", "type": "number", "required": true, "placeholder": "Expected number of hires/month", "validation": {"min": 1}} + ] + }, + { + "id": "step_5_compliance", + "title": "Verification and Compliance", + "fields": [ + {"id": "registration_number", "label": "Company Registration Number", "type": "text", "required": true, "placeholder": "CIN / MSME / GST Number"}, + {"id": "official_email", "label": "Official Email", "type": "email", "required": true, "placeholder": "official@yourcompany.com"} + ] + }, + { + "id": "step_6_business_verification", + "title": "Business Verification", + "fields": [ + {"id": "company_doc_type", "label": "Document Type", "type": "select", "required": true, + "options": [{"label":"GST Certificate","value":"GST Certificate"},{"label":"Certificate of Incorporation","value":"Certificate of Incorporation"},{"label":"MSME/Udyam Registration","value":"MSME/Udyam Registration"},{"label":"Company PAN Card","value":"Company PAN Card"}]}, + {"id": "company_doc_upload", "label": "Upload Company Document", "type": "file", "required": true, "multiple": false, "maxFiles": 1, "accept": "application/pdf,image/jpeg,image/jpg,image/png", "maxSizeMB": 2} + ] + } + ] +}$json$::jsonb, 2, true +FROM roles WHERE key = 'COMPANY' +ON CONFLICT (role_id, is_active) DO UPDATE SET schema_json = EXCLUDED.schema_json, version = EXCLUDED.version; + +-- JOB_SEEKER (5 steps — NO resume upload to prevent phone number exposure) +INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) +SELECT id, $json${ + "steps": [ + { + "id": "step_1_basic", + "title": "Basic Profile", + "fields": [ + {"id": "full_name", "label": "Full Name", "type": "text", "required": true, "placeholder": "Your full name"}, + {"id": "city", "label": "City", "type": "text", "required": true, "readOnly": true, "defaultValue": "Chennai, India"}, + {"id": "skills", "label": "Key Skills", "type": "text", "required": true, "placeholder": "e.g., JavaScript, React, Node.js (comma-separated)"} + ] + }, + { + "id": "step_2_preferences", + "title": "Job Preferences", + "fields": [ + {"id": "preferred_role", "label": "Preferred Role", "type": "text", "required": true, "placeholder": "e.g., Frontend Developer"}, + {"id": "expected_salary", "label": "Expected Salary (LPA)", "type": "number", "required": true, "placeholder": "Annual salary in Lakhs", "validation": {"min": 0}}, + {"id": "work_mode", "label": "Work Mode", "type": "select", "required": true, + "options": [{"label":"Onsite","value":"Onsite"},{"label":"Remote","value":"Remote"},{"label":"Hybrid","value":"Hybrid"}]} + ] + }, + { + "id": "step_3_experience", + "title": "Experience Details", + "fields": [ + {"id": "experience_years", "label": "Years of Experience", "type": "number", "required": true, "placeholder": "0 for freshers", "validation": {"min": 0}}, + {"id": "latest_company", "label": "Latest/Current Company", "type": "text", "required": false, "placeholder": "Company name or 'Fresher'"}, + {"id": "notice_period", "label": "Notice Period", "type": "select", "required": true, + "options": [{"label":"Immediate","value":"Immediate"},{"label":"15 Days","value":"15 Days"},{"label":"30 Days","value":"30 Days"},{"label":"60 Days","value":"60 Days"},{"label":"90 Days","value":"90 Days"}]} + ] + }, + { + "id": "step_4_review", + "title": "About Me", + "fields": [ + {"id": "about_me", "label": "About Me", "type": "textarea", "required": true, "placeholder": "Tell companies about yourself, your strengths, and career goals"}, + {"id": "linkedin_url","label": "LinkedIn URL", "type": "url", "required": false, "placeholder": "https://linkedin.com/in/yourprofile"} + ] + }, + { + "id": "step_5_verification", + "title": "Identity Verification", + "fields": [ + {"id": "id_type", "label": "ID Type", "type": "select", "required": true, + "options": [{"label":"Aadhaar Card","value":"Aadhaar Card"},{"label":"PAN Card","value":"PAN Card"},{"label":"Driving License","value":"Driving License"},{"label":"Voter ID","value":"Voter ID"},{"label":"Passport","value":"Passport"}]}, + {"id": "id_number", "label": "ID Number", "type": "text", "required": true, "placeholder": "Enter ID Number"}, + {"id": "id_document_upload", "label": "Upload ID Document", "type": "file", "required": true, "multiple": true, "maxFiles": 2, "accept": "application/pdf,image/jpeg,image/jpg,image/png", "maxSizeMB": 2} + ] + } + ] +}$json$::jsonb, 2, true +FROM roles WHERE key = 'JOB_SEEKER' +ON CONFLICT (role_id, is_active) DO UPDATE SET schema_json = EXCLUDED.schema_json, version = EXCLUDED.version; + +-- PHOTOGRAPHER (6 steps) +INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) +SELECT id, $json${ + "steps": [ + { + "id": "step_1_profile", + "title": "Profile Details", + "fields": [ + {"id": "full_name", "label": "Full Name", "type": "text", "required": true, "placeholder": "Your full name"}, + {"id": "experience", "label": "Experience (Years)","type": "number", "required": true, "validation": {"min": 0}}, + {"id": "bio", "label": "Bio", "type": "textarea", "required": true, "placeholder": "Hi, I am a photographer specializing in..."} + ] + }, + { + "id": "step_2_contact", + "title": "Contact and Location", + "fields": [ + {"id": "email", "label": "Email", "type": "email", "required": true}, + {"id": "phone", "label": "Phone Number", "type": "tel", "required": true, "placeholder": "10-digit mobile number", "validation": {"pattern": "^[0-9]{10}$", "minLength": 10, "maxLength": 10}}, + {"id": "city", "label": "City", "type": "text", "required": true, "readOnly": true, "defaultValue": "Chennai, India"} + ] + }, + { + "id": "step_3_specialization", + "title": "Photography Specialization", + "fields": [ + {"id": "specialty", "label": "Specialty", "type": "select", "required": true, + "options": [{"label":"Wedding","value":"Wedding"},{"label":"Portrait","value":"Portrait"},{"label":"Product/Commercial","value":"Product/Commercial"},{"label":"Events","value":"Events"},{"label":"Real Estate","value":"Real Estate"},{"label":"Fashion/Editorial","value":"Fashion/Editorial"}]}, + {"id": "camera_equipment", "label": "Camera Equipment", "type": "text", "required": true, "placeholder": "e.g., Canon EOS R5, Sony A7 III"}, + {"id": "editing_software", "label": "Editing Software", "type": "select", "required": true, + "options": [{"label":"Adobe Lightroom","value":"Adobe Lightroom"},{"label":"Adobe Photoshop","value":"Adobe Photoshop"},{"label":"Capture One","value":"Capture One"},{"label":"Other","value":"Other"}]} + ] + }, + { + "id": "step_4_pricing", + "title": "Pricing and Availability", + "fields": [ + {"id": "pricing_model", "label": "Pricing Model", "type": "select", "required": true, + "options": [{"label":"Hourly","value":"Hourly"},{"label":"Per Session/Event","value":"Per Session/Event"},{"label":"Per Project","value":"Per Project"},{"label":"Custom Package","value":"Custom Package"}]}, + {"id": "base_rate", "label": "Base Rate (\u20b9)","type": "number", "required": true, "placeholder": "Starting price in INR", "validation": {"min": 0}}, + {"id": "availability", "label": "Availability", "type": "select", "required": true, + "options": [{"label":"Weekdays","value":"Weekdays"},{"label":"Weekends","value":"Weekends"},{"label":"All Days","value":"All Days"},{"label":"By Appointment","value":"By Appointment"}]} + ] + }, + { + "id": "step_5_portfolio", + "title": "Portfolio", + "fields": [ + {"id": "portfolio_images", "label": "Portfolio Images (up to 6)", "type": "file", "required": true, "multiple": true, "maxFiles": 6, "accept": "image/jpeg,image/jpg,image/png,image/webp", "maxSizeMB": 2, "helperText": "Upload up to 6 images, max 2MB each. Displayed in 3\u00d72 grid."}, + {"id": "portfolio_url", "label": "External Portfolio URL", "type": "url", "required": false, "placeholder": "Instagram, website, or Behance link"}, + {"id": "portfolio_note", "label": "Portfolio Note", "type": "textarea", "placeholder": "Describe your style and best work"} + ] + }, + { + "id": "step_6_verification", + "title": "Identity Verification", + "fields": [ + {"id": "id_type", "label": "ID Type", "type": "select", "required": true, + "options": [{"label":"Aadhaar Card","value":"Aadhaar Card"},{"label":"PAN Card","value":"PAN Card"},{"label":"Driving License","value":"Driving License"},{"label":"Voter ID","value":"Voter ID"},{"label":"Passport","value":"Passport"}]}, + {"id": "id_number", "label": "ID Number", "type": "text", "required": true, "placeholder": "Enter ID Number"}, + {"id": "id_document_upload", "label": "Upload ID Document", "type": "file", "required": true, "multiple": true, "maxFiles": 2, "accept": "application/pdf,image/jpeg,image/jpg,image/png", "maxSizeMB": 2} + ] + } + ] +}$json$::jsonb, 2, true +FROM roles WHERE key = 'PHOTOGRAPHER' +ON CONFLICT (role_id, is_active) DO UPDATE SET schema_json = EXCLUDED.schema_json, version = EXCLUDED.version; + +-- MAKEUP_ARTIST (6 steps) +INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) +SELECT id, $json${ + "steps": [ + { + "id": "step_1_profile", + "title": "Profile Details", + "fields": [ + {"id": "full_name", "label": "Full Name", "type": "text", "required": true, "placeholder": "Your full name"}, + {"id": "experience", "label": "Experience (Years)", "type": "number", "required": true, "validation": {"min": 0}}, + {"id": "bio", "label": "Bio", "type": "textarea", "required": true, "placeholder": "Hi, I am a makeup artist specializing in..."} + ] + }, + { + "id": "step_2_contact", + "title": "Contact and Location", + "fields": [ + {"id": "email", "label": "Email", "type": "email", "required": true}, + {"id": "phone", "label": "Phone Number", "type": "tel", "required": true, "placeholder": "10-digit mobile number", "validation": {"pattern": "^[0-9]{10}$", "minLength": 10, "maxLength": 10}}, + {"id": "city", "label": "City", "type": "text", "required": true, "readOnly": true, "defaultValue": "Chennai, India"} + ] + }, + { + "id": "step_3_specialization", + "title": "Makeup Specialization", + "fields": [ + {"id": "makeup_specialty", "label": "Makeup Specialty", "type": "select", "required": true, + "options": [{"label":"Bridal","value":"Bridal"},{"label":"Editorial/Fashion","value":"Editorial/Fashion"},{"label":"Film/TV","value":"Film/TV"},{"label":"Special Effects","value":"Special Effects"},{"label":"Party/Events","value":"Party/Events"}]}, + {"id": "preferred_brands", "label": "Preferred Brands", "type": "text", "required": true, "placeholder": "e.g., MAC, Huda Beauty, Kryolan"}, + {"id": "services_offered", "label": "Services Offered", "type": "select", "required": true, "multiple": true, + "options": [{"label":"Bridal Makeup","value":"Bridal Makeup"},{"label":"Party Makeup","value":"Party Makeup"},{"label":"Photoshoot Makeup","value":"Photoshoot Makeup"},{"label":"Grooming","value":"Grooming"},{"label":"Airbrush Makeup","value":"Airbrush Makeup"}]} + ] + }, + { + "id": "step_4_pricing", + "title": "Pricing and Availability", + "fields": [ + {"id": "pricing_model", "label": "Pricing Model", "type": "select", "required": true, + "options": [{"label":"Per Session","value":"Per Session"},{"label":"Per Person","value":"Per Person"},{"label":"Package-based","value":"Package-based"},{"label":"Custom","value":"Custom"}]}, + {"id": "base_rate", "label": "Base Rate (\u20b9)", "type": "number", "required": true, "placeholder": "Starting price in INR", "validation": {"min": 0}}, + {"id": "availability", "label": "Availability", "type": "select", "required": true, + "options": [{"label":"Weekdays","value":"Weekdays"},{"label":"Weekends","value":"Weekends"},{"label":"All Days","value":"All Days"},{"label":"By Appointment","value":"By Appointment"}]} + ] + }, + { + "id": "step_5_portfolio", + "title": "Portfolio", + "fields": [ + {"id": "portfolio_images", "label": "Portfolio Images (up to 6)", "type": "file", "required": true, "multiple": true, "maxFiles": 6, "accept": "image/jpeg,image/jpg,image/png,image/webp", "maxSizeMB": 2, "helperText": "Upload up to 6 images, max 2MB each. Displayed in 3\u00d72 grid."}, + {"id": "portfolio_url", "label": "External Portfolio URL", "type": "url", "required": false, "placeholder": "Instagram or website link"}, + {"id": "portfolio_note", "label": "Portfolio Note", "type": "textarea", "placeholder": "Describe your style and best work"} + ] + }, + { + "id": "step_6_verification", + "title": "Identity Verification", + "fields": [ + {"id": "id_type", "label": "ID Type", "type": "select", "required": true, + "options": [{"label":"Aadhaar Card","value":"Aadhaar Card"},{"label":"PAN Card","value":"PAN Card"},{"label":"Driving License","value":"Driving License"},{"label":"Voter ID","value":"Voter ID"},{"label":"Passport","value":"Passport"}]}, + {"id": "id_number", "label": "ID Number", "type": "text", "required": true, "placeholder": "Enter ID Number"}, + {"id": "id_document_upload", "label": "Upload ID Document", "type": "file", "required": true, "multiple": true, "maxFiles": 2, "accept": "application/pdf,image/jpeg,image/jpg,image/png", "maxSizeMB": 2} + ] + } + ] +}$json$::jsonb, 2, true +FROM roles WHERE key = 'MAKEUP_ARTIST' +ON CONFLICT (role_id, is_active) DO UPDATE SET schema_json = EXCLUDED.schema_json, version = EXCLUDED.version; + +-- TUTOR (6 steps) +INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) +SELECT id, $json${ + "steps": [ + { + "id": "step_1_profile", + "title": "Profile Details", + "fields": [ + {"id": "full_name", "label": "Full Name", "type": "text", "required": true, "placeholder": "Your full name"}, + {"id": "experience", "label": "Experience (Years)", "type": "number", "required": true, "validation": {"min": 0}}, + {"id": "bio", "label": "Bio", "type": "textarea", "required": true, "placeholder": "Hi, I am a tutor specializing in..."} + ] + }, + { + "id": "step_2_contact", + "title": "Contact and Location", + "fields": [ + {"id": "email", "label": "Email", "type": "email", "required": true}, + {"id": "phone", "label": "Phone Number", "type": "tel", "required": true, "placeholder": "10-digit mobile number", "validation": {"pattern": "^[0-9]{10}$", "minLength": 10, "maxLength": 10}}, + {"id": "city", "label": "City", "type": "text", "required": true, "readOnly": true, "defaultValue": "Chennai, India"} + ] + }, + { + "id": "step_3_specialization", + "title": "Teaching Specialization", + "fields": [ + {"id": "subjects", "label": "Subjects", "type": "text", "required": true, "placeholder": "e.g., Mathematics, Physics, Spoken English"}, + {"id": "grade_levels", "label": "Grade Levels", "type": "select", "required": true, "multiple": true, + "options": [{"label":"Primary (1-5)","value":"Primary"},{"label":"Middle School (6-8)","value":"Middle School"},{"label":"High School (9-12)","value":"High School"},{"label":"College","value":"College"},{"label":"Professional/Adult","value":"Professional"}]}, + {"id": "teaching_mode", "label": "Teaching Mode", "type": "select", "required": true, + "options": [{"label":"Online","value":"Online"},{"label":"Offline (Home visits)","value":"Offline"},{"label":"Hybrid","value":"Hybrid"}]} + ] + }, + { + "id": "step_4_pricing", + "title": "Pricing and Availability", + "fields": [ + {"id": "pricing_model", "label": "Pricing Model", "type": "select", "required": true, + "options": [{"label":"Per Hour","value":"Per Hour"},{"label":"Per Session","value":"Per Session"},{"label":"Monthly Package","value":"Monthly Package"}]}, + {"id": "base_rate", "label": "Base Rate (\u20b9)", "type": "number", "required": true, "placeholder": "Starting price in INR", "validation": {"min": 0}}, + {"id": "availability", "label": "Availability", "type": "select", "required": true, + "options": [{"label":"Weekdays","value":"Weekdays"},{"label":"Weekends","value":"Weekends"},{"label":"All Days","value":"All Days"},{"label":"By Appointment","value":"By Appointment"}]} + ] + }, + { + "id": "step_5_portfolio", + "title": "Credentials and Work Samples", + "fields": [ + {"id": "portfolio_images", "label": "Certificates / Work Samples (up to 6)", "type": "file", "required": false, "multiple": true, "maxFiles": 6, "accept": "image/jpeg,image/jpg,image/png,image/webp", "maxSizeMB": 2, "helperText": "Upload certificates or student work samples, max 2MB each."}, + {"id": "portfolio_url", "label": "Online Profile or Course Link", "type": "url", "required": false, "placeholder": "LinkedIn, Vedantu, or website link"}, + {"id": "portfolio_note", "label": "Teaching Approach", "type": "textarea", "placeholder": "Describe your teaching style and methodology"} + ] + }, + { + "id": "step_6_verification", + "title": "Identity Verification", + "fields": [ + {"id": "id_type", "label": "ID Type", "type": "select", "required": true, + "options": [{"label":"Aadhaar Card","value":"Aadhaar Card"},{"label":"PAN Card","value":"PAN Card"},{"label":"Driving License","value":"Driving License"},{"label":"Voter ID","value":"Voter ID"},{"label":"Passport","value":"Passport"}]}, + {"id": "id_number", "label": "ID Number", "type": "text", "required": true, "placeholder": "Enter ID Number"}, + {"id": "id_document_upload", "label": "Upload ID Document", "type": "file", "required": true, "multiple": true, "maxFiles": 2, "accept": "application/pdf,image/jpeg,image/jpg,image/png", "maxSizeMB": 2} + ] + } + ] +}$json$::jsonb, 2, true +FROM roles WHERE key = 'TUTOR' +ON CONFLICT (role_id, is_active) DO UPDATE SET schema_json = EXCLUDED.schema_json, version = EXCLUDED.version; + +-- DEVELOPER (6 steps) +INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) +SELECT id, $json${ + "steps": [ + { + "id": "step_1_profile", + "title": "Profile Details", + "fields": [ + {"id": "full_name", "label": "Full Name", "type": "text", "required": true, "placeholder": "Your full name"}, + {"id": "experience", "label": "Experience (Years)", "type": "number", "required": true, "validation": {"min": 0}}, + {"id": "bio", "label": "Bio", "type": "textarea", "required": true, "placeholder": "Hi, I am a developer specializing in..."} + ] + }, + { + "id": "step_2_contact", + "title": "Contact and Location", + "fields": [ + {"id": "email", "label": "Email", "type": "email", "required": true}, + {"id": "phone", "label": "Phone Number", "type": "tel", "required": true, "placeholder": "10-digit mobile number", "validation": {"pattern": "^[0-9]{10}$", "minLength": 10, "maxLength": 10}}, + {"id": "city", "label": "City", "type": "text", "required": true, "readOnly": true, "defaultValue": "Chennai, India"} + ] + }, + { + "id": "step_3_specialization", + "title": "Development Specialization", + "fields": [ + {"id": "developer_type", "label": "Developer Type", "type": "select", "required": true, + "options": [{"label":"Frontend","value":"Frontend"},{"label":"Backend","value":"Backend"},{"label":"Full-stack","value":"Full-stack"},{"label":"Mobile (iOS/Android)","value":"Mobile"},{"label":"DevOps/Cloud","value":"DevOps"}]}, + {"id": "tech_stack", "label": "Tech Stack", "type": "text", "required": true, "placeholder": "e.g., React, Node.js, PostgreSQL, Docker"}, + {"id": "open_source_profile", "label": "GitHub / Portfolio URL", "type": "url", "required": false, "placeholder": "https://github.com/yourusername"} + ] + }, + { + "id": "step_4_pricing", + "title": "Pricing and Availability", + "fields": [ + {"id": "pricing_model", "label": "Pricing Model", "type": "select", "required": true, + "options": [{"label":"Hourly","value":"Hourly"},{"label":"Per Project","value":"Per Project"},{"label":"Monthly Retainer","value":"Monthly Retainer"},{"label":"Custom","value":"Custom"}]}, + {"id": "base_rate", "label": "Base Rate (\u20b9)", "type": "number", "required": true, "placeholder": "Starting price in INR", "validation": {"min": 0}}, + {"id": "availability", "label": "Availability", "type": "select", "required": true, + "options": [{"label":"Full-time","value":"Full-time"},{"label":"Part-time","value":"Part-time"},{"label":"Weekends Only","value":"Weekends Only"},{"label":"Flexible","value":"Flexible"}]} + ] + }, + { + "id": "step_5_portfolio", + "title": "Portfolio", + "fields": [ + {"id": "portfolio_images", "label": "Screenshots / Work Samples (up to 6)", "type": "file", "required": false, "multiple": true, "maxFiles": 6, "accept": "image/jpeg,image/jpg,image/png,image/webp", "maxSizeMB": 2, "helperText": "Upload app/website screenshots, max 2MB each."}, + {"id": "portfolio_url", "label": "Live Project / Portfolio URL", "type": "url", "required": false, "placeholder": "Deployed project, Behance, or GitHub Pages"}, + {"id": "portfolio_note", "label": "Portfolio Note", "type": "textarea", "placeholder": "Describe your most impactful projects"} + ] + }, + { + "id": "step_6_verification", + "title": "Identity Verification", + "fields": [ + {"id": "id_type", "label": "ID Type", "type": "select", "required": true, + "options": [{"label":"Aadhaar Card","value":"Aadhaar Card"},{"label":"PAN Card","value":"PAN Card"},{"label":"Driving License","value":"Driving License"},{"label":"Voter ID","value":"Voter ID"},{"label":"Passport","value":"Passport"}]}, + {"id": "id_number", "label": "ID Number", "type": "text", "required": true, "placeholder": "Enter ID Number"}, + {"id": "id_document_upload", "label": "Upload ID Document", "type": "file", "required": true, "multiple": true, "maxFiles": 2, "accept": "application/pdf,image/jpeg,image/jpg,image/png", "maxSizeMB": 2} + ] + } + ] +}$json$::jsonb, 2, true +FROM roles WHERE key = 'DEVELOPER' +ON CONFLICT (role_id, is_active) DO UPDATE SET schema_json = EXCLUDED.schema_json, version = EXCLUDED.version; + +-- VIDEO_EDITOR (6 steps) +INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) +SELECT id, $json${ + "steps": [ + { + "id": "step_1_profile", + "title": "Profile Details", + "fields": [ + {"id": "full_name", "label": "Full Name", "type": "text", "required": true, "placeholder": "Your full name"}, + {"id": "experience", "label": "Experience (Years)", "type": "number", "required": true, "validation": {"min": 0}}, + {"id": "bio", "label": "Bio", "type": "textarea", "required": true, "placeholder": "Hi, I am a video editor specializing in..."} + ] + }, + { + "id": "step_2_contact", + "title": "Contact and Location", + "fields": [ + {"id": "email", "label": "Email", "type": "email", "required": true}, + {"id": "phone", "label": "Phone Number", "type": "tel", "required": true, "placeholder": "10-digit mobile number", "validation": {"pattern": "^[0-9]{10}$", "minLength": 10, "maxLength": 10}}, + {"id": "city", "label": "City", "type": "text", "required": true, "readOnly": true, "defaultValue": "Chennai, India"} + ] + }, + { + "id": "step_3_specialization", + "title": "Video Editing Specialization", + "fields": [ + {"id": "editing_software", "label": "Primary Editing Software", "type": "select", "required": true, + "options": [{"label":"Adobe Premiere Pro","value":"Adobe Premiere Pro"},{"label":"Final Cut Pro","value":"Final Cut Pro"},{"label":"DaVinci Resolve","value":"DaVinci Resolve"},{"label":"After Effects","value":"After Effects"},{"label":"CapCut","value":"CapCut"}]}, + {"id": "video_specialties", "label": "Video Specialties", "type": "select", "required": true, "multiple": true, + "options": [{"label":"YouTube Videos","value":"YouTube Videos"},{"label":"Instagram Reels/Shorts","value":"Reels/Shorts"},{"label":"Wedding Highlights","value":"Wedding Highlights"},{"label":"Corporate Promo","value":"Corporate Promo"},{"label":"Explainer/Animation","value":"Explainer"}]}, + {"id": "turnaround_time", "label": "Typical Turnaround", "type": "select", "required": true, + "options": [{"label":"24-48 hours","value":"24-48 hours"},{"label":"3-5 days","value":"3-5 days"},{"label":"1-2 weeks","value":"1-2 weeks"},{"label":"Depends on project","value":"Depends"}]} + ] + }, + { + "id": "step_4_pricing", + "title": "Pricing and Availability", + "fields": [ + {"id": "pricing_model", "label": "Pricing Model", "type": "select", "required": true, + "options": [{"label":"Per Video","value":"Per Video"},{"label":"Per Minute of Output","value":"Per Minute"},{"label":"Hourly","value":"Hourly"},{"label":"Monthly Retainer","value":"Monthly Retainer"}]}, + {"id": "base_rate", "label": "Base Rate (\u20b9)", "type": "number", "required": true, "placeholder": "Starting price in INR", "validation": {"min": 0}}, + {"id": "availability", "label": "Availability", "type": "select", "required": true, + "options": [{"label":"Full-time","value":"Full-time"},{"label":"Part-time","value":"Part-time"},{"label":"Weekends Only","value":"Weekends Only"},{"label":"Flexible","value":"Flexible"}]} + ] + }, + { + "id": "step_5_portfolio", + "title": "Portfolio", + "fields": [ + {"id": "portfolio_images", "label": "Work Thumbnails / Stills (up to 6)", "type": "file", "required": false, "multiple": true, "maxFiles": 6, "accept": "image/jpeg,image/jpg,image/png,image/webp", "maxSizeMB": 2, "helperText": "Upload video thumbnails or stills, max 2MB each."}, + {"id": "portfolio_url", "label": "YouTube / Vimeo Portfolio Link", "type": "url", "required": true, "placeholder": "Link to your best video work"}, + {"id": "portfolio_note", "label": "Portfolio Note", "type": "textarea", "placeholder": "Describe your editing style and best projects"} + ] + }, + { + "id": "step_6_verification", + "title": "Identity Verification", + "fields": [ + {"id": "id_type", "label": "ID Type", "type": "select", "required": true, + "options": [{"label":"Aadhaar Card","value":"Aadhaar Card"},{"label":"PAN Card","value":"PAN Card"},{"label":"Driving License","value":"Driving License"},{"label":"Voter ID","value":"Voter ID"},{"label":"Passport","value":"Passport"}]}, + {"id": "id_number", "label": "ID Number", "type": "text", "required": true, "placeholder": "Enter ID Number"}, + {"id": "id_document_upload", "label": "Upload ID Document", "type": "file", "required": true, "multiple": true, "maxFiles": 2, "accept": "application/pdf,image/jpeg,image/jpg,image/png", "maxSizeMB": 2} + ] + } + ] +}$json$::jsonb, 2, true +FROM roles WHERE key = 'VIDEO_EDITOR' +ON CONFLICT (role_id, is_active) DO UPDATE SET schema_json = EXCLUDED.schema_json, version = EXCLUDED.version; + +-- GRAPHIC_DESIGNER (6 steps) +INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) +SELECT id, $json${ + "steps": [ + { + "id": "step_1_profile", + "title": "Profile Details", + "fields": [ + {"id": "full_name", "label": "Full Name", "type": "text", "required": true, "placeholder": "Your full name"}, + {"id": "experience", "label": "Experience (Years)", "type": "number", "required": true, "validation": {"min": 0}}, + {"id": "bio", "label": "Bio", "type": "textarea", "required": true, "placeholder": "Hi, I am a graphic designer specializing in..."} + ] + }, + { + "id": "step_2_contact", + "title": "Contact and Location", + "fields": [ + {"id": "email", "label": "Email", "type": "email", "required": true}, + {"id": "phone", "label": "Phone Number", "type": "tel", "required": true, "placeholder": "10-digit mobile number", "validation": {"pattern": "^[0-9]{10}$", "minLength": 10, "maxLength": 10}}, + {"id": "city", "label": "City", "type": "text", "required": true, "readOnly": true, "defaultValue": "Chennai, India"} + ] + }, + { + "id": "step_3_specialization", + "title": "Design Specialization", + "fields": [ + {"id": "design_tools", "label": "Design Tools", "type": "select", "required": true, "multiple": true, + "options": [{"label":"Figma","value":"Figma"},{"label":"Adobe Illustrator","value":"Adobe Illustrator"},{"label":"Adobe Photoshop","value":"Adobe Photoshop"},{"label":"Canva Pro","value":"Canva Pro"},{"label":"Adobe InDesign","value":"Adobe InDesign"}]}, + {"id": "design_specialties","label": "Design Specialties","type": "select", "required": true, "multiple": true, + "options": [{"label":"Branding/Logo","value":"Branding/Logo"},{"label":"UI/UX Design","value":"UI/UX"},{"label":"Print Media","value":"Print Media"},{"label":"Social Media Graphics","value":"Social Media"},{"label":"Motion Graphics","value":"Motion Graphics"}]}, + {"id": "style_note", "label": "Design Style", "type": "text", "required": false, "placeholder": "e.g., Minimalist, Bold, Illustrative"} + ] + }, + { + "id": "step_4_pricing", + "title": "Pricing and Availability", + "fields": [ + {"id": "pricing_model", "label": "Pricing Model", "type": "select", "required": true, + "options": [{"label":"Per Design/Asset","value":"Per Design"},{"label":"Per Project","value":"Per Project"},{"label":"Hourly","value":"Hourly"},{"label":"Monthly Retainer","value":"Monthly Retainer"}]}, + {"id": "base_rate", "label": "Base Rate (\u20b9)", "type": "number", "required": true, "placeholder": "Starting price in INR", "validation": {"min": 0}}, + {"id": "availability", "label": "Availability", "type": "select", "required": true, + "options": [{"label":"Full-time","value":"Full-time"},{"label":"Part-time","value":"Part-time"},{"label":"Weekends Only","value":"Weekends Only"},{"label":"Flexible","value":"Flexible"}]} + ] + }, + { + "id": "step_5_portfolio", + "title": "Portfolio", + "fields": [ + {"id": "portfolio_images", "label": "Design Samples (up to 6)", "type": "file", "required": true, "multiple": true, "maxFiles": 6, "accept": "image/jpeg,image/jpg,image/png,image/webp", "maxSizeMB": 2, "helperText": "Upload your best design work, max 2MB each. Displayed in 3\u00d72 grid."}, + {"id": "portfolio_url", "label": "Behance / Dribbble / Portfolio URL", "type": "url", "required": false, "placeholder": "https://behance.net/yourprofile"}, + {"id": "portfolio_note", "label": "Portfolio Note", "type": "textarea", "placeholder": "Describe your design philosophy and best projects"} + ] + }, + { + "id": "step_6_verification", + "title": "Identity Verification", + "fields": [ + {"id": "id_type", "label": "ID Type", "type": "select", "required": true, + "options": [{"label":"Aadhaar Card","value":"Aadhaar Card"},{"label":"PAN Card","value":"PAN Card"},{"label":"Driving License","value":"Driving License"},{"label":"Voter ID","value":"Voter ID"},{"label":"Passport","value":"Passport"}]}, + {"id": "id_number", "label": "ID Number", "type": "text", "required": true, "placeholder": "Enter ID Number"}, + {"id": "id_document_upload", "label": "Upload ID Document", "type": "file", "required": true, "multiple": true, "maxFiles": 2, "accept": "application/pdf,image/jpeg,image/jpg,image/png", "maxSizeMB": 2} + ] + } + ] +}$json$::jsonb, 2, true +FROM roles WHERE key = 'GRAPHIC_DESIGNER' +ON CONFLICT (role_id, is_active) DO UPDATE SET schema_json = EXCLUDED.schema_json, version = EXCLUDED.version; + +-- SOCIAL_MEDIA_MANAGER (6 steps) +INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) +SELECT id, $json${ + "steps": [ + { + "id": "step_1_profile", + "title": "Profile Details", + "fields": [ + {"id": "full_name", "label": "Full Name", "type": "text", "required": true, "placeholder": "Your full name"}, + {"id": "experience", "label": "Experience (Years)", "type": "number", "required": true, "validation": {"min": 0}}, + {"id": "bio", "label": "Bio", "type": "textarea", "required": true, "placeholder": "Hi, I am a social media manager specializing in..."} + ] + }, + { + "id": "step_2_contact", + "title": "Contact and Location", + "fields": [ + {"id": "email", "label": "Email", "type": "email", "required": true}, + {"id": "phone", "label": "Phone Number", "type": "tel", "required": true, "placeholder": "10-digit mobile number", "validation": {"pattern": "^[0-9]{10}$", "minLength": 10, "maxLength": 10}}, + {"id": "city", "label": "City", "type": "text", "required": true, "readOnly": true, "defaultValue": "Chennai, India"} + ] + }, + { + "id": "step_3_specialization", + "title": "Social Media Specialization", + "fields": [ + {"id": "platforms_managed", "label": "Platforms Managed", "type": "select", "required": true, "multiple": true, + "options": [{"label":"Instagram","value":"Instagram"},{"label":"LinkedIn","value":"LinkedIn"},{"label":"Facebook","value":"Facebook"},{"label":"X/Twitter","value":"X/Twitter"},{"label":"YouTube","value":"YouTube"},{"label":"Pinterest","value":"Pinterest"}]}, + {"id": "content_types", "label": "Content Types", "type": "select", "required": true, "multiple": true, + "options": [{"label":"Graphics/Creatives","value":"Graphics"},{"label":"Reels/Short Videos","value":"Reels"},{"label":"Copywriting/Captions","value":"Copywriting"},{"label":"Stories","value":"Stories"},{"label":"Analytics & Reports","value":"Analytics"}]}, + {"id": "tools_used", "label": "Tools Used", "type": "text", "required": true, "placeholder": "e.g., Canva, Hootsuite, Buffer, Meta Business Suite"} + ] + }, + { + "id": "step_4_pricing", + "title": "Pricing and Availability", + "fields": [ + {"id": "pricing_model", "label": "Pricing Model", "type": "select", "required": true, + "options": [{"label":"Monthly Retainer","value":"Monthly Retainer"},{"label":"Per Platform","value":"Per Platform"},{"label":"Per Post","value":"Per Post"},{"label":"Custom Package","value":"Custom Package"}]}, + {"id": "base_rate", "label": "Base Rate (\u20b9)", "type": "number", "required": true, "placeholder": "Starting price in INR", "validation": {"min": 0}}, + {"id": "availability", "label": "Availability", "type": "select", "required": true, + "options": [{"label":"Full-time","value":"Full-time"},{"label":"Part-time","value":"Part-time"},{"label":"Flexible","value":"Flexible"}]} + ] + }, + { + "id": "step_5_portfolio", + "title": "Portfolio", + "fields": [ + {"id": "portfolio_images", "label": "Content Samples (up to 6)", "type": "file", "required": true, "multiple": true, "maxFiles": 6, "accept": "image/jpeg,image/jpg,image/png,image/webp", "maxSizeMB": 2, "helperText": "Upload posts, stories, or analytics screenshots, max 2MB each."}, + {"id": "portfolio_url", "label": "Sample Brand Account URL", "type": "url", "required": false, "placeholder": "Instagram/LinkedIn page you manage"}, + {"id": "portfolio_note", "label": "Portfolio Note", "type": "textarea", "placeholder": "Describe brands you have worked with and results achieved"} + ] + }, + { + "id": "step_6_verification", + "title": "Identity Verification", + "fields": [ + {"id": "id_type", "label": "ID Type", "type": "select", "required": true, + "options": [{"label":"Aadhaar Card","value":"Aadhaar Card"},{"label":"PAN Card","value":"PAN Card"},{"label":"Driving License","value":"Driving License"},{"label":"Voter ID","value":"Voter ID"},{"label":"Passport","value":"Passport"}]}, + {"id": "id_number", "label": "ID Number", "type": "text", "required": true, "placeholder": "Enter ID Number"}, + {"id": "id_document_upload", "label": "Upload ID Document", "type": "file", "required": true, "multiple": true, "maxFiles": 2, "accept": "application/pdf,image/jpeg,image/jpg,image/png", "maxSizeMB": 2} + ] + } + ] +}$json$::jsonb, 2, true +FROM roles WHERE key = 'SOCIAL_MEDIA_MANAGER' +ON CONFLICT (role_id, is_active) DO UPDATE SET schema_json = EXCLUDED.schema_json, version = EXCLUDED.version; + +-- FITNESS_TRAINER (6 steps) +INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) +SELECT id, $json${ + "steps": [ + { + "id": "step_1_profile", + "title": "Profile Details", + "fields": [ + {"id": "full_name", "label": "Full Name", "type": "text", "required": true, "placeholder": "Your full name"}, + {"id": "experience", "label": "Experience (Years)", "type": "number", "required": true, "validation": {"min": 0}}, + {"id": "bio", "label": "Bio", "type": "textarea", "required": true, "placeholder": "Hi, I am a fitness trainer specializing in..."} + ] + }, + { + "id": "step_2_contact", + "title": "Contact and Location", + "fields": [ + {"id": "email", "label": "Email", "type": "email", "required": true}, + {"id": "phone", "label": "Phone Number", "type": "tel", "required": true, "placeholder": "10-digit mobile number", "validation": {"pattern": "^[0-9]{10}$", "minLength": 10, "maxLength": 10}}, + {"id": "city", "label": "City", "type": "text", "required": true, "readOnly": true, "defaultValue": "Chennai, India"} + ] + }, + { + "id": "step_3_specialization", + "title": "Training Specialization", + "fields": [ + {"id": "training_specialties", "label": "Training Specialties", "type": "select", "required": true, "multiple": true, + "options": [{"label":"Weight Loss","value":"Weight Loss"},{"label":"Muscle Gain","value":"Muscle Gain"},{"label":"HIIT","value":"HIIT"},{"label":"Yoga/Flexibility","value":"Yoga/Flexibility"},{"label":"CrossFit","value":"CrossFit"},{"label":"Sports-specific","value":"Sports-specific"},{"label":"Rehabilitation","value":"Rehabilitation"}]}, + {"id": "certifications", "label": "Certifications", "type": "text", "required": false, "placeholder": "e.g., ACE CPT, NASM CPT, RYT-200"}, + {"id": "training_mode", "label": "Training Mode", "type": "select", "required": true, + "options": [{"label":"Online/Virtual","value":"Online"},{"label":"In-person (Client's location)","value":"In-person"},{"label":"Both Online and In-person","value":"Both"}]} + ] + }, + { + "id": "step_4_pricing", + "title": "Pricing and Availability", + "fields": [ + {"id": "pricing_model", "label": "Pricing Model", "type": "select", "required": true, + "options": [{"label":"Per Session","value":"Per Session"},{"label":"Monthly Package","value":"Monthly Package"},{"label":"Quarterly Package","value":"Quarterly Package"},{"label":"Custom","value":"Custom"}]}, + {"id": "base_rate", "label": "Base Rate (\u20b9)", "type": "number", "required": true, "placeholder": "Starting price in INR", "validation": {"min": 0}}, + {"id": "availability", "label": "Availability", "type": "select", "required": true, + "options": [{"label":"Early Morning (5-8 AM)","value":"Early Morning"},{"label":"Morning (8-12 PM)","value":"Morning"},{"label":"Evening (5-9 PM)","value":"Evening"},{"label":"Flexible","value":"Flexible"}]} + ] + }, + { + "id": "step_5_portfolio", + "title": "Portfolio", + "fields": [ + {"id": "portfolio_images", "label": "Transformation Photos / Certificates (up to 6)", "type": "file", "required": false, "multiple": true, "maxFiles": 6, "accept": "image/jpeg,image/jpg,image/png,image/webp", "maxSizeMB": 2, "helperText": "Upload client transformation photos or certification images, max 2MB each."}, + {"id": "portfolio_url", "label": "Instagram / YouTube Channel", "type": "url", "required": false, "placeholder": "Your fitness social media or YouTube link"}, + {"id": "portfolio_note", "label": "Portfolio Note", "type": "textarea", "placeholder": "Describe your training philosophy and client success stories"} + ] + }, + { + "id": "step_6_verification", + "title": "Identity Verification", + "fields": [ + {"id": "id_type", "label": "ID Type", "type": "select", "required": true, + "options": [{"label":"Aadhaar Card","value":"Aadhaar Card"},{"label":"PAN Card","value":"PAN Card"},{"label":"Driving License","value":"Driving License"},{"label":"Voter ID","value":"Voter ID"},{"label":"Passport","value":"Passport"}]}, + {"id": "id_number", "label": "ID Number", "type": "text", "required": true, "placeholder": "Enter ID Number"}, + {"id": "id_document_upload", "label": "Upload ID Document", "type": "file", "required": true, "multiple": true, "maxFiles": 2, "accept": "application/pdf,image/jpeg,image/jpg,image/png", "maxSizeMB": 2} + ] + } + ] +}$json$::jsonb, 2, true +FROM roles WHERE key = 'FITNESS_TRAINER' +ON CONFLICT (role_id, is_active) DO UPDATE SET schema_json = EXCLUDED.schema_json, version = EXCLUDED.version; + +-- CATERING_SERVICES (6 steps) +INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) +SELECT id, $json${ + "steps": [ + { + "id": "step_1_profile", + "title": "Profile Details", + "fields": [ + {"id": "full_name", "label": "Full Name / Business Name", "type": "text", "required": true, "placeholder": "Your name or catering business name"}, + {"id": "experience", "label": "Experience (Years)", "type": "number", "required": true, "validation": {"min": 0}}, + {"id": "bio", "label": "Bio", "type": "textarea", "required": true, "placeholder": "Tell us about your catering business and specialties..."} + ] + }, + { + "id": "step_2_contact", + "title": "Contact and Location", + "fields": [ + {"id": "email", "label": "Email", "type": "email", "required": true}, + {"id": "phone", "label": "Phone Number", "type": "tel", "required": true, "placeholder": "10-digit mobile number", "validation": {"pattern": "^[0-9]{10}$", "minLength": 10, "maxLength": 10}}, + {"id": "city", "label": "City", "type": "text", "required": true, "readOnly": true, "defaultValue": "Chennai, India"} + ] + }, + { + "id": "step_3_specialization", + "title": "Catering Specialization", + "fields": [ + {"id": "cuisine_types", "label": "Cuisine Types", "type": "select", "required": true, "multiple": true, + "options": [{"label":"South Indian","value":"South Indian"},{"label":"North Indian","value":"North Indian"},{"label":"Continental","value":"Continental"},{"label":"Chinese","value":"Chinese"},{"label":"Fusion","value":"Fusion"},{"label":"Biryani/Mughlai","value":"Biryani/Mughlai"}]}, + {"id": "dietary_options", "label": "Dietary Options", "type": "select", "required": true, "multiple": true, + "options": [{"label":"Pure Veg","value":"Pure Veg"},{"label":"Non-Veg","value":"Non-Veg"},{"label":"Vegan","value":"Vegan"},{"label":"Jain","value":"Jain"}]}, + {"id": "max_capacity", "label": "Max Capacity (Plates/Guests)", "type": "number", "required": true, "placeholder": "Maximum plates per event", "validation": {"min": 1}} + ] + }, + { + "id": "step_4_pricing", + "title": "Pricing and Availability", + "fields": [ + {"id": "pricing_model", "label": "Pricing Model", "type": "select", "required": true, + "options": [{"label":"Per Plate","value":"Per Plate"},{"label":"Per Event","value":"Per Event"},{"label":"Package-based","value":"Package-based"},{"label":"Custom Quote","value":"Custom Quote"}]}, + {"id": "base_rate", "label": "Base Rate (\u20b9)", "type": "number", "required": true, "placeholder": "Starting price (e.g., per plate or per event)", "validation": {"min": 0}}, + {"id": "advance_notice","label": "Advance Notice Required", "type": "select", "required": true, + "options": [{"label":"24 hours","value":"24 hours"},{"label":"2-3 days","value":"2-3 days"},{"label":"1 week","value":"1 week"},{"label":"2+ weeks","value":"2+ weeks"}]} + ] + }, + { + "id": "step_5_portfolio", + "title": "Portfolio", + "fields": [ + {"id": "portfolio_images", "label": "Food / Event Photos (up to 6)", "type": "file", "required": true, "multiple": true, "maxFiles": 6, "accept": "image/jpeg,image/jpg,image/png,image/webp", "maxSizeMB": 2, "helperText": "Upload your best food and event photos, max 2MB each. Displayed in 3\u00d72 grid."}, + {"id": "portfolio_url", "label": "Instagram / Google Business URL", "type": "url", "required": false, "placeholder": "Your food page or Google Business listing"}, + {"id": "portfolio_note", "label": "Portfolio Note", "type": "textarea", "placeholder": "Describe your specialty dishes and memorable events you have catered"} + ] + }, + { + "id": "step_6_verification", + "title": "Identity Verification", + "fields": [ + {"id": "id_type", "label": "ID Type", "type": "select", "required": true, + "options": [{"label":"Aadhaar Card","value":"Aadhaar Card"},{"label":"PAN Card","value":"PAN Card"},{"label":"Driving License","value":"Driving License"},{"label":"Voter ID","value":"Voter ID"},{"label":"Passport","value":"Passport"}]}, + {"id": "id_number", "label": "ID Number", "type": "text", "required": true, "placeholder": "Enter ID Number"}, + {"id": "id_document_upload", "label": "Upload ID Document", "type": "file", "required": true, "multiple": true, "maxFiles": 2, "accept": "application/pdf,image/jpeg,image/jpg,image/png", "maxSizeMB": 2} + ] + } + ] +}$json$::jsonb, 2, true +FROM roles WHERE key = 'CATERING_SERVICES' +ON CONFLICT (role_id, is_active) DO UPDATE SET schema_json = EXCLUDED.schema_json, version = EXCLUDED.version; + +-- ── 4. Default Dashboard Configs ───────────────────────────────────────────── + +INSERT INTO dashboard_configs (role_id, audience, config_json, version, is_active) +SELECT r.id, + 'EXTERNAL', + jsonb_build_object( + 'nav', CASE r.key + WHEN 'COMPANY' THEN '[ + {"key": "jobs", "label": "My Jobs", "path": "/dashboard/jobs", "icon": "briefcase"}, + {"key": "applications", "label": "Applications", "path": "/dashboard/applications", "icon": "users"}, + {"key": "profile", "label": "Company Profile", "path": "/dashboard/profile", "icon": "building"} + ]'::jsonb + WHEN 'JOB_SEEKER' THEN '[ + {"key": "browse_jobs", "label": "Browse Jobs", "path": "/dashboard/jobs", "icon": "search"}, + {"key": "my_applications", "label": "My Applications", "path": "/dashboard/applications", "icon": "file-text"}, + {"key": "profile", "label": "My Profile", "path": "/dashboard/profile", "icon": "user"} + ]'::jsonb + WHEN 'CUSTOMER' THEN '[ + {"key": "requirements", "label": "My Requirements", "path": "/dashboard/requirements", "icon": "list"}, + {"key": "profile", "label": "My Profile", "path": "/dashboard/profile", "icon": "user"} + ]'::jsonb + ELSE '[ + {"key": "marketplace", "label": "Marketplace", "path": "/dashboard/marketplace", "icon": "store"}, + {"key": "leads", "label": "My Leads", "path": "/dashboard/leads", "icon": "zap"}, + {"key": "portfolio", "label": "Portfolio", "path": "/dashboard/portfolio", "icon": "image"}, + {"key": "services", "label": "Services", "path": "/dashboard/services", "icon": "list"}, + {"key": "wallet", "label": "Wallet", "path": "/dashboard/wallet", "icon": "wallet"}, + {"key": "profile", "label": "My Profile", "path": "/dashboard/profile", "icon": "user"} + ]'::jsonb + END, + 'enabled_modules', CASE r.key + WHEN 'COMPANY' THEN '["jobs", "applications", "profile"]'::jsonb + WHEN 'JOB_SEEKER' THEN '["browse_jobs", "my_applications", "profile"]'::jsonb + WHEN 'CUSTOMER' THEN '["requirements", "profile"]'::jsonb + ELSE '["marketplace", "leads", "portfolio", "services", "wallet", "profile"]'::jsonb + END + ), + 1, + true +FROM roles r +WHERE r.audience = 'EXTERNAL' +ON CONFLICT (role_id, is_active) DO NOTHING; + +-- ── Done ────────────────────────────────────────────────────────────────────── +SELECT 'Seed completed successfully.' AS status;