feat: add Redis for OTP, auth tokens, rate limiting, lead dedup and marketplace cache
- Add crates/cache with client, otp, rate_limit, token, lead, jobs modules
- OTP tokens stored in Redis (15-min TTL, single-use GETDEL on verify)
- Refresh tokens stored in Redis (30-day TTL) — removed DB storage
- Password reset tokens stored in Redis (1-hour TTL, single-use)
- Rate limiting: register (10/hr), login (10/15min), OTP resend (3/hr), lead (5/hr), job post (20/hr)
- Lead request deduplication: 24-hour Redis lock per professional+requirement pair
- Marketplace listings cached in Redis (5-min TTL per profession+page+limit)
- Add ProfessionState{pool, redis} to contracts crate, replacing bare PgPool in all 9 profession apps
- All profession handlers and main.rs updated to use ProfessionState
- REDIS_URL env var (default: redis://127.0.0.1:6379) used across all services
- Fix profession model struct name mangling in 6 handlers (MakeupArtistRepository etc.)
- Add custom_data JSONB migration for all 9 profession profile tables
- Add onboarding_state model and repository (save_progress, complete, is_complete)
- Add onboarding handler accepting roleKey:String (not role_id:UUID) for frontend compat
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5640cd4ee5
commit
bb8155dd27
60 changed files with 2856 additions and 1070 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Environment variables — NEVER commit
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Rust build artifacts
|
||||
/target
|
||||
Cargo.lock
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
|
@ -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"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ chrono = { workspace = true }
|
|||
db = { path = "../../crates/db" }
|
||||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +1,28 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, patch},
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
|
||||
use db::models::catering_service::{CateringServiceRepository, UpsertCateringServiceProfilePayload};
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
use contracts::{auth_middleware::AuthUser, ProfessionState};
|
||||
|
||||
pub fn router() -> Router<PgPool> {
|
||||
pub fn router() -> Router<ProfessionState> {
|
||||
Router::new()
|
||||
.route("/profile/me", get(get_profile).patch(update_profile))
|
||||
.merge(contracts::profession_shared::shared_routes("CATERING_SERVICE"))
|
||||
.merge(contracts::profession_shared::shared_routes("CATERING_SERVICES"))
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
match CateringServiceRepository::get_by_user_id(&pool, auth.user_id).await {
|
||||
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
async fn get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||
match CateringServiceRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||
Ok(Some(p)) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(pool): State<PgPool>,
|
||||
State(state): State<ProfessionState>,
|
||||
auth: AuthUser,
|
||||
Json(payload): Json<UpsertCateringServiceProfilePayload>,
|
||||
) -> impl IntoResponse {
|
||||
match CateringServiceRepository::upsert(&pool, auth.user_id, payload).await {
|
||||
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
match CateringServiceRepository::upsert(&state.pool, auth.user_id, payload).await {
|
||||
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ chrono = { workspace = true }
|
|||
db = { path = "../../crates/db" }
|
||||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,28 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
|
||||
use db::models::developer::{DeveloperRepository, UpsertDeveloperProfilePayload};
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
use contracts::{auth_middleware::AuthUser, ProfessionState};
|
||||
|
||||
pub fn router() -> Router<PgPool> {
|
||||
pub fn router() -> Router<ProfessionState> {
|
||||
Router::new()
|
||||
.route("/profile/me", get(get_profile).patch(update_profile))
|
||||
.merge(contracts::profession_shared::shared_routes("DEVELOPER"))
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
match DeveloperRepository::get_by_user_id(&pool, auth.user_id).await {
|
||||
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
async fn get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||
match DeveloperRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||
Ok(Some(p)) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(pool): State<PgPool>,
|
||||
State(state): State<ProfessionState>,
|
||||
auth: AuthUser,
|
||||
Json(payload): Json<UpsertDeveloperProfilePayload>,
|
||||
) -> impl IntoResponse {
|
||||
match DeveloperRepository::upsert(&pool, auth.user_id, payload).await {
|
||||
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
match DeveloperRepository::upsert(&state.pool, auth.user_id, payload).await {
|
||||
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ chrono = { workspace = true }
|
|||
db = { path = "../../crates/db" }
|
||||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +1,28 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
|
||||
use db::models::fitness_trainer::{FitnessTrainerRepository, UpsertFitnessTrainerProfilePayload};
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
use contracts::{auth_middleware::AuthUser, ProfessionState};
|
||||
|
||||
pub fn router() -> Router<PgPool> {
|
||||
pub fn router() -> Router<ProfessionState> {
|
||||
Router::new()
|
||||
.route("/profile/me", get(get_profile).patch(update_profile))
|
||||
.merge(contracts::profession_shared::shared_routes("FITNESS_TRAINER"))
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
match FitnessTrainerRepository::get_by_user_id(&pool, auth.user_id).await {
|
||||
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
async fn get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||
match FitnessTrainerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||
Ok(Some(p)) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(pool): State<PgPool>,
|
||||
State(state): State<ProfessionState>,
|
||||
auth: AuthUser,
|
||||
Json(payload): Json<UpsertFitnessTrainerProfilePayload>,
|
||||
) -> impl IntoResponse {
|
||||
match FitnessTrainerRepository::upsert(&pool, auth.user_id, payload).await {
|
||||
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
match FitnessTrainerRepository::upsert(&state.pool, auth.user_id, payload).await {
|
||||
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ chrono = { workspace = true }
|
|||
db = { path = "../../crates/db" }
|
||||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +1,28 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, patch},
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
|
||||
use db::models::graphic_designer::{GraphicDesignerRepository, UpsertGraphicDesignerProfilePayload};
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
use contracts::{auth_middleware::AuthUser, ProfessionState};
|
||||
|
||||
pub fn router() -> Router<PgPool> {
|
||||
pub fn router() -> Router<ProfessionState> {
|
||||
Router::new()
|
||||
.route("/profile/me", get(get_profile).patch(update_profile))
|
||||
.merge(contracts::profession_shared::shared_routes("GRAPHIC_DESIGNER"))
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
match GraphicDesignerRepository::get_by_user_id(&pool, auth.user_id).await {
|
||||
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
async fn get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||
match GraphicDesignerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||
Ok(Some(p)) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(pool): State<PgPool>,
|
||||
State(state): State<ProfessionState>,
|
||||
auth: AuthUser,
|
||||
Json(payload): Json<UpsertGraphicDesignerProfilePayload>,
|
||||
) -> impl IntoResponse {
|
||||
match GraphicDesignerRepository::upsert(&pool, auth.user_id, payload).await {
|
||||
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
match GraphicDesignerRepository::upsert(&state.pool, auth.user_id, payload).await {
|
||||
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ chrono = { workspace = true }
|
|||
db = { path = "../../crates/db" }
|
||||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,28 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
|
||||
use db::models::makeup_artist::{MakeupArtistRepository, UpsertMakeupArtistProfilePayload};
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
use contracts::{auth_middleware::AuthUser, ProfessionState};
|
||||
|
||||
pub fn router() -> Router<PgPool> {
|
||||
pub fn router() -> Router<ProfessionState> {
|
||||
Router::new()
|
||||
.route("/profile/me", get(get_profile).patch(update_profile))
|
||||
.merge(contracts::profession_shared::shared_routes("MAKEUP_ARTIST"))
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
match MakeupArtistRepository::get_by_user_id(&pool, auth.user_id).await {
|
||||
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
async fn get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||
match MakeupArtistRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||
Ok(Some(p)) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(pool): State<PgPool>,
|
||||
State(state): State<ProfessionState>,
|
||||
auth: AuthUser,
|
||||
Json(payload): Json<UpsertMakeupArtistProfilePayload>,
|
||||
) -> impl IntoResponse {
|
||||
match MakeupArtistRepository::upsert(&pool, auth.user_id, payload).await {
|
||||
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
match MakeupArtistRepository::upsert(&state.pool, auth.user_id, payload).await {
|
||||
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ chrono = { workspace = true }
|
|||
db = { path = "../../crates/db" }
|
||||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,41 +1,28 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
|
||||
use db::models::photographer::{PhotographerRepository, UpsertPhotographerProfilePayload};
|
||||
use db::models::professional::ProfessionalRepository;
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
use contracts::{auth_middleware::AuthUser, ProfessionState};
|
||||
|
||||
pub fn router() -> Router<PgPool> {
|
||||
pub fn router() -> Router<ProfessionState> {
|
||||
Router::new()
|
||||
.route("/profile/me", get(get_profile).patch(update_profile))
|
||||
// All shared routes (marketplace, leads, portfolio, services, wallet)
|
||||
.merge(contracts::profession_shared::shared_routes("PHOTOGRAPHER"))
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
match PhotographerRepository::get_by_user_id(&pool, auth.user_id).await {
|
||||
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
async fn get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||
match PhotographerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||
Ok(Some(p)) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(pool): State<PgPool>,
|
||||
State(state): State<ProfessionState>,
|
||||
auth: AuthUser,
|
||||
Json(payload): Json<UpsertPhotographerProfilePayload>,
|
||||
) -> impl IntoResponse {
|
||||
match PhotographerRepository::upsert(&pool, auth.user_id, payload).await {
|
||||
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
match PhotographerRepository::upsert(&state.pool, auth.user_id, payload).await {
|
||||
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ chrono = { workspace = true }
|
|||
db = { path = "../../crates/db" }
|
||||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +1,28 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, patch}, // Re-adding patch here as it's used in the router
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
|
||||
use db::models::social_media_manager::{SocialMediaManagerRepository, UpsertSocialMediaManagerProfilePayload};
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
use contracts::{auth_middleware::AuthUser, ProfessionState};
|
||||
|
||||
pub fn router() -> Router<PgPool> {
|
||||
pub fn router() -> Router<ProfessionState> {
|
||||
Router::new()
|
||||
.route("/profile/me", get(get_profile).patch(update_profile))
|
||||
.merge(contracts::profession_shared::shared_routes("SOCIAL_MEDIA_MANAGER"))
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
match SocialMediaManagerRepository::get_by_user_id(&pool, auth.user_id).await {
|
||||
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
async fn get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||
match SocialMediaManagerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||
Ok(Some(p)) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(pool): State<PgPool>,
|
||||
State(state): State<ProfessionState>,
|
||||
auth: AuthUser,
|
||||
Json(payload): Json<UpsertSocialMediaManagerProfilePayload>,
|
||||
) -> impl IntoResponse {
|
||||
match SocialMediaManagerRepository::upsert(&pool, auth.user_id, payload).await {
|
||||
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
match SocialMediaManagerRepository::upsert(&state.pool, auth.user_id, payload).await {
|
||||
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ chrono = { workspace = true }
|
|||
db = { path = "../../crates/db" }
|
||||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,40 +1,28 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
|
||||
use db::models::tutor::{TutorRepository, UpsertTutorProfilePayload};
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
use contracts::{auth_middleware::AuthUser, ProfessionState};
|
||||
|
||||
pub fn router() -> Router<PgPool> {
|
||||
pub fn router() -> Router<ProfessionState> {
|
||||
Router::new()
|
||||
.route("/profile/me", get(get_profile).patch(update_profile))
|
||||
.merge(contracts::profession_shared::shared_routes("TUTOR"))
|
||||
}
|
||||
|
||||
|
||||
async fn get_profile(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
match TutorRepository::get_by_user_id(&pool, auth.user_id).await {
|
||||
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
async fn get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||
match TutorRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||
Ok(Some(p)) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(pool): State<PgPool>,
|
||||
State(state): State<ProfessionState>,
|
||||
auth: AuthUser,
|
||||
Json(payload): Json<UpsertTutorProfilePayload>,
|
||||
) -> impl IntoResponse {
|
||||
match TutorRepository::upsert(&pool, auth.user_id, payload).await {
|
||||
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
match TutorRepository::upsert(&state.pool, auth.user_id, payload).await {
|
||||
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ use auth::{
|
|||
crypto::{hash_password, verify_password},
|
||||
jwt::generate_tokens,
|
||||
};
|
||||
use ax_um_state_alias::AppState; // I'll use crate::AppState
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header::SET_COOKIE, StatusCode},
|
||||
|
|
@ -10,7 +9,6 @@ use axum::{
|
|||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use db::models::user::{CreateUserPayload, UserRepository};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
|
|
@ -18,32 +16,32 @@ use crate::AppState;
|
|||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/register", post(register))
|
||||
.route("/login", post(login))
|
||||
.route("/logout", post(logout))
|
||||
.route("/refresh", post(refresh))
|
||||
.route("/session", get(session))
|
||||
.route("/verify-email", post(verify_email))
|
||||
.route("/resend-otp", post(resend_otp))
|
||||
.route("/register", post(register))
|
||||
.route("/login", post(login))
|
||||
.route("/logout", post(logout))
|
||||
.route("/refresh", post(refresh))
|
||||
.route("/session", get(session))
|
||||
.route("/switch-role", post(switch_role))
|
||||
.route("/verify-email", post(verify_email))
|
||||
.route("/resend-otp", post(resend_otp))
|
||||
.route("/forgot-password", post(forgot_password))
|
||||
.route("/reset-password", post(reset_password))
|
||||
.route("/reset-password", post(reset_password))
|
||||
.route("/change-password", post(change_password))
|
||||
}
|
||||
|
||||
|
||||
// ── DTOs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegisterPayload {
|
||||
pub full_name: String,
|
||||
pub email: String,
|
||||
pub phone: String,
|
||||
pub password: String,
|
||||
pub email: String,
|
||||
pub phone: Option<String>,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginPayload {
|
||||
pub email: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +50,11 @@ pub struct VerifyEmailPayload {
|
|||
pub otp: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResendOtpPayload {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ForgotPasswordPayload {
|
||||
pub email: String,
|
||||
|
|
@ -59,256 +62,277 @@ pub struct ForgotPasswordPayload {
|
|||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetPasswordPayload {
|
||||
pub token: String,
|
||||
pub token: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChangePasswordPayload {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SwitchRolePayload {
|
||||
pub role_key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterResponse {
|
||||
pub user_id: String,
|
||||
pub email: String,
|
||||
pub phone: String,
|
||||
pub full_name: String,
|
||||
pub status: String,
|
||||
pub user_id: String,
|
||||
pub email: String,
|
||||
pub phone: Option<String>,
|
||||
pub full_name: String,
|
||||
pub status: String,
|
||||
pub email_verified: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: u64,
|
||||
pub user: SessionUser,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SessionUser {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub full_name: String,
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub full_name: String,
|
||||
pub email_verified: bool,
|
||||
pub roles: Vec<String>,
|
||||
pub roles: Vec<String>,
|
||||
pub active_role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
pub code: String,
|
||||
pub error: String,
|
||||
pub code: String,
|
||||
#[serde(rename = "statusCode")]
|
||||
pub status_code: u16,
|
||||
}
|
||||
|
||||
fn err(status: StatusCode, msg: &str, code: &str) -> (StatusCode, Json<ErrorResponse>) {
|
||||
(
|
||||
status,
|
||||
Json(ErrorResponse {
|
||||
error: msg.to_string(),
|
||||
code: code.to_string(),
|
||||
status_code: status.as_u16(),
|
||||
}),
|
||||
)
|
||||
(status, Json(ErrorResponse {
|
||||
error: msg.to_string(),
|
||||
code: code.to_string(),
|
||||
status_code: status.as_u16(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /api/auth/register
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RegisterPayload>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||
// Basic validation
|
||||
if payload.password.len() < 8 {
|
||||
return Err(err(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"Password minimum 8 characters",
|
||||
"VALIDATION_ERROR",
|
||||
));
|
||||
let email = payload.email.to_lowercase();
|
||||
let mut redis = state.redis.clone();
|
||||
|
||||
// Rate limit: max 10 registrations per hour per email
|
||||
if !cache::rate_limit::check_register(&mut redis, &email).await.unwrap_or(true) {
|
||||
return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many registration attempts. Try again later.", "RATE_LIMITED"));
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&payload.password).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&e.to_string(),
|
||||
"INTERNAL_ERROR",
|
||||
)
|
||||
})?;
|
||||
if payload.password.len() < 8 {
|
||||
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
let user = UserRepository::create(
|
||||
&state.pool,
|
||||
CreateUserPayload {
|
||||
full_name: payload.full_name,
|
||||
email: payload.email.to_lowercase(),
|
||||
phone: payload.phone,
|
||||
password_hash,
|
||||
},
|
||||
)
|
||||
let password_hash = hash_password(&payload.password)
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?;
|
||||
|
||||
let user = UserRepository::create(&state.pool, CreateUserPayload {
|
||||
full_name: payload.full_name,
|
||||
email: email.clone(),
|
||||
phone: payload.phone.filter(|p| !p.trim().is_empty()),
|
||||
password_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("users_email_key") || msg.contains("email") && msg.contains("unique") {
|
||||
if msg.contains("users_email_key") || (msg.contains("email") && msg.contains("unique")) {
|
||||
err(StatusCode::CONFLICT, "Email already registered", "EMAIL_EXISTS")
|
||||
} else if msg.contains("users_phone_key") || msg.contains("phone") && msg.contains("unique") {
|
||||
} else if msg.contains("users_phone_key") || (msg.contains("phone") && msg.contains("unique")) {
|
||||
err(StatusCode::CONFLICT, "Phone already registered", "PHONE_EXISTS")
|
||||
} else {
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, &msg, "DB_ERROR")
|
||||
}
|
||||
})?;
|
||||
|
||||
// Generate and send email OTP for verification
|
||||
let otp = format!("{:06}", rand::random::<u32>() % 1000000);
|
||||
let expires_at = Utc::now() + Duration::minutes(15);
|
||||
|
||||
UserRepository::set_email_verification_token(&state.pool, user.id, &otp, expires_at)
|
||||
// Store OTP in Redis (15-min TTL, keyed by code → user_id)
|
||||
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
||||
cache::otp::set(&mut redis, &otp, &user.id.to_string())
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
|
||||
cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok();
|
||||
|
||||
let _ = state.mail.send_verification_email(&user.email, &user.full_name.unwrap_or_default(), &otp).await;
|
||||
let _ = state.mail.send_verification_email(&user.email, &user.full_name.clone().unwrap_or_default(), &otp).await;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(RegisterResponse {
|
||||
user_id: user.id.to_string(),
|
||||
email: user.email,
|
||||
phone: user.phone.unwrap_or_default(),
|
||||
full_name: user.full_name.unwrap_or_default(),
|
||||
status: user.status,
|
||||
email_verified: user.email_verified,
|
||||
created_at: user.created_at.to_rfc3339(),
|
||||
}),
|
||||
))
|
||||
Ok((StatusCode::CREATED, Json(RegisterResponse {
|
||||
user_id: user.id.to_string(),
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
full_name: user.full_name.unwrap_or_default(),
|
||||
status: user.status,
|
||||
email_verified: user.email_verified,
|
||||
created_at: user.created_at.to_rfc3339(),
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/auth/login
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginPayload>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||
let user = UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase())
|
||||
let email = payload.email.to_lowercase();
|
||||
let mut redis = state.redis.clone();
|
||||
|
||||
// Rate limit: max 10 login attempts per 15 min per email
|
||||
if !cache::rate_limit::check_login(&mut redis, &email).await.unwrap_or(true) {
|
||||
return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many login attempts. Try again in 15 minutes.", "RATE_LIMITED"));
|
||||
}
|
||||
|
||||
let user = UserRepository::get_by_email(&state.pool, &email)
|
||||
.await
|
||||
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid credentials", "INVALID_CREDENTIALS"))?;
|
||||
|
||||
// Check account status
|
||||
if user.status == "SUSPENDED" {
|
||||
return Err(err(StatusCode::FORBIDDEN, "Account suspended", "ACCOUNT_SUSPENDED"));
|
||||
}
|
||||
|
||||
// Email verification check
|
||||
if !user.email_verified {
|
||||
return Err(err(StatusCode::UNAUTHORIZED, "Email not verified", "EMAIL_NOT_VERIFIED"));
|
||||
return Err(err(StatusCode::UNAUTHORIZED, "Email not verified. Check your inbox.", "EMAIL_NOT_VERIFIED"));
|
||||
}
|
||||
|
||||
let is_valid = verify_password(&payload.password, &user.password_hash).map_err(|e| {
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR")
|
||||
})?;
|
||||
|
||||
let is_valid = verify_password(&payload.password, &user.password_hash)
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?;
|
||||
if !is_valid {
|
||||
return Err(err(StatusCode::UNAUTHORIZED, "Invalid credentials", "INVALID_CREDENTIALS"));
|
||||
}
|
||||
|
||||
// Fetch user's active roles
|
||||
let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "changeme".to_string());
|
||||
let tokens = generate_tokens(user.id.to_string(), user_roles.first().cloned(), &jwt_secret)
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
|
||||
|
||||
UserRepository::store_refresh_token(
|
||||
&state.pool,
|
||||
user.id,
|
||||
&tokens.refresh_token,
|
||||
Utc::now() + Duration::days(30),
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
let tokens = generate_tokens(
|
||||
user.id.to_string(),
|
||||
user.email.clone(),
|
||||
user_roles.clone(),
|
||||
user_roles.first().cloned(),
|
||||
&jwt_secret,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
|
||||
|
||||
// Refresh token → Redis (30-day TTL)
|
||||
cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string())
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
|
||||
|
||||
// Set refresh token as httpOnly cookie
|
||||
let cookie = format!(
|
||||
"nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000",
|
||||
tokens.refresh_token
|
||||
);
|
||||
let active_role = user_roles.first().cloned();
|
||||
|
||||
let response = Json(LoginResponse {
|
||||
access_token: tokens.access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: 900,
|
||||
user: SessionUser {
|
||||
id: user.id.to_string(),
|
||||
email: user.email,
|
||||
full_name: user.full_name.unwrap_or_default(),
|
||||
email_verified: user.email_verified,
|
||||
roles: user_roles,
|
||||
},
|
||||
});
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[(SET_COOKIE, cookie)],
|
||||
response,
|
||||
))
|
||||
Ok((StatusCode::OK, [(SET_COOKIE, cookie)], Json(serde_json::json!({
|
||||
"access_token": tokens.access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900,
|
||||
"user": {
|
||||
"id": user.id.to_string(),
|
||||
"email": user.email,
|
||||
"full_name": user.full_name.unwrap_or_default(),
|
||||
"email_verified": user.email_verified,
|
||||
"active_role": active_role,
|
||||
"roles": user_roles,
|
||||
}
|
||||
}))))
|
||||
}
|
||||
|
||||
/// POST /api/auth/logout
|
||||
async fn logout(
|
||||
State(state): State<AppState>,
|
||||
// In real implementation: extract refresh token from cookie header
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
) -> impl IntoResponse {
|
||||
// TODO: Revoke refresh token from cookie
|
||||
let _ = &state.pool;
|
||||
(StatusCode::OK, Json(serde_json::json!({ "message": "Logged out successfully" })))
|
||||
let cookie_header = req
|
||||
.headers()
|
||||
.get(axum::http::header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if let Some(token) = cookie_header
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.find_map(|p| p.strip_prefix("nxtgauge_refresh_token="))
|
||||
{
|
||||
let mut redis = state.redis.clone();
|
||||
let _ = cache::token::revoke_refresh(&mut redis, token).await;
|
||||
}
|
||||
|
||||
let clear = "nxtgauge_refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0";
|
||||
(StatusCode::OK, [(SET_COOKIE, clear)], Json(serde_json::json!({ "message": "Logged out" })))
|
||||
}
|
||||
|
||||
/// POST /api/auth/refresh
|
||||
async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
// In real impl: read httpOnly cookie, not body
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||
let token = payload["refresh_token"]
|
||||
.as_str()
|
||||
let cookie_header = req
|
||||
.headers()
|
||||
.get(axum::http::header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let token = cookie_header
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.find_map(|p| p.strip_prefix("nxtgauge_refresh_token="))
|
||||
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token missing", "REFRESH_TOKEN_INVALID"))?;
|
||||
|
||||
let rt = UserRepository::get_valid_refresh_token(&state.pool, token)
|
||||
.await
|
||||
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token invalid", "REFRESH_TOKEN_INVALID"))?;
|
||||
let mut redis = state.redis.clone();
|
||||
|
||||
let user = UserRepository::get_by_id(&state.pool, rt.user_id)
|
||||
let user_id_str = cache::token::get_refresh(&mut redis, token)
|
||||
.await
|
||||
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token invalid", "REFRESH_TOKEN_INVALID"))?
|
||||
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token expired", "REFRESH_TOKEN_INVALID"))?;
|
||||
|
||||
let user_id = user_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token corrupt", "REFRESH_TOKEN_INVALID"))?;
|
||||
|
||||
let user = UserRepository::get_by_id(&state.pool, user_id)
|
||||
.await
|
||||
.map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "INVALID_CREDENTIALS"))?;
|
||||
|
||||
let _ = UserRepository::revoke_refresh_token(&state.pool, token).await;
|
||||
// Rotate: revoke old, issue new
|
||||
let _ = cache::token::revoke_refresh(&mut redis, token).await;
|
||||
|
||||
let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "changeme".to_string());
|
||||
let tokens = generate_tokens(user.id.to_string(), user_roles.first().cloned(), &jwt_secret)
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
|
||||
|
||||
UserRepository::store_refresh_token(
|
||||
&state.pool,
|
||||
user.id,
|
||||
&tokens.refresh_token,
|
||||
Utc::now() + Duration::days(30),
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
let tokens = generate_tokens(
|
||||
user.id.to_string(),
|
||||
user.email.clone(),
|
||||
user_roles.clone(),
|
||||
user_roles.first().cloned(),
|
||||
&jwt_secret,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"access_token": tokens.access_token,
|
||||
"expires_in": 900
|
||||
})),
|
||||
))
|
||||
cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string())
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
|
||||
|
||||
let new_cookie = format!(
|
||||
"nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000",
|
||||
tokens.refresh_token
|
||||
);
|
||||
|
||||
Ok((StatusCode::OK, [(SET_COOKIE, new_cookie)], Json(serde_json::json!({
|
||||
"access_token": tokens.access_token,
|
||||
"expires_in": 900
|
||||
}))))
|
||||
}
|
||||
|
||||
|
||||
/// GET /api/auth/session
|
||||
async fn session(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -322,113 +346,131 @@ async fn session(
|
|||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(SessionUser {
|
||||
id: user.id.to_string(),
|
||||
email: user.email,
|
||||
full_name: user.full_name.unwrap_or_default(),
|
||||
id: user.id.to_string(),
|
||||
email: user.email,
|
||||
full_name: user.full_name.unwrap_or_default(),
|
||||
email_verified: user.email_verified,
|
||||
roles: user_roles,
|
||||
active_role: user_roles.first().cloned(),
|
||||
roles: user_roles,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/auth/verify-email { "otp": "123456" }
|
||||
async fn verify_email(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<VerifyEmailPayload>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||
let user = UserRepository::get_by_verification_token(&state.pool, &payload.otp)
|
||||
let mut redis = state.redis.clone();
|
||||
|
||||
// Atomically consume OTP from Redis (GETDEL — single use, auto-expiry)
|
||||
let user_id_str = cache::otp::consume(&mut redis, &payload.otp)
|
||||
.await
|
||||
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "Cache error", "CACHE_ERROR"))?
|
||||
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired verification code", "INVALID_CODE"))?;
|
||||
|
||||
let user_id = user_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid verification code", "INVALID_CODE"))?;
|
||||
|
||||
if let Some(expires_at) = user.email_verification_expires_at {
|
||||
if expires_at < Utc::now() {
|
||||
return Err(err(StatusCode::UNAUTHORIZED, "Verification code expired", "CODE_EXPIRED"));
|
||||
}
|
||||
}
|
||||
|
||||
UserRepository::set_email_verified(&state.pool, user.id)
|
||||
UserRepository::set_email_verified(&state.pool, user_id)
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
||||
|
||||
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Email verified successfully" }))))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResendOtpPayload {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// POST /api/auth/resend-otp
|
||||
async fn resend_otp(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ResendOtpPayload>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||
let user = UserRepository::get_by_email(&state.pool, &payload.email)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::OK, Json(serde_json::json!({ "message": "If email exists, a new OTP has been sent" }))))?;
|
||||
let silent_ok = (StatusCode::OK, Json(serde_json::json!({
|
||||
"message": "If the email is registered, a new code has been sent"
|
||||
})));
|
||||
|
||||
let otp = format!("{:06}", rand::random::<u32>() % 1000000);
|
||||
let expires_at = Utc::now() + Duration::minutes(15);
|
||||
let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Ok(silent_ok),
|
||||
};
|
||||
if user.email_verified {
|
||||
return Ok(silent_ok);
|
||||
}
|
||||
|
||||
UserRepository::set_email_verification_token(&state.pool, user.id, &otp, expires_at)
|
||||
let mut redis = state.redis.clone();
|
||||
|
||||
// Rate limit: max 3 resends per hour per user
|
||||
if !cache::otp::resend_allowed(&mut redis, &user.id.to_string()).await.unwrap_or(true) {
|
||||
return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many OTP requests. Try again in an hour.", "RATE_LIMITED"));
|
||||
}
|
||||
|
||||
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
||||
cache::otp::set(&mut redis, &otp, &user.id.to_string())
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
|
||||
cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok();
|
||||
|
||||
let _ = state.mail.send_verification_email(&user.email, &user.full_name.unwrap_or_default(), &otp).await;
|
||||
|
||||
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "If email exists, a new OTP has been sent" }))))
|
||||
Ok(silent_ok)
|
||||
}
|
||||
|
||||
/// POST /api/auth/forgot-password
|
||||
async fn forgot_password(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ForgotPasswordPayload>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||
let user = UserRepository::get_by_email(&state.pool, &payload.email)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::OK, Json(serde_json::json!({ "message": "Reset link sent if email exists" }))))?;
|
||||
let silent_ok = (StatusCode::OK, Json(serde_json::json!({ "message": "Reset link sent if email exists" })));
|
||||
|
||||
let token: String = uuid::Uuid::new_v4().to_string();
|
||||
let expires_at = Utc::now() + Duration::hours(1);
|
||||
let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Ok(silent_ok),
|
||||
};
|
||||
|
||||
UserRepository::set_reset_token(&state.pool, user.id, &token, expires_at)
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
let mut redis = state.redis.clone();
|
||||
|
||||
// Store reset token in Redis (1-hour TTL, consumed single-use on reset)
|
||||
cache::token::store_reset(&mut redis, &token, &user.id.to_string())
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
|
||||
|
||||
let _ = state.mail.send_password_reset_email(&user.email, &user.full_name.unwrap_or_default(), &token).await;
|
||||
|
||||
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Reset link sent if email exists" }))))
|
||||
Ok(silent_ok)
|
||||
}
|
||||
|
||||
/// POST /api/auth/reset-password
|
||||
async fn reset_password(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ResetPasswordPayload>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||
let user = UserRepository::get_by_reset_token(&state.pool, &payload.token)
|
||||
.await
|
||||
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid or expired reset token", "INVALID_TOKEN"))?;
|
||||
let mut redis = state.redis.clone();
|
||||
|
||||
if let Some(expires_at) = user.reset_password_expires_at {
|
||||
if expires_at < Utc::now() {
|
||||
return Err(err(StatusCode::UNAUTHORIZED, "Reset token expired", "TOKEN_EXPIRED"));
|
||||
}
|
||||
}
|
||||
// Consume reset token from Redis (single-use GETDEL)
|
||||
let user_id_str = cache::token::consume_reset(&mut redis, &payload.token)
|
||||
.await
|
||||
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "Cache error", "CACHE_ERROR"))?
|
||||
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired reset token", "INVALID_TOKEN"))?;
|
||||
|
||||
let user_id = user_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid reset token", "INVALID_TOKEN"))?;
|
||||
|
||||
if payload.new_password.len() < 8 {
|
||||
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password minimum 8 characters", "VALIDATION_ERROR"));
|
||||
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&payload.new_password).map_err(|e| {
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR")
|
||||
})?;
|
||||
let password_hash = hash_password(&payload.new_password)
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?;
|
||||
|
||||
UserRepository::update_password(&state.pool, user.id, &password_hash)
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
||||
|
||||
UserRepository::clear_reset_token(&state.pool, user.id)
|
||||
UserRepository::update_password(&state.pool, user_id, &password_hash)
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
||||
|
||||
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" }))))
|
||||
}
|
||||
|
||||
/// POST /api/auth/change-password
|
||||
async fn change_password(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -438,17 +480,18 @@ async fn change_password(
|
|||
.await
|
||||
.map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "USER_NOT_FOUND"))?;
|
||||
|
||||
if !verify_password(&payload.current_password, &user.password_hash).map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "AUTH_ERROR"))? {
|
||||
if !verify_password(&payload.current_password, &user.password_hash)
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "AUTH_ERROR"))?
|
||||
{
|
||||
return Err(err(StatusCode::UNAUTHORIZED, "Incorrect current password", "INVALID_PASSWORD"));
|
||||
}
|
||||
|
||||
if payload.new_password.len() < 8 {
|
||||
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password minimum 8 characters", "VALIDATION_ERROR"));
|
||||
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&payload.new_password).map_err(|e| {
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR")
|
||||
})?;
|
||||
let password_hash = hash_password(&payload.new_password)
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?;
|
||||
|
||||
UserRepository::update_password(&state.pool, user.id, &password_hash)
|
||||
.await
|
||||
|
|
@ -456,3 +499,34 @@ async fn change_password(
|
|||
|
||||
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password changed successfully" }))))
|
||||
}
|
||||
|
||||
/// POST /api/auth/switch-role
|
||||
async fn switch_role(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SwitchRolePayload>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||
let user_roles = UserRepository::get_user_role_keys(&state.pool, auth.user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let requested = payload.role_key.to_uppercase();
|
||||
if !user_roles.contains(&requested) {
|
||||
return Err(err(StatusCode::FORBIDDEN, "You do not have this role", "ROLE_NOT_FOUND"));
|
||||
}
|
||||
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
let tokens = generate_tokens(
|
||||
auth.user_id.to_string(),
|
||||
auth.email.clone(),
|
||||
user_roles,
|
||||
Some(requested),
|
||||
&jwt_secret,
|
||||
)
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
|
||||
|
||||
Ok((StatusCode::OK, Json(serde_json::json!({
|
||||
"access_token": tokens.access_token,
|
||||
"expires_in": 900
|
||||
}))))
|
||||
}
|
||||
|
|
|
|||
194
apps/users/src/handlers/onboarding.rs
Normal file
194
apps/users/src/handlers/onboarding.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
use db::models::{
|
||||
onboarding_state::OnboardingStateRepository,
|
||||
role::RoleRepository,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── Routers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn onboarding_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/state", get(get_state))
|
||||
.route("/save-progress", post(save_progress))
|
||||
.route("/submit", post(submit))
|
||||
}
|
||||
|
||||
pub fn me_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/profile-status", get(profile_status))
|
||||
}
|
||||
|
||||
// ── DTOs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RoleKeyQuery {
|
||||
#[serde(rename = "roleKey")]
|
||||
pub role_key: Option<String>,
|
||||
}
|
||||
|
||||
// Accept role_key (string) so the frontend never has to know the internal UUID.
|
||||
#[derive(Deserialize)]
|
||||
pub struct SaveProgressInput {
|
||||
#[serde(rename = "roleKey", alias = "role_key")]
|
||||
pub role_key: String,
|
||||
pub progress_json: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SubmitInput {
|
||||
#[serde(rename = "roleKey", alias = "role_key")]
|
||||
pub role_key: String,
|
||||
pub progress_json: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ProfileStatusResponse {
|
||||
pub onboarding_complete: bool,
|
||||
pub active_role: Option<String>,
|
||||
pub roles: Vec<String>,
|
||||
pub email_verified: bool,
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/onboarding/state?roleKey=COMPANY
|
||||
async fn get_state(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<RoleKeyQuery>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let role_key = query
|
||||
.role_key
|
||||
.filter(|k| !k.is_empty())
|
||||
.unwrap_or_else(|| auth.claims.active_role.clone());
|
||||
|
||||
let role = RoleRepository::get_by_key(&state.pool, &role_key)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", role_key)))?;
|
||||
|
||||
let onboarding_state =
|
||||
OnboardingStateRepository::get(&state.pool, auth.user_id, role.id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let response = match onboarding_state {
|
||||
Some(s) => serde_json::json!({
|
||||
"status": s.status,
|
||||
"currentStep": s.progress_json.as_ref()
|
||||
.and_then(|p| p.get("step"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0),
|
||||
"progress": s.progress_json,
|
||||
"completed_at": s.completed_at,
|
||||
"role_key": role_key,
|
||||
}),
|
||||
None => serde_json::json!({
|
||||
"status": "NOT_STARTED",
|
||||
"currentStep": 0,
|
||||
"progress": null,
|
||||
"completed_at": null,
|
||||
"role_key": role_key,
|
||||
}),
|
||||
};
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// POST /api/onboarding/save-progress
|
||||
/// Body: { roleKey: "PHOTOGRAPHER", progress_json: { step: 2, total: 6, data: {...} } }
|
||||
async fn save_progress(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<SaveProgressInput>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let role = RoleRepository::get_by_key(&state.pool, &input.role_key)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", input.role_key)))?;
|
||||
|
||||
let progress = input.progress_json.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
|
||||
let saved = OnboardingStateRepository::save_progress(
|
||||
&state.pool,
|
||||
auth.user_id,
|
||||
role.id,
|
||||
&progress,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok((StatusCode::OK, Json(saved)))
|
||||
}
|
||||
|
||||
/// POST /api/onboarding/submit
|
||||
/// Body: { roleKey: "PHOTOGRAPHER", progress_json: { ...all form values... } }
|
||||
async fn submit(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<SubmitInput>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let role = RoleRepository::get_by_key(&state.pool, &input.role_key)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", input.role_key)))?;
|
||||
|
||||
let progress = input.progress_json.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
|
||||
let completed = OnboardingStateRepository::complete(
|
||||
&state.pool,
|
||||
auth.user_id,
|
||||
role.id,
|
||||
&progress,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok((StatusCode::OK, Json(completed)))
|
||||
}
|
||||
|
||||
/// GET /api/me/profile-status
|
||||
async fn profile_status(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
use db::models::user::UserRepository;
|
||||
|
||||
let user = UserRepository::get_by_id(&state.pool, auth.user_id)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||
|
||||
let roles = UserRepository::get_user_role_keys(&state.pool, auth.user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let active_role = auth.claims.active_role.clone();
|
||||
let active_role_opt = if active_role.is_empty() { None } else { Some(active_role.clone()) };
|
||||
|
||||
let onboarding_complete = if let Some(ref role_key) = active_role_opt {
|
||||
match RoleRepository::get_by_key(&state.pool, role_key).await {
|
||||
Ok(role) => OnboardingStateRepository::is_complete(&state.pool, auth.user_id, role.id)
|
||||
.await
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ProfileStatusResponse {
|
||||
onboarding_complete,
|
||||
active_role: active_role_opt,
|
||||
roles,
|
||||
email_verified: user.email_verified,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
mod handlers;
|
||||
mod mail;
|
||||
|
||||
use axum::{Router, http::Method};
|
||||
use axum::{routing::get, Router};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use sqlx::PgPool;
|
||||
use mail::Mailer;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub mail: Arc<Mailer>,
|
||||
pub pool: PgPool,
|
||||
pub mail: Arc<Mailer>,
|
||||
pub redis: cache::RedisPool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -24,6 +24,9 @@ async fn main() {
|
|||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
// Fail fast — critical env vars must be present before binding any port
|
||||
std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
|
||||
let database_url =
|
||||
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
|
||||
|
|
@ -33,18 +36,21 @@ async fn main() {
|
|||
|
||||
tracing::info!("Connected to the database");
|
||||
|
||||
let redis_url = std::env::var("REDIS_URL")
|
||||
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
|
||||
let redis = cache::connect(&redis_url)
|
||||
.await
|
||||
.expect("Failed to connect to Redis");
|
||||
tracing::info!("Connected to Redis");
|
||||
|
||||
let mailer = Arc::new(Mailer::new());
|
||||
|
||||
let state = AppState {
|
||||
pool,
|
||||
mail: mailer,
|
||||
mail: mailer,
|
||||
redis,
|
||||
};
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS])
|
||||
.allow_origin(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = Router::new()
|
||||
// ── Auth ─────────────────────────────────────────────────────────
|
||||
.nest("/api/auth", handlers::auth::router())
|
||||
|
|
@ -52,6 +58,10 @@ async fn main() {
|
|||
.nest("/api/admin/roles", handlers::roles::router())
|
||||
// ── Notifications ─────────────────────────────────────────────────
|
||||
.nest("/api/me/notifications", handlers::notifications::router())
|
||||
// ── Me: Profile Status ─────────────────────────────────────────────
|
||||
.nest("/api/me", handlers::onboarding::me_router())
|
||||
// ── Onboarding State (user-facing) ────────────────────────────────
|
||||
.nest("/api/onboarding", handlers::onboarding::onboarding_router())
|
||||
// ── Admin: Onboarding + Dashboard Config ──────────────────────────
|
||||
.nest("/api/admin/onboarding-config", handlers::config::onboarding_router())
|
||||
.nest("/api/admin/dashboard-config", handlers::config::dashboard_router())
|
||||
|
|
@ -59,7 +69,7 @@ async fn main() {
|
|||
.nest("/api/config/onboarding", handlers::config::onboarding_router())
|
||||
.nest("/api/config/dashboard", handlers::config::dashboard_router())
|
||||
.nest("/api/runtime-config", handlers::config::runtime_router())
|
||||
.layer(cors)
|
||||
.route("/health", get(|| async { "Users OK" }))
|
||||
.with_state(state);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ chrono = { workspace = true }
|
|||
db = { path = "../../crates/db" }
|
||||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,28 @@
|
|||
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::{get, patch}, Json, Router};
|
||||
use sqlx::PgPool;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
|
||||
use db::models::video_editor::{VideoEditorRepository, UpsertVideoEditorProfilePayload};
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
use contracts::{auth_middleware::AuthUser, ProfessionState};
|
||||
|
||||
pub fn router() -> Router<PgPool> {
|
||||
pub fn router() -> Router<ProfessionState> {
|
||||
Router::new()
|
||||
.route("/profile/me", get(get_profile).patch(update_profile))
|
||||
.merge(contracts::profession_shared::shared_routes("VIDEO_EDITOR"))
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
match VideoEditorRepository::get_by_user_id(&pool, auth.user_id).await {
|
||||
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
async fn get_profile(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||
match VideoEditorRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||
Ok(Some(p)) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(pool): State<PgPool>,
|
||||
State(state): State<ProfessionState>,
|
||||
auth: AuthUser,
|
||||
Json(payload): Json<UpsertVideoEditorProfilePayload>,
|
||||
) -> impl IntoResponse {
|
||||
match VideoEditorRepository::upsert(&pool, auth.user_id, payload).await {
|
||||
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
|
||||
match VideoEditorRepository::upsert(&state.pool, auth.user_id, payload).await {
|
||||
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
mod handlers;
|
||||
|
||||
use axum::{Router, http::Method};
|
||||
use axum::{routing::get, Router};
|
||||
use std::net::SocketAddr;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use contracts::profession_shared::shared_routes;
|
||||
|
||||
use contracts::ProfessionState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
|
|
@ -23,20 +21,29 @@ async fn main() {
|
|||
.await
|
||||
.expect("Failed to connect to postgres");
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
|
||||
.allow_origin(Any)
|
||||
.allow_headers(Any);
|
||||
let redis_url = std::env::var("REDIS_URL")
|
||||
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
|
||||
let redis = cache::connect(&redis_url)
|
||||
.await
|
||||
.expect("Failed to connect to Redis");
|
||||
|
||||
tracing::info!("Video Editors service — connected to DB and Redis");
|
||||
|
||||
let state = ProfessionState { pool, redis };
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", axum::routing::get(|| async { "Video Editors Service OK" }))
|
||||
.nest("/api/video-editors", handlers::router())
|
||||
.layer(cors)
|
||||
.with_state(pool);
|
||||
.route("/health", get(|| async { "Video Editors OK" }))
|
||||
.with_state(state);
|
||||
|
||||
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8089".to_string()).parse().unwrap();
|
||||
let port: u16 = std::env::var("PORT")
|
||||
.unwrap_or_else(|_| "8089".to_string())
|
||||
.parse()
|
||||
.expect("PORT must be a number");
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
|
||||
tracing::info!("Video Editors service listening on {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
|
|
|||
13
crates/cache/Cargo.toml
vendored
Normal file
13
crates/cache/Cargo.toml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "cache"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
redis = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
10
crates/cache/src/client.rs
vendored
Normal file
10
crates/cache/src/client.rs
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
use redis::aio::ConnectionManager;
|
||||
|
||||
/// Thread-safe, auto-reconnecting Redis handle.
|
||||
/// Clone is cheap — all clones share the same underlying multiplexed connection.
|
||||
pub type RedisPool = ConnectionManager;
|
||||
|
||||
pub async fn connect(url: &str) -> Result<RedisPool, redis::RedisError> {
|
||||
let client = redis::Client::open(url)?;
|
||||
ConnectionManager::new(client).await
|
||||
}
|
||||
42
crates/cache/src/jobs.rs
vendored
Normal file
42
crates/cache/src/jobs.rs
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
//! Marketplace listing cache.
|
||||
//!
|
||||
//! Key: `jobs:marketplace:{profession}:{page}:{limit}` → JSON, TTL 5 min
|
||||
|
||||
use redis::AsyncCommands;
|
||||
use crate::RedisPool;
|
||||
|
||||
const CACHE_TTL: u64 = 300; // 5 minutes
|
||||
|
||||
pub async fn get_marketplace(
|
||||
redis: &mut RedisPool,
|
||||
profession: &str,
|
||||
page: i64,
|
||||
limit: i64,
|
||||
) -> Result<Option<String>, redis::RedisError> {
|
||||
let key = format!("jobs:marketplace:{profession}:{page}:{limit}");
|
||||
redis.get(key).await
|
||||
}
|
||||
|
||||
pub async fn set_marketplace(
|
||||
redis: &mut RedisPool,
|
||||
profession: &str,
|
||||
page: i64,
|
||||
limit: i64,
|
||||
data: &str,
|
||||
) -> Result<(), redis::RedisError> {
|
||||
let key = format!("jobs:marketplace:{profession}:{page}:{limit}");
|
||||
redis.set_ex(key, data, CACHE_TTL).await
|
||||
}
|
||||
|
||||
/// Invalidate all marketplace caches for a profession (call when a profile is updated).
|
||||
pub async fn invalidate_marketplace(
|
||||
redis: &mut RedisPool,
|
||||
profession: &str,
|
||||
) -> Result<(), redis::RedisError> {
|
||||
let pattern = format!("jobs:marketplace:{profession}:*");
|
||||
let keys: Vec<String> = redis.keys(pattern).await?;
|
||||
if !keys.is_empty() {
|
||||
redis.del(keys).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
33
crates/cache/src/lead.rs
vendored
Normal file
33
crates/cache/src/lead.rs
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//! Lead request deduplication.
|
||||
//!
|
||||
//! Prevents a professional from sending more than one lead request
|
||||
//! to the same requirement within 24 hours.
|
||||
//!
|
||||
//! Key: `lead_dedup:{professional_id}:{requirement_id}` → "1", TTL 24 h
|
||||
|
||||
use redis::AsyncCommands;
|
||||
use crate::RedisPool;
|
||||
|
||||
const DEDUP_TTL: u64 = 24 * 3_600; // 24 hours
|
||||
|
||||
/// Returns `true` if the professional has already sent a lead request
|
||||
/// for this requirement in the last 24 hours.
|
||||
pub async fn is_duplicate(
|
||||
redis: &mut RedisPool,
|
||||
professional_id: &str,
|
||||
requirement_id: &str,
|
||||
) -> Result<bool, redis::RedisError> {
|
||||
let key = format!("lead_dedup:{professional_id}:{requirement_id}");
|
||||
let exists: bool = redis.exists(key).await?;
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
/// Mark a lead request as sent. Call after successfully creating it in the DB.
|
||||
pub async fn mark_sent(
|
||||
redis: &mut RedisPool,
|
||||
professional_id: &str,
|
||||
requirement_id: &str,
|
||||
) -> Result<(), redis::RedisError> {
|
||||
let key = format!("lead_dedup:{professional_id}:{requirement_id}");
|
||||
redis.set_ex(key, "1", DEDUP_TTL).await
|
||||
}
|
||||
8
crates/cache/src/lib.rs
vendored
Normal file
8
crates/cache/src/lib.rs
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
pub mod client;
|
||||
pub mod otp;
|
||||
pub mod rate_limit;
|
||||
pub mod token;
|
||||
pub mod lead;
|
||||
pub mod jobs;
|
||||
|
||||
pub use client::{RedisPool, connect};
|
||||
49
crates/cache/src/otp.rs
vendored
Normal file
49
crates/cache/src/otp.rs
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! OTP storage and rate-limiting.
|
||||
//!
|
||||
//! Keys
|
||||
//! ────
|
||||
//! `otp:code:{6-digit-code}` → user_id string, TTL 15 min
|
||||
//! `otp:resend:{user_id}` → resend attempt counter, TTL 1 hour (max 3)
|
||||
|
||||
use redis::AsyncCommands;
|
||||
use crate::RedisPool;
|
||||
|
||||
const OTP_TTL_SECS: u64 = 900; // 15 minutes
|
||||
const RESEND_WINDOW_SECS: i64 = 3_600; // 1 hour
|
||||
const RESEND_MAX: i64 = 3;
|
||||
|
||||
// ── Store / verify ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Store OTP code keyed by the code itself → user_id. TTL 15 min.
|
||||
pub async fn set(redis: &mut RedisPool, code: &str, user_id: &str) -> Result<(), redis::RedisError> {
|
||||
let key = format!("otp:code:{code}");
|
||||
redis.set_ex(key, user_id, OTP_TTL_SECS).await
|
||||
}
|
||||
|
||||
/// Atomically fetch the user_id for this OTP and delete it (single-use).
|
||||
/// Returns `None` if the code doesn't exist or has expired.
|
||||
pub async fn consume(redis: &mut RedisPool, code: &str) -> Result<Option<String>, redis::RedisError> {
|
||||
let key = format!("otp:code:{code}");
|
||||
// GETDEL: atomic get + delete (Redis ≥ 6.2)
|
||||
redis.get_del(key).await
|
||||
}
|
||||
|
||||
// ── Resend rate limit ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns `true` if the user is allowed to request another OTP (< 3 in last hour).
|
||||
pub async fn resend_allowed(redis: &mut RedisPool, user_id: &str) -> Result<bool, redis::RedisError> {
|
||||
let key = format!("otp:resend:{user_id}");
|
||||
let count: i64 = redis.get(&key).await.unwrap_or(0);
|
||||
Ok(count < RESEND_MAX)
|
||||
}
|
||||
|
||||
/// Increment the resend counter. Call after sending the OTP.
|
||||
pub async fn record_resend(redis: &mut RedisPool, user_id: &str) -> Result<(), redis::RedisError> {
|
||||
let key = format!("otp:resend:{user_id}");
|
||||
let count: i64 = redis.incr(&key, 1i64).await?;
|
||||
// Only set expiry on first increment so window is fixed from first request
|
||||
if count == 1 {
|
||||
redis.expire(&key, RESEND_WINDOW_SECS).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
52
crates/cache/src/rate_limit.rs
vendored
Normal file
52
crates/cache/src/rate_limit.rs
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! Generic sliding-window rate limiter.
|
||||
//!
|
||||
//! Key pattern: `rate:{namespace}:{identifier}`
|
||||
//! Returns `Ok(true)` if the request is allowed, `Ok(false)` if rate-limited.
|
||||
|
||||
use redis::AsyncCommands;
|
||||
use crate::RedisPool;
|
||||
|
||||
/// Check + increment a rate-limit counter.
|
||||
///
|
||||
/// * `namespace` – e.g. `"login"`, `"register"`, `"lead"`
|
||||
/// * `identifier` – e.g. email, IP, user_id
|
||||
/// * `max` – maximum requests allowed in `window_secs`
|
||||
/// * `window_secs` – sliding window length in seconds
|
||||
///
|
||||
/// Returns `Ok(true)` = allowed, `Ok(false)` = blocked.
|
||||
pub async fn check(
|
||||
redis: &mut RedisPool,
|
||||
namespace: &str,
|
||||
identifier: &str,
|
||||
max: i64,
|
||||
window_secs: i64,
|
||||
) -> Result<bool, redis::RedisError> {
|
||||
let key = format!("rate:{namespace}:{identifier}");
|
||||
let count: i64 = redis.incr(&key, 1i64).await?;
|
||||
if count == 1 {
|
||||
redis.expire(&key, window_secs).await?;
|
||||
}
|
||||
Ok(count <= max)
|
||||
}
|
||||
|
||||
/// Convenience wrappers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Register: max 10 per hour per email
|
||||
pub async fn check_register(redis: &mut RedisPool, email: &str) -> Result<bool, redis::RedisError> {
|
||||
check(redis, "register", email, 10, 3_600).await
|
||||
}
|
||||
|
||||
/// Login: max 10 attempts per 15 min per email
|
||||
pub async fn check_login(redis: &mut RedisPool, email: &str) -> Result<bool, redis::RedisError> {
|
||||
check(redis, "login", email, 10, 900).await
|
||||
}
|
||||
|
||||
/// Lead request: max 5 per hour per professional
|
||||
pub async fn check_lead(redis: &mut RedisPool, professional_id: &str) -> Result<bool, redis::RedisError> {
|
||||
check(redis, "lead", professional_id, 5, 3_600).await
|
||||
}
|
||||
|
||||
/// Job post: max 20 per hour per company
|
||||
pub async fn check_job_post(redis: &mut RedisPool, company_id: &str) -> Result<bool, redis::RedisError> {
|
||||
check(redis, "job_post", company_id, 20, 3_600).await
|
||||
}
|
||||
64
crates/cache/src/token.rs
vendored
Normal file
64
crates/cache/src/token.rs
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
//! Auth token management in Redis.
|
||||
//!
|
||||
//! Refresh tokens
|
||||
//! ──────────────
|
||||
//! `refresh:{token}` → user_id, TTL 30 days
|
||||
//!
|
||||
//! Password-reset tokens
|
||||
//! ─────────────────────
|
||||
//! `reset:{token}` → user_id, TTL 1 hour
|
||||
|
||||
use redis::AsyncCommands;
|
||||
use crate::RedisPool;
|
||||
|
||||
const REFRESH_TTL: u64 = 30 * 24 * 3_600; // 30 days in seconds
|
||||
const RESET_TTL: u64 = 3_600; // 1 hour
|
||||
|
||||
// ── Refresh tokens ────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn store_refresh(
|
||||
redis: &mut RedisPool,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), redis::RedisError> {
|
||||
let key = format!("refresh:{token}");
|
||||
redis.set_ex(key, user_id, REFRESH_TTL).await
|
||||
}
|
||||
|
||||
/// Returns the user_id string associated with this token, or `None` if expired/invalid.
|
||||
pub async fn get_refresh(
|
||||
redis: &mut RedisPool,
|
||||
token: &str,
|
||||
) -> Result<Option<String>, redis::RedisError> {
|
||||
let key = format!("refresh:{token}");
|
||||
redis.get(key).await
|
||||
}
|
||||
|
||||
/// Delete refresh token (logout / rotation).
|
||||
pub async fn revoke_refresh(
|
||||
redis: &mut RedisPool,
|
||||
token: &str,
|
||||
) -> Result<(), redis::RedisError> {
|
||||
let key = format!("refresh:{token}");
|
||||
redis.del(key).await
|
||||
}
|
||||
|
||||
// ── Password-reset tokens ─────────────────────────────────────────────────────
|
||||
|
||||
pub async fn store_reset(
|
||||
redis: &mut RedisPool,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), redis::RedisError> {
|
||||
let key = format!("reset:{token}");
|
||||
redis.set_ex(key, user_id, RESET_TTL).await
|
||||
}
|
||||
|
||||
/// Atomically fetch and delete the reset token (single-use).
|
||||
pub async fn consume_reset(
|
||||
redis: &mut RedisPool,
|
||||
token: &str,
|
||||
) -> Result<Option<String>, redis::RedisError> {
|
||||
let key = format!("reset:{token}");
|
||||
redis.get_del(key).await
|
||||
}
|
||||
16
crates/contracts/Cargo.toml
Normal file
16
crates/contracts/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "contracts"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
jsonwebtoken = "9.3"
|
||||
db = { path = "../db" }
|
||||
cache = { path = "../cache" }
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ use axum::{
|
|||
routing::{delete, get, patch, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use db::models::lead_request::{CreateLeadRequestPayload, LeadRequestRepository};
|
||||
use db::models::professional::ProfessionalRepository;
|
||||
use db::models::requirement::RequirementRepository;
|
||||
use db::models::lead_request::{LeadRequestRepository, CreateLeadRequestPayload};
|
||||
use crate::auth_middleware::AuthUser;
|
||||
use crate::ProfessionState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PaginationQuery {
|
||||
|
|
@ -24,76 +25,126 @@ pub struct LeadRequestPayload {
|
|||
pub requirement_id: Uuid,
|
||||
}
|
||||
|
||||
pub fn shared_routes(profession_key: &'static str) -> Router<PgPool> {
|
||||
/// Build the shared Router that every profession service merges into its own Router.
|
||||
/// `profession_key` must be a `'static str` matching the role key, e.g. `"PHOTOGRAPHER"`.
|
||||
pub fn shared_routes(profession_key: &'static str) -> Router<ProfessionState> {
|
||||
Router::new()
|
||||
// ── Marketplace (Redis-cached) ────────────────────────────────────────
|
||||
.route(
|
||||
"/marketplace",
|
||||
get(move |state, query| browse_marketplace(state, query, profession_key)),
|
||||
get(move |State(state): State<ProfessionState>, Query(q): Query<PaginationQuery>| async move {
|
||||
let page = q.page.unwrap_or(1);
|
||||
let limit = q.limit.unwrap_or(20);
|
||||
|
||||
// Try cache first
|
||||
let mut redis = state.redis.clone();
|
||||
if let Ok(Some(cached)) = cache::jobs::get_marketplace(&mut redis, profession_key, page, limit).await {
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&cached) {
|
||||
return (StatusCode::OK, Json(parsed)).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
match ProfessionalRepository::get_marketplace(&state.pool, profession_key, page, limit).await {
|
||||
Ok(items) => {
|
||||
let body = serde_json::json!({
|
||||
"data": items,
|
||||
"pagination": { "page": page, "limit": limit }
|
||||
});
|
||||
// Write to cache (best-effort)
|
||||
let _ = cache::jobs::set_marketplace(
|
||||
&mut redis,
|
||||
profession_key,
|
||||
page,
|
||||
limit,
|
||||
&body.to_string(),
|
||||
).await;
|
||||
(StatusCode::OK, Json(body)).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route("/marketplace/:id", get(get_requirement))
|
||||
// ── Lead Requests ────────────────────────────────────────────────────
|
||||
.route("/leads/request", post(send_lead_request))
|
||||
.route("/leads/requests/me", get(my_requests))
|
||||
.route("/leads/requests/:id", delete(cancel_request))
|
||||
.route("/leads/accepted/me", get(accepted_leads))
|
||||
.route("/leads/accepted/:id", get(accepted_lead_detail))
|
||||
.route("/portfolio/me", get(list_portfolio))
|
||||
// ... (other routes remain same for now)
|
||||
// ── Portfolio ────────────────────────────────────────────────────────
|
||||
.route("/portfolio/me", get(list_portfolio).post(create_portfolio_item))
|
||||
.route("/portfolio/me/:id", patch(update_portfolio_item).delete(delete_portfolio_item))
|
||||
// ── Services ─────────────────────────────────────────────────────────
|
||||
.route("/services/me", get(list_services).post(create_service))
|
||||
.route("/services/me/:id", patch(update_service).delete(delete_service))
|
||||
// ── Wallet ───────────────────────────────────────────────────────────
|
||||
.route("/wallet/me", get(wallet_balance))
|
||||
.route("/wallet/me/ledger", get(wallet_ledger))
|
||||
.route("/wallet/me/invoices", get(wallet_invoices))
|
||||
.route("/wallet/me/invoices/:id", get(wallet_invoice_detail))
|
||||
}
|
||||
|
||||
async fn browse_marketplace(
|
||||
State(pool): State<PgPool>,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
profession_key: &str,
|
||||
) -> impl IntoResponse {
|
||||
let page = q.page.unwrap_or(1);
|
||||
let limit = q.limit.unwrap_or(20);
|
||||
|
||||
match ProfessionalRepository::get_marketplace(&pool, profession_key, page, limit).await {
|
||||
Ok(items) => (StatusCode::OK, Json(serde_json::json!({
|
||||
"data": items,
|
||||
"pagination": { "page": page, "limit": limit }
|
||||
}))).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn get_requirement(
|
||||
State(pool): State<PgPool>,
|
||||
State(state): State<ProfessionState>,
|
||||
_auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
match RequirementRepository::get_by_id(&pool, id).await {
|
||||
match RequirementRepository::get_by_id(&state.pool, id).await {
|
||||
Ok(Some(req)) if req.status == "OPEN" => (StatusCode::OK, Json(req)).into_response(),
|
||||
Ok(Some(_)) => (StatusCode::FORBIDDEN, "Requirement is not open").into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_lead_request(
|
||||
State(pool): State<PgPool>,
|
||||
State(state): State<ProfessionState>,
|
||||
auth: AuthUser,
|
||||
Json(payload): Json<LeadRequestPayload>,
|
||||
) -> impl IntoResponse {
|
||||
let prof = match ProfessionalRepository::get_by_user_id(&pool, auth.user_id).await {
|
||||
Ok(p) => p,
|
||||
let mut redis = state.redis.clone();
|
||||
|
||||
// ── Rate limit: max 5 lead requests per hour per professional ─────────────
|
||||
let allowed = cache::rate_limit::check_lead(&mut redis, &auth.user_id.to_string())
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
if !allowed {
|
||||
return (StatusCode::TOO_MANY_REQUESTS, "Too many lead requests. Try again later.").into_response();
|
||||
}
|
||||
|
||||
let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
|
||||
};
|
||||
|
||||
let req = match RequirementRepository::get_by_id(&pool, payload.requirement_id).await {
|
||||
// ── Deduplication: one lead per requirement per professional (24 h) ────────
|
||||
let duplicate = cache::lead::is_duplicate(
|
||||
&mut redis,
|
||||
&prof.id.to_string(),
|
||||
&payload.requirement_id.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if duplicate {
|
||||
return (StatusCode::CONFLICT, "You have already sent a lead request for this requirement").into_response();
|
||||
}
|
||||
|
||||
let req = match RequirementRepository::get_by_id(&state.pool, payload.requirement_id).await {
|
||||
Ok(Some(r)) if r.status == "OPEN" => r,
|
||||
Ok(Some(_)) => return (StatusCode::BAD_REQUEST, "Requirement is not open").into_response(),
|
||||
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
||||
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
||||
};
|
||||
|
||||
if req.request_count >= 20 {
|
||||
return (StatusCode::CONFLICT, "Requirement reached max requests").into_response();
|
||||
}
|
||||
|
||||
// Check wallet balance
|
||||
let wallet = match ProfessionalRepository::get_wallet(&pool, auth.user_id).await {
|
||||
Ok(w) => w,
|
||||
_ => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(),
|
||||
let wallet = match ProfessionalRepository::get_wallet(&state.pool, auth.user_id).await {
|
||||
Ok(w) => w,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(),
|
||||
};
|
||||
|
||||
if wallet.balance < 25 {
|
||||
|
|
@ -101,17 +152,23 @@ async fn send_lead_request(
|
|||
}
|
||||
|
||||
let db_payload = CreateLeadRequestPayload {
|
||||
requirement_id: req.id,
|
||||
requirement_id: req.id,
|
||||
professional_id: prof.id,
|
||||
expires_at: Utc::now() + chrono::Duration::hours(24),
|
||||
};
|
||||
|
||||
match LeadRequestRepository::create(&pool, db_payload).await {
|
||||
match LeadRequestRepository::create(&state.pool, db_payload).await {
|
||||
Ok(lead) => {
|
||||
let _ = RequirementRepository::increment_request_count(&pool, req.id).await;
|
||||
// TODO: Debit/Reserve Tracecoins in wallet ledger
|
||||
let _ = RequirementRepository::increment_request_count(&state.pool, req.id).await;
|
||||
// Mark dedup in Redis so this professional can't spam the same requirement
|
||||
let _ = cache::lead::mark_sent(
|
||||
&mut redis,
|
||||
&prof.id.to_string(),
|
||||
&payload.requirement_id.to_string(),
|
||||
)
|
||||
.await;
|
||||
(StatusCode::CREATED, Json(lead)).into_response()
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
if e.to_string().contains("unique") {
|
||||
(StatusCode::CONFLICT, "Already requested this lead").into_response()
|
||||
|
|
@ -122,58 +179,137 @@ async fn send_lead_request(
|
|||
}
|
||||
}
|
||||
|
||||
async fn list_portfolio(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
match ProfessionalRepository::get_by_user_id(&pool, auth.user_id).await {
|
||||
Ok(prof) => {
|
||||
match ProfessionalRepository::get_portfolio(&pool, prof.id).await {
|
||||
Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
async fn list_portfolio(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||
match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||
Ok(prof) => match ProfessionalRepository::get_portfolio(&state.pool, prof.id).await {
|
||||
Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
},
|
||||
Err(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_services(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
match ProfessionalRepository::get_by_user_id(&pool, auth.user_id).await {
|
||||
Ok(prof) => {
|
||||
match ProfessionalRepository::get_services(&pool, prof.id).await {
|
||||
Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
async fn list_services(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||
match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
||||
Ok(prof) => match ProfessionalRepository::get_services(&state.pool, prof.id).await {
|
||||
Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
},
|
||||
Err(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wallet_balance(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
match ProfessionalRepository::get_wallet(&pool, auth.user_id).await {
|
||||
Ok(w) => (StatusCode::OK, Json(w)).into_response(),
|
||||
async fn wallet_balance(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
|
||||
match ProfessionalRepository::get_wallet(&state.pool, auth.user_id).await {
|
||||
Ok(w) => (StatusCode::OK, Json(w)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// Stubs for remaining routes (ledger, invoices, etc.)
|
||||
async fn my_requests(_s: State<PgPool>, _a: AuthUser, _q: Query<PaginationQuery>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) }
|
||||
async fn cancel_request(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Done"}))) }
|
||||
async fn accepted_leads(_s: State<PgPool>, _a: AuthUser, _q: Query<PaginationQuery>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) }
|
||||
async fn accepted_lead_detail(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"id":_p.to_string()}))) }
|
||||
async fn create_portfolio_item(_s: State<PgPool>, _a: AuthUser, _p: Json<serde_json::Value>) -> impl IntoResponse { (StatusCode::CREATED, Json(serde_json::json!({"id":Uuid::new_v4().to_string()}))) }
|
||||
async fn update_portfolio_item(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>, _v: Json<serde_json::Value>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Updated"}))) }
|
||||
async fn delete_portfolio_item(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Deleted"}))) }
|
||||
async fn create_service(_s: State<PgPool>, _a: AuthUser, _p: Json<serde_json::Value>) -> impl IntoResponse { (StatusCode::CREATED, Json(serde_json::json!({"id":Uuid::new_v4().to_string()}))) }
|
||||
async fn update_service(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>, _v: Json<serde_json::Value>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Updated"}))) }
|
||||
async fn delete_service(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Deleted"}))) }
|
||||
async fn wallet_ledger(_s: State<PgPool>, _a: AuthUser, _q: Query<PaginationQuery>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) }
|
||||
async fn wallet_invoices(_s: State<PgPool>, _a: AuthUser, _q: Query<PaginationQuery>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) }
|
||||
async fn wallet_invoice_detail(_s: State<PgPool>, _a: AuthUser, _p: Path<Uuid>) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"id":_p.to_string()}))) }
|
||||
// ── Stub handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn my_requests(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
_q: Query<PaginationQuery>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
|
||||
}
|
||||
|
||||
async fn cancel_request(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
_p: Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "message": "Cancelled" })))
|
||||
}
|
||||
|
||||
async fn accepted_leads(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
_q: Query<PaginationQuery>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
|
||||
}
|
||||
|
||||
async fn accepted_lead_detail(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() })))
|
||||
}
|
||||
|
||||
async fn create_portfolio_item(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
_p: Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::CREATED, Json(serde_json::json!({ "id": Uuid::new_v4().to_string() })))
|
||||
}
|
||||
|
||||
async fn update_portfolio_item(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
_id: Path<Uuid>,
|
||||
_p: Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "message": "Updated" })))
|
||||
}
|
||||
|
||||
async fn delete_portfolio_item(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
_id: Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "message": "Deleted" })))
|
||||
}
|
||||
|
||||
async fn create_service(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
_p: Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::CREATED, Json(serde_json::json!({ "id": Uuid::new_v4().to_string() })))
|
||||
}
|
||||
|
||||
async fn update_service(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
_id: Path<Uuid>,
|
||||
_p: Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "message": "Updated" })))
|
||||
}
|
||||
|
||||
async fn delete_service(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
_id: Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "message": "Deleted" })))
|
||||
}
|
||||
|
||||
async fn wallet_ledger(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
_q: Query<PaginationQuery>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
|
||||
}
|
||||
|
||||
async fn wallet_invoices(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
_q: Query<PaginationQuery>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "data": [] })))
|
||||
}
|
||||
|
||||
async fn wallet_invoice_detail(
|
||||
_s: State<ProfessionState>,
|
||||
_a: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() })))
|
||||
}
|
||||
|
|
|
|||
10
crates/contracts/src/profession_state.rs
Normal file
10
crates/contracts/src/profession_state.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
use sqlx::PgPool;
|
||||
use cache::RedisPool;
|
||||
|
||||
/// Shared state for all 9 profession micro-services.
|
||||
/// Passed as the Axum router state — replaces the bare `PgPool`.
|
||||
#[derive(Clone)]
|
||||
pub struct ProfessionState {
|
||||
pub pool: PgPool,
|
||||
pub redis: RedisPool,
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -3,77 +3,58 @@ use serde::{Deserialize, Serialize};
|
|||
use sqlx::{FromRow, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
// catering_service_profiles uses "business_name" instead of "display_name"
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct CateringServiceProfile {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub business_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpsertCateringServiceProfilePayload {
|
||||
pub business_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct CateringServiceRepository;
|
||||
|
||||
impl CateringServiceRepository {
|
||||
pub async fn get_by_user_id(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<CateringServiceProfile>, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<CateringServiceProfile>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
CateringServiceProfile,
|
||||
r#"
|
||||
SELECT
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
FROM catering_service_profiles
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
r#"SELECT id, user_id, business_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at
|
||||
FROM catering_service_profiles WHERE user_id = $1"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
).fetch_optional(pool).await
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
payload: UpsertCateringServiceProfilePayload,
|
||||
) -> Result<CateringServiceProfile, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertCateringServiceProfilePayload) -> Result<CateringServiceProfile, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
CateringServiceProfile,
|
||||
r#"
|
||||
INSERT INTO catering_service_profiles (
|
||||
user_id, bio, experience_years, custom_data
|
||||
)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
bio = EXCLUDED.bio,
|
||||
experience_years = EXCLUDED.experience_years,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
"#,
|
||||
user_id,
|
||||
payload.bio,
|
||||
payload.experience_years,
|
||||
payload.custom_data
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
r#"INSERT INTO catering_service_profiles (user_id, business_name, bio, location, custom_data)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
business_name = COALESCE(EXCLUDED.business_name, catering_service_profiles.business_name),
|
||||
bio = EXCLUDED.bio,
|
||||
location = EXCLUDED.location,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, business_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at"#,
|
||||
user_id, p.business_name, p.bio, p.location, p.custom_data
|
||||
).fetch_one(pool).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,73 +7,52 @@ use uuid::Uuid;
|
|||
pub struct DeveloperProfile {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpsertDeveloperProfilePayload {
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct DeveloperRepository;
|
||||
|
||||
impl DeveloperRepository {
|
||||
pub async fn get_by_user_id(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<DeveloperProfile>, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<DeveloperProfile>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
DeveloperProfile,
|
||||
r#"
|
||||
SELECT
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
FROM developer_profiles
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
r#"SELECT id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at
|
||||
FROM developer_profiles WHERE user_id = $1"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
).fetch_optional(pool).await
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
payload: UpsertDeveloperProfilePayload,
|
||||
) -> Result<DeveloperProfile, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertDeveloperProfilePayload) -> Result<DeveloperProfile, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
DeveloperProfile,
|
||||
r#"
|
||||
INSERT INTO developer_profiles (
|
||||
user_id, bio, experience_years, custom_data
|
||||
)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
bio = EXCLUDED.bio,
|
||||
experience_years = EXCLUDED.experience_years,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
"#,
|
||||
user_id,
|
||||
payload.bio,
|
||||
payload.experience_years,
|
||||
payload.custom_data
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
r#"INSERT INTO developer_profiles (user_id, display_name, bio, location, custom_data)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name = COALESCE(EXCLUDED.display_name, developer_profiles.display_name),
|
||||
bio = EXCLUDED.bio,
|
||||
location = EXCLUDED.location,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at"#,
|
||||
user_id, p.display_name, p.bio, p.location, p.custom_data
|
||||
).fetch_one(pool).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,73 +7,52 @@ use uuid::Uuid;
|
|||
pub struct FitnessTrainerProfile {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpsertFitnessTrainerProfilePayload {
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct FitnessTrainerRepository;
|
||||
|
||||
impl FitnessTrainerRepository {
|
||||
pub async fn get_by_user_id(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<FitnessTrainerProfile>, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<FitnessTrainerProfile>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
FitnessTrainerProfile,
|
||||
r#"
|
||||
SELECT
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
FROM fitness_trainer_profiles
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
r#"SELECT id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at
|
||||
FROM fitness_trainer_profiles WHERE user_id = $1"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
).fetch_optional(pool).await
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
payload: UpsertFitnessTrainerProfilePayload,
|
||||
) -> Result<FitnessTrainerProfile, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertFitnessTrainerProfilePayload) -> Result<FitnessTrainerProfile, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
FitnessTrainerProfile,
|
||||
r#"
|
||||
INSERT INTO fitness_trainer_profiles (
|
||||
user_id, bio, experience_years, custom_data
|
||||
)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
bio = EXCLUDED.bio,
|
||||
experience_years = EXCLUDED.experience_years,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
"#,
|
||||
user_id,
|
||||
payload.bio,
|
||||
payload.experience_years,
|
||||
payload.custom_data
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
r#"INSERT INTO fitness_trainer_profiles (user_id, display_name, bio, location, custom_data)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name = COALESCE(EXCLUDED.display_name, fitness_trainer_profiles.display_name),
|
||||
bio = EXCLUDED.bio,
|
||||
location = EXCLUDED.location,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at"#,
|
||||
user_id, p.display_name, p.bio, p.location, p.custom_data
|
||||
).fetch_one(pool).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,73 +7,52 @@ use uuid::Uuid;
|
|||
pub struct GraphicDesignerProfile {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpsertGraphicDesignerProfilePayload {
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct GraphicDesignerRepository;
|
||||
|
||||
impl GraphicDesignerRepository {
|
||||
pub async fn get_by_user_id(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<GraphicDesignerProfile>, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<GraphicDesignerProfile>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
GraphicDesignerProfile,
|
||||
r#"
|
||||
SELECT
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
FROM graphic_designer_profiles
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
r#"SELECT id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at
|
||||
FROM graphic_designer_profiles WHERE user_id = $1"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
).fetch_optional(pool).await
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
payload: UpsertGraphicDesignerProfilePayload,
|
||||
) -> Result<GraphicDesignerProfile, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertGraphicDesignerProfilePayload) -> Result<GraphicDesignerProfile, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
GraphicDesignerProfile,
|
||||
r#"
|
||||
INSERT INTO graphic_designer_profiles (
|
||||
user_id, bio, experience_years, custom_data
|
||||
)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
bio = EXCLUDED.bio,
|
||||
experience_years = EXCLUDED.experience_years,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
"#,
|
||||
user_id,
|
||||
payload.bio,
|
||||
payload.experience_years,
|
||||
payload.custom_data
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
r#"INSERT INTO graphic_designer_profiles (user_id, display_name, bio, location, custom_data)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name = COALESCE(EXCLUDED.display_name, graphic_designer_profiles.display_name),
|
||||
bio = EXCLUDED.bio,
|
||||
location = EXCLUDED.location,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at"#,
|
||||
user_id, p.display_name, p.bio, p.location, p.custom_data
|
||||
).fetch_one(pool).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,73 +7,52 @@ use uuid::Uuid;
|
|||
pub struct MakeupArtistProfile {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpsertMakeupArtistProfilePayload {
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct MakeupArtistRepository;
|
||||
|
||||
impl MakeupArtistRepository {
|
||||
pub async fn get_by_user_id(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<MakeupArtistProfile>, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<MakeupArtistProfile>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
MakeupArtistProfile,
|
||||
r#"
|
||||
SELECT
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
FROM makeup_artist_profiles
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
r#"SELECT id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at
|
||||
FROM makeup_artist_profiles WHERE user_id = $1"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
).fetch_optional(pool).await
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
payload: UpsertMakeupArtistProfilePayload,
|
||||
) -> Result<MakeupArtistProfile, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertMakeupArtistProfilePayload) -> Result<MakeupArtistProfile, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
MakeupArtistProfile,
|
||||
r#"
|
||||
INSERT INTO makeup_artist_profiles (
|
||||
user_id, bio, experience_years, custom_data
|
||||
)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
bio = EXCLUDED.bio,
|
||||
experience_years = EXCLUDED.experience_years,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
"#,
|
||||
user_id,
|
||||
payload.bio,
|
||||
payload.experience_years,
|
||||
payload.custom_data
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
r#"INSERT INTO makeup_artist_profiles (user_id, display_name, bio, location, custom_data)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name = COALESCE(EXCLUDED.display_name, makeup_artist_profiles.display_name),
|
||||
bio = EXCLUDED.bio,
|
||||
location = EXCLUDED.location,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at"#,
|
||||
user_id, p.display_name, p.bio, p.location, p.custom_data
|
||||
).fetch_one(pool).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
135
crates/db/src/models/onboarding_state.rs
Normal file
135
crates/db/src/models/onboarding_state.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── Structs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct OnboardingState {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub role_id: Uuid,
|
||||
pub status: String, // NOT_STARTED | IN_PROGRESS | COMPLETED
|
||||
pub progress_json: Value,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SaveProgressPayload {
|
||||
pub role_id: Uuid,
|
||||
pub progress_json: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SubmitOnboardingPayload {
|
||||
pub role_id: Uuid,
|
||||
pub progress_json: Value,
|
||||
}
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct OnboardingStateRepository;
|
||||
|
||||
impl OnboardingStateRepository {
|
||||
/// Fetch onboarding state for a user + role. Returns None if no record exists yet.
|
||||
pub async fn get(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
role_id: Uuid,
|
||||
) -> Result<Option<OnboardingState>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
OnboardingState,
|
||||
r#"
|
||||
SELECT id, user_id, role_id, status, progress_json,
|
||||
completed_at, created_at, updated_at
|
||||
FROM onboarding_states
|
||||
WHERE user_id = $1 AND role_id = $2
|
||||
"#,
|
||||
user_id,
|
||||
role_id,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Upsert progress — inserts a new record or updates progress on conflict.
|
||||
pub async fn save_progress(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
role_id: Uuid,
|
||||
progress: &Value,
|
||||
) -> Result<OnboardingState, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
OnboardingState,
|
||||
r#"
|
||||
INSERT INTO onboarding_states (user_id, role_id, status, progress_json)
|
||||
VALUES ($1, $2, 'IN_PROGRESS', $3)
|
||||
ON CONFLICT (user_id, role_id) DO UPDATE
|
||||
SET status = CASE
|
||||
WHEN onboarding_states.status = 'COMPLETED' THEN 'COMPLETED'
|
||||
ELSE 'IN_PROGRESS'
|
||||
END,
|
||||
progress_json = EXCLUDED.progress_json,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, role_id, status, progress_json,
|
||||
completed_at, created_at, updated_at
|
||||
"#,
|
||||
user_id,
|
||||
role_id,
|
||||
progress,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Mark onboarding as COMPLETED and freeze the final answers.
|
||||
pub async fn complete(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
role_id: Uuid,
|
||||
final_answers: &Value,
|
||||
) -> Result<OnboardingState, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
OnboardingState,
|
||||
r#"
|
||||
INSERT INTO onboarding_states (user_id, role_id, status, progress_json, completed_at)
|
||||
VALUES ($1, $2, 'COMPLETED', $3, NOW())
|
||||
ON CONFLICT (user_id, role_id) DO UPDATE
|
||||
SET status = 'COMPLETED',
|
||||
progress_json = EXCLUDED.progress_json,
|
||||
completed_at = NOW(),
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, role_id, status, progress_json,
|
||||
completed_at, created_at, updated_at
|
||||
"#,
|
||||
user_id,
|
||||
role_id,
|
||||
final_answers,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Check whether onboarding is complete for a user+role. Returns false if no record.
|
||||
pub async fn is_complete(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
role_id: Uuid,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT status FROM onboarding_states
|
||||
WHERE user_id = $1 AND role_id = $2
|
||||
"#,
|
||||
user_id,
|
||||
role_id,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.status == "COMPLETED").unwrap_or(false))
|
||||
}
|
||||
}
|
||||
|
|
@ -7,83 +7,52 @@ use uuid::Uuid;
|
|||
pub struct PhotographerProfile {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub portfolio_url: Option<String>,
|
||||
pub equipment_list: Option<String>,
|
||||
pub years_of_experience: Option<i32>,
|
||||
pub hourly_rate: Option<sqlx::types::BigDecimal>,
|
||||
pub specialties: Option<Vec<String>>,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpsertPhotographerProfilePayload {
|
||||
pub portfolio_url: Option<String>,
|
||||
pub equipment_list: Option<String>,
|
||||
pub years_of_experience: Option<i32>,
|
||||
pub hourly_rate: Option<f64>,
|
||||
pub specialties: Option<Vec<String>>,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct PhotographerRepository;
|
||||
|
||||
impl PhotographerRepository {
|
||||
pub async fn get_by_user_id(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<PhotographerProfile>, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<PhotographerProfile>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
PhotographerProfile,
|
||||
r#"
|
||||
SELECT
|
||||
id, user_id, portfolio_url, equipment_list, years_of_experience,
|
||||
hourly_rate, specialties, created_at, updated_at
|
||||
FROM photographer_profiles
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
r#"SELECT id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at
|
||||
FROM photographer_profiles WHERE user_id = $1"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
).fetch_optional(pool).await
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
payload: UpsertPhotographerProfilePayload,
|
||||
) -> Result<PhotographerProfile, sqlx::Error> {
|
||||
let hourly_rate_bd = payload.hourly_rate.map(|v| sqlx::types::BigDecimal::try_from(v).unwrap_or_default());
|
||||
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertPhotographerProfilePayload) -> Result<PhotographerProfile, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
PhotographerProfile,
|
||||
r#"
|
||||
INSERT INTO photographer_profiles (
|
||||
user_id, portfolio_url, equipment_list, years_of_experience, hourly_rate, specialties
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
portfolio_url = EXCLUDED.portfolio_url,
|
||||
equipment_list = EXCLUDED.equipment_list,
|
||||
years_of_experience = EXCLUDED.years_of_experience,
|
||||
hourly_rate = EXCLUDED.hourly_rate,
|
||||
specialties = EXCLUDED.specialties,
|
||||
updated_at = NOW()
|
||||
RETURNING
|
||||
id, user_id, portfolio_url, equipment_list, years_of_experience,
|
||||
hourly_rate, specialties, created_at, updated_at
|
||||
"#,
|
||||
user_id,
|
||||
payload.portfolio_url,
|
||||
payload.equipment_list,
|
||||
payload.years_of_experience,
|
||||
hourly_rate_bd,
|
||||
payload.specialties.as_deref()
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
r#"INSERT INTO photographer_profiles (user_id, display_name, bio, location, custom_data)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name = COALESCE(EXCLUDED.display_name, photographer_profiles.display_name),
|
||||
bio = EXCLUDED.bio,
|
||||
location = EXCLUDED.location,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at"#,
|
||||
user_id, p.display_name, p.bio, p.location, p.custom_data
|
||||
).fetch_one(pool).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,73 +7,52 @@ use uuid::Uuid;
|
|||
pub struct SocialMediaManagerProfile {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpsertSocialMediaManagerProfilePayload {
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct SocialMediaManagerRepository;
|
||||
|
||||
impl SocialMediaManagerRepository {
|
||||
pub async fn get_by_user_id(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<SocialMediaManagerProfile>, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<SocialMediaManagerProfile>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
SocialMediaManagerProfile,
|
||||
r#"
|
||||
SELECT
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
FROM social_media_manager_profiles
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
r#"SELECT id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at
|
||||
FROM social_media_manager_profiles WHERE user_id = $1"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
).fetch_optional(pool).await
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
payload: UpsertSocialMediaManagerProfilePayload,
|
||||
) -> Result<SocialMediaManagerProfile, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertSocialMediaManagerProfilePayload) -> Result<SocialMediaManagerProfile, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
SocialMediaManagerProfile,
|
||||
r#"
|
||||
INSERT INTO social_media_manager_profiles (
|
||||
user_id, bio, experience_years, custom_data
|
||||
)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
bio = EXCLUDED.bio,
|
||||
experience_years = EXCLUDED.experience_years,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
"#,
|
||||
user_id,
|
||||
payload.bio,
|
||||
payload.experience_years,
|
||||
payload.custom_data
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
r#"INSERT INTO social_media_manager_profiles (user_id, display_name, bio, location, custom_data)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name = COALESCE(EXCLUDED.display_name, social_media_manager_profiles.display_name),
|
||||
bio = EXCLUDED.bio,
|
||||
location = EXCLUDED.location,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at"#,
|
||||
user_id, p.display_name, p.bio, p.location, p.custom_data
|
||||
).fetch_one(pool).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,83 +7,52 @@ use uuid::Uuid;
|
|||
pub struct TutorProfile {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub subjects_taught: Option<Vec<String>>,
|
||||
pub education_level: Option<String>,
|
||||
pub certifications: Option<String>,
|
||||
pub years_of_experience: Option<i32>,
|
||||
pub hourly_rate: Option<sqlx::types::BigDecimal>,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpsertTutorProfilePayload {
|
||||
pub subjects_taught: Option<Vec<String>>,
|
||||
pub education_level: Option<String>,
|
||||
pub certifications: Option<String>,
|
||||
pub years_of_experience: Option<i32>,
|
||||
pub hourly_rate: Option<f64>,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct TutorRepository;
|
||||
|
||||
impl TutorRepository {
|
||||
pub async fn get_by_user_id(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<TutorProfile>, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<TutorProfile>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
TutorProfile,
|
||||
r#"
|
||||
SELECT
|
||||
id, user_id, subjects_taught, education_level, certifications,
|
||||
years_of_experience, hourly_rate, created_at, updated_at
|
||||
FROM tutor_profiles
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
r#"SELECT id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at
|
||||
FROM tutor_profiles WHERE user_id = $1"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
).fetch_optional(pool).await
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
payload: UpsertTutorProfilePayload,
|
||||
) -> Result<TutorProfile, sqlx::Error> {
|
||||
let hourly_rate_bd = payload.hourly_rate.map(|v| sqlx::types::BigDecimal::try_from(v).unwrap_or_default());
|
||||
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertTutorProfilePayload) -> Result<TutorProfile, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
TutorProfile,
|
||||
r#"
|
||||
INSERT INTO tutor_profiles (
|
||||
user_id, subjects_taught, education_level, certifications, years_of_experience, hourly_rate
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
subjects_taught = EXCLUDED.subjects_taught,
|
||||
education_level = EXCLUDED.education_level,
|
||||
certifications = EXCLUDED.certifications,
|
||||
years_of_experience = EXCLUDED.years_of_experience,
|
||||
hourly_rate = EXCLUDED.hourly_rate,
|
||||
updated_at = NOW()
|
||||
RETURNING
|
||||
id, user_id, subjects_taught, education_level, certifications,
|
||||
years_of_experience, hourly_rate, created_at, updated_at
|
||||
"#,
|
||||
user_id,
|
||||
payload.subjects_taught.as_deref(),
|
||||
payload.education_level,
|
||||
payload.certifications,
|
||||
payload.years_of_experience,
|
||||
hourly_rate_bd
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
r#"INSERT INTO tutor_profiles (user_id, display_name, bio, location, custom_data)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name = COALESCE(EXCLUDED.display_name, tutor_profiles.display_name),
|
||||
bio = EXCLUDED.bio,
|
||||
location = EXCLUDED.location,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at"#,
|
||||
user_id, p.display_name, p.bio, p.location, p.custom_data
|
||||
).fetch_one(pool).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,73 +7,52 @@ use uuid::Uuid;
|
|||
pub struct VideoEditorProfile {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpsertVideoEditorProfilePayload {
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub experience_years: Option<i32>,
|
||||
pub location: Option<String>,
|
||||
pub custom_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct VideoEditorRepository;
|
||||
|
||||
impl VideoEditorRepository {
|
||||
pub async fn get_by_user_id(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<VideoEditorProfile>, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<VideoEditorProfile>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
VideoEditorProfile,
|
||||
r#"
|
||||
SELECT
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
FROM video_editor_profiles
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
r#"SELECT id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at
|
||||
FROM video_editor_profiles WHERE user_id = $1"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
).fetch_optional(pool).await
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
payload: UpsertVideoEditorProfilePayload,
|
||||
) -> Result<VideoEditorProfile, sqlx::Error> {
|
||||
let profile = sqlx::query_as!(
|
||||
pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertVideoEditorProfilePayload) -> Result<VideoEditorProfile, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
VideoEditorProfile,
|
||||
r#"
|
||||
INSERT INTO video_editor_profiles (
|
||||
user_id, bio, experience_years, custom_data
|
||||
)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
bio = EXCLUDED.bio,
|
||||
experience_years = EXCLUDED.experience_years,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING
|
||||
id, user_id, bio, experience_years, custom_data,
|
||||
created_at, updated_at
|
||||
"#,
|
||||
user_id,
|
||||
payload.bio,
|
||||
payload.experience_years,
|
||||
payload.custom_data
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
r#"INSERT INTO video_editor_profiles (user_id, display_name, bio, location, custom_data)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name = COALESCE(EXCLUDED.display_name, video_editor_profiles.display_name),
|
||||
bio = EXCLUDED.bio,
|
||||
location = EXCLUDED.location,
|
||||
custom_data = EXCLUDED.custom_data,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, display_name, bio, location,
|
||||
custom_data as "custom_data: Option<serde_json::Value>",
|
||||
status, created_at, updated_at"#,
|
||||
user_id, p.display_name, p.bio, p.location, p.custom_data
|
||||
).fetch_one(pool).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
crates/storage/Cargo.toml
Normal file
17
crates/storage/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "storage"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
aws-sdk-s3 = "1"
|
||||
aws-config = "1"
|
||||
aws-credential-types = "1"
|
||||
bytes = "1"
|
||||
mime_guess = "2"
|
||||
85
crates/storage/src/lib.rs
Normal file
85
crates/storage/src/lib.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
//! Backblaze B2 file storage via S3-compatible API.
|
||||
//!
|
||||
//! Configuration (environment variables):
|
||||
//! B2_KEY_ID — Application Key ID
|
||||
//! B2_APPLICATION_KEY — Application Key secret
|
||||
//! B2_BUCKET_NAME — Bucket name (e.g. Nxtgauge-object)
|
||||
//! B2_ENDPOINT — S3 endpoint (e.g. s3.eu-central-003.backblazeb2.com)
|
||||
//! B2_REGION — Region (e.g. eu-central-003)
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use aws_config::Region;
|
||||
use aws_credential_types::Credentials;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::config::{Builder as S3ConfigBuilder, SharedCredentialsProvider};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use bytes::Bytes;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StorageClient {
|
||||
client: Client,
|
||||
bucket: String,
|
||||
public_base_url: String,
|
||||
}
|
||||
|
||||
impl StorageClient {
|
||||
/// Build from environment variables. Panics if required vars are missing.
|
||||
pub async fn from_env() -> Self {
|
||||
let key_id = std::env::var("B2_KEY_ID").expect("B2_KEY_ID must be set");
|
||||
let app_key = std::env::var("B2_APPLICATION_KEY").expect("B2_APPLICATION_KEY must be set");
|
||||
let bucket = std::env::var("B2_BUCKET_NAME").expect("B2_BUCKET_NAME must be set");
|
||||
let endpoint = std::env::var("B2_ENDPOINT").expect("B2_ENDPOINT must be set");
|
||||
let region = std::env::var("B2_REGION").expect("B2_REGION must be set");
|
||||
|
||||
let creds = Credentials::new(key_id, app_key, None, None, "nxtgauge-storage");
|
||||
let endpoint_url = format!("https://{}", endpoint);
|
||||
let public_base_url = format!("https://{}/{}", endpoint, bucket);
|
||||
|
||||
let s3_config = S3ConfigBuilder::new()
|
||||
.endpoint_url(endpoint_url)
|
||||
.region(Region::new(region))
|
||||
.credentials_provider(SharedCredentialsProvider::new(creds))
|
||||
.force_path_style(true)
|
||||
.build();
|
||||
|
||||
let client = Client::from_conf(s3_config);
|
||||
Self { client, bucket, public_base_url }
|
||||
}
|
||||
|
||||
/// Upload bytes to B2. Returns the public URL.
|
||||
///
|
||||
/// `prefix` — e.g. "portfolio", "resume", "profile"
|
||||
/// `ext` — file extension without dot, e.g. "jpg", "pdf"
|
||||
pub async fn upload(&self, prefix: &str, ext: &str, data: Bytes, content_type: &str) -> Result<String> {
|
||||
let key = format!("{}/{}.{}", prefix, Uuid::new_v4(), ext);
|
||||
|
||||
self.client
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from(data))
|
||||
.content_type(content_type)
|
||||
.send()
|
||||
.await
|
||||
.context("B2 upload failed")?;
|
||||
|
||||
Ok(format!("{}/{}", self.public_base_url, key))
|
||||
}
|
||||
|
||||
/// Delete a file by its full public URL (best-effort — logs on failure).
|
||||
pub async fn delete_by_url(&self, url: &str) {
|
||||
let prefix = format!("{}/", self.public_base_url);
|
||||
if let Some(key) = url.strip_prefix(&prefix) {
|
||||
if let Err(e) = self.client
|
||||
.delete_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
tracing::warn!("B2 delete failed for key={}: {}", key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1009
scripts/seed.sql
Normal file
1009
scripts/seed.sql
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue