diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..486ce7f --- /dev/null +++ b/.env.example @@ -0,0 +1,61 @@ +# Nxtgauge Backend — Environment Variables +# Copy this file to .env and fill in the values. + +# ── Database ───────────────────────────────────────────────────────────────── +POSTGRES_PASSWORD=nxtgauge_dev +DATABASE_URL=postgresql://nxtgauge:nxtgauge_dev@localhost:5432/nxtgauge_db + +# ── Auth ────────────────────────────────────────────────────────────────────── +# Generate with: openssl rand -base64 64 +JWT_SECRET=change-me-to-a-secure-random-string-of-at-least-64-chars +JWT_EXPIRY_MINUTES=15 +REFRESH_TOKEN_EXPIRY_DAYS=30 + +# ── SMTP (ZeptoMail/Zoho) ────────────────────────────────────────────────── +SMTP_HOST=smtp.zeptomail.in +SMTP_PORT=587 +SMTP_USER=emailapikey +SMTP_PASS=PHtE6r1ZR+zi3jV88RNW4/O4F8CkPdksqO9iJAhA4YcTD6dQFk1S+dl/wDC3/h97AKYWFfSczo1rt72etOuDLTnrMjlEDWqyqK3sx/VYSPOZsbq6x00esVgYdEfYVYDpcNFj3SPQut7dNA== +SMTP_FROM_EMAIL=support@nxtgauge.com +SMTP_FROM_NAME=NXTGAUGE + + +# ── Payments ────────────────────────────────────────────────────────────────── +RAZORPAY_KEY_ID=rzp_test_... +RAZORPAY_KEY_SECRET=... + +# ── Frontend ────────────────────────────────────────────────────────────────── +FRONTEND_URL=http://localhost:3000 + +# ── Service Ports (local development, for running services individually) ────── +GATEWAY_PORT=8000 +USERS_PORT=8080 +COMPANIES_PORT=8081 +JOB_SEEKERS_PORT=8082 +CUSTOMERS_PORT=8083 +PHOTOGRAPHERS_PORT=8085 +MAKEUP_ARTISTS_PORT=8086 +TUTORS_PORT=8087 +DEVELOPERS_PORT=8088 +VIDEO_EDITORS_PORT=8089 +GRAPHIC_DESIGNERS_PORT=8090 +SOCIAL_MEDIA_MANAGERS_PORT=8091 +FITNESS_TRAINERS_PORT=8092 +CATERING_SERVICES_PORT=8093 +PAYMENTS_PORT=8094 + +# ── Service URLs (used by gateway — override only for non-Docker dev) ───────── +USERS_SERVICE_URL=http://localhost:8080 +COMPANIES_SERVICE_URL=http://localhost:8081 +JOB_SEEKERS_SERVICE_URL=http://localhost:8082 +CUSTOMERS_SERVICE_URL=http://localhost:8083 +PHOTOGRAPHERS_SERVICE_URL=http://localhost:8085 +MAKEUP_ARTISTS_SERVICE_URL=http://localhost:8086 +TUTORS_SERVICE_URL=http://localhost:8087 +DEVELOPERS_SERVICE_URL=http://localhost:8088 +VIDEO_EDITORS_SERVICE_URL=http://localhost:8089 +GRAPHIC_DESIGNERS_SERVICE_URL=http://localhost:8090 +SOCIAL_MEDIA_MANAGERS_SERVICE_URL=http://localhost:8091 +FITNESS_TRAINERS_SERVICE_URL=http://localhost:8092 +CATERING_SERVICES_SERVICE_URL=http://localhost:8093 +PAYMENTS_SERVICE_URL=http://localhost:8094 diff --git a/Cargo.toml b/Cargo.toml index 3cb6564..fc95cf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,10 +3,18 @@ resolver = "2" members = [ "apps/gateway", "apps/users", + "apps/photographers", + "apps/tutors", "apps/companies", + "apps/job_seekers", "apps/customers", - "apps/professionals", - "apps/jobseekers", + "apps/makeup_artists", + "apps/developers", + "apps/video_editors", + "apps/graphic_designers", + "apps/social_media_managers", + "apps/fitness_trainers", + "apps/catering_services", "crates/contracts", "crates/config", "crates/errors", @@ -34,3 +42,5 @@ prost = "0.13" sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json"] } uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } +lettre = { version = "0.11", features = ["tokio-rustls-tls", "serde"] } + diff --git a/apps/catering_services/Cargo.toml b/apps/catering_services/Cargo.toml new file mode 100644 index 0000000..590cddd --- /dev/null +++ b/apps/catering_services/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "catering_services" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } + diff --git a/apps/catering_services/src/handlers.rs b/apps/catering_services/src/handlers.rs new file mode 100644 index 0000000..0d9e8bb --- /dev/null +++ b/apps/catering_services/src/handlers.rs @@ -0,0 +1,38 @@ +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{get, patch}, + Json, Router, +}; +use sqlx::PgPool; +use db::models::catering_service::{CateringServiceRepository, UpsertCateringServiceProfilePayload}; +use contracts::auth_middleware::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + .merge(contracts::profession_shared::shared_routes("CATERING_SERVICE")) +} + +async fn get_profile( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match CateringServiceRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match CateringServiceRepository::upsert(&pool, auth.user_id, payload).await { + Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/apps/catering_services/src/main.rs b/apps/catering_services/src/main.rs new file mode 100644 index 0000000..2bdbe37 --- /dev/null +++ b/apps/catering_services/src/main.rs @@ -0,0 +1,42 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use contracts::profession_shared::shared_routes; + + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Catering Services OK" })) + .nest("/api/catering-services", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8093".to_string()).parse().unwrap(); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("Catering Services listening on {}", addr); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/companies/Cargo.toml b/apps/companies/Cargo.toml new file mode 100644 index 0000000..3a9d35d --- /dev/null +++ b/apps/companies/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "companies" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } + diff --git a/apps/companies/src/handlers.rs b/apps/companies/src/handlers.rs new file mode 100644 index 0000000..174eee3 --- /dev/null +++ b/apps/companies/src/handlers.rs @@ -0,0 +1,304 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, patch, post}, + Json, Router, +}; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; +use db::models::company::{CompanyRepository, UpsertCompanyProfilePayload}; +use db::models::job::{JobRepository, CreateJobPayload as DbCreateJobPayload, UpdateJobPayload as DbUpdateJobPayload}; +use db::models::application::ApplicationRepository; +use contracts::auth_middleware::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + .route("/jobs", get(list_jobs).post(create_job)) + .route("/jobs/:id", get(get_job).patch(update_job)) + .route("/jobs/:id/submit", post(submit_job)) + .route("/jobs/:id/close", post(close_job)) + .route("/jobs/:id/applications", get(list_applications)) + .route("/applications/:id/status", patch(update_application_status)) + .route("/applications/:id/contact", get(view_contact)) +} + +#[derive(Deserialize)] +pub struct PaginationQuery { + pub page: Option, + pub limit: Option, + pub status: Option, +} + +#[derive(Deserialize)] +pub struct CreateJobRequest { + pub title: String, + pub description: String, + pub location: String, + pub job_type: Option, + pub salary_min: Option, + pub salary_max: Option, + pub experience_years: Option, + pub skills: Option>, + pub category: Option, +} + +#[derive(Deserialize)] +pub struct UpdateApplicationStatusPayload { + pub status: String, +} + +async fn get_profile( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match CompanyRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Company profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match CompanyRepository::upsert(&pool, auth.user_id, payload).await { + Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn list_jobs( + State(pool): State, + auth: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::NOT_FOUND, "Company not found").into_response(), + }; + + let page = q.page.unwrap_or(1); + let limit = q.limit.unwrap_or(20); + match JobRepository::list_by_company_id(&pool, company.id, q.status, page, limit).await { + Ok(jobs) => (StatusCode::OK, Json(serde_json::json!({ + "data": jobs, + "pagination": { "page": page, "limit": limit } + }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn create_job( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::NOT_FOUND, "Company not found").into_response(), + }; + + let db_payload = DbCreateJobPayload { + company_id: company.id, + title: payload.title, + category: payload.category, + description: payload.description, + location: payload.location, + job_type: payload.job_type, + salary_min: payload.salary_min, + salary_max: payload.salary_max, + experience_years: payload.experience_years, + skills: payload.skills, + }; + + match JobRepository::create(&pool, db_payload).await { + Ok(job) => (StatusCode::CREATED, Json(job)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn get_job( + State(pool): State, + Path(id): Path, + _auth: AuthUser, +) -> impl IntoResponse { + match JobRepository::get_by_id(&pool, id).await { + Ok(Some(job)) => (StatusCode::OK, Json(job)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Job not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_job( + State(pool): State, + Path(id): Path, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + // Basic verification: does job belong to auth user's company? + let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::NOT_FOUND, "Company not found").into_response(), + }; + + let job = match JobRepository::get_by_id(&pool, id).await { + Ok(Some(j)) if j.company_id == company.id => j, + Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + _ => return (StatusCode::NOT_FOUND, "Job not found").into_response(), + }; + + match JobRepository::update(&pool, job.id, payload).await { + Ok(updated) => (StatusCode::OK, Json(updated)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn submit_job( + State(pool): State, + Path(id): Path, + auth: AuthUser, +) -> impl IntoResponse { + let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::NOT_FOUND, "Company not found").into_response(), + }; + + let job = match JobRepository::get_by_id(&pool, id).await { + Ok(Some(j)) if j.company_id == company.id => j, + Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + _ => return (StatusCode::NOT_FOUND, "Job not found").into_response(), + }; + + if job.status != "DRAFT" { + return (StatusCode::BAD_REQUEST, "Job already submitted or live").into_response(); + } + + match JobRepository::update_status(&pool, job.id, "PENDING_APPROVAL").await { + Ok(updated) => (StatusCode::OK, Json(updated)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn close_job( + State(pool): State, + Path(id): Path, + auth: AuthUser, +) -> impl IntoResponse { + let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::NOT_FOUND, "Company not found").into_response(), + }; + + let job = match JobRepository::get_by_id(&pool, id).await { + Ok(Some(j)) if j.company_id == company.id => j, + Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + _ => return (StatusCode::NOT_FOUND, "Job not found").into_response(), + }; + + match JobRepository::update_status(&pool, job.id, "CLOSED").await { + Ok(updated) => (StatusCode::OK, Json(updated)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn list_applications( + State(pool): State, + Path(id): Path, + auth: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::NOT_FOUND, "Company not found").into_response(), + }; + + let job = match JobRepository::get_by_id(&pool, id).await { + Ok(Some(j)) if j.company_id == company.id => j, + Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + _ => return (StatusCode::NOT_FOUND, "Job not found").into_response(), + }; + + let page = q.page.unwrap_or(1); + let limit = q.limit.unwrap_or(20); + match ApplicationRepository::list_by_job_id(&pool, job.id, q.status, page, limit).await { + Ok(apps) => (StatusCode::OK, Json(serde_json::json!({ + "data": apps, + "pagination": { "page": page, "limit": limit } + }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_application_status( + State(pool): State, + Path(id): Path, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + let app = match ApplicationRepository::get_by_id(&pool, id).await { + Ok(Some(a)) => a, + _ => return (StatusCode::NOT_FOUND, "Application not found").into_response(), + }; + + let job = match JobRepository::get_by_id(&pool, app.job_id).await { + Ok(Some(j)) => j, + _ => return (StatusCode::INTERNAL_SERVER_ERROR, "Job lost").into_response(), + }; + + let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + }; + + if job.company_id != company.id { + return (StatusCode::FORBIDDEN, "Access denied").into_response(); + } + + match ApplicationRepository::update_status(&pool, app.id, &payload.status).await { + Ok(updated) => (StatusCode::OK, Json(updated)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn view_contact( + State(pool): State, + Path(id): Path, + auth: AuthUser, +) -> impl IntoResponse { + let app = match ApplicationRepository::get_by_id(&pool, id).await { + Ok(Some(a)) => a, + _ => return (StatusCode::NOT_FOUND, "Application not found").into_response(), + }; + + let job = match JobRepository::get_by_id(&pool, app.job_id).await { + Ok(Some(j)) => j, + _ => return (StatusCode::INTERNAL_SERVER_ERROR, "Job lost").into_response(), + }; + + let company = match CompanyRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + }; + + if job.company_id != company.id { + return (StatusCode::FORBIDDEN, "Access denied").into_response(); + } + + // TODO: logic to deduct quota + fetch job seeker contact info from users table + // For now, just mark viewed and return placeholder + let _ = ApplicationRepository::mark_contact_viewed(&pool, app.id).await; + + (StatusCode::OK, Json(serde_json::json!({ + "application_id": id.to_string(), + "full_name": "Applicant Contact Info Locked", + "email": "hidden@example.com", + "phone": "+91 0000000000", + "message": "Contact revealed" + }))).into_response() +} + diff --git a/apps/companies/src/main.rs b/apps/companies/src/main.rs new file mode 100644 index 0000000..2201b88 --- /dev/null +++ b/apps/companies/src/main.rs @@ -0,0 +1,49 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + tracing::info!("Companies service — connected to database"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Companies Service OK" })) + .nest("/api/companies", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "8081".to_string()) + .parse() + .expect("PORT must be a number"); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + + tracing::info!("Companies service listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/customers/Cargo.toml b/apps/customers/Cargo.toml new file mode 100644 index 0000000..8c598b6 --- /dev/null +++ b/apps/customers/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "customers" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } + diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs new file mode 100644 index 0000000..b9ffc56 --- /dev/null +++ b/apps/customers/src/handlers.rs @@ -0,0 +1,259 @@ +uuse axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, patch, post}, + Json, Router, +}; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; +use db::models::customer::{CustomerRepository, UpsertCustomerProfilePayload}; +use db::models::requirement::{RequirementRepository, CreateRequirementPayload as DbCreateRequirementPayload, UpdateRequirementPayload as DbUpdateRequirementPayload}; +use db::models::lead_request::LeadRequestRepository; +use contracts::auth_middleware::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + .route("/requirements", get(list_requirements).post(create_requirement)) + .route("/requirements/:id", get(get_requirement).patch(update_requirement)) + .route("/requirements/:id/requests", get(list_requests)) + .route("/requirements/:id/requests/:lead_id/approve", post(approve_request)) + .route("/requirements/:id/requests/:lead_id/reject", post(reject_request)) +} + +#[derive(Deserialize)] +pub struct PaginationQuery { + pub page: Option, + pub limit: Option, +} + +#[derive(Deserialize)] +pub struct CreateRequirementRequest { + pub profession_key: String, + pub title: String, + pub description: String, + pub location: String, + pub budget: Option, + pub preferred_date: Option, + pub extra_data_json: Option, +} + +#[derive(Deserialize)] +pub struct RejectRequestPayload { + pub reason: Option, +} + +async fn get_profile( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match CustomerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Customer profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match CustomerRepository::upsert(&pool, auth.user_id, payload).await { + Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn list_requirements( + State(pool): State, + auth: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let customer = match CustomerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), + }; + + let page = q.page.unwrap_or(1); + let limit = q.limit.unwrap_or(20); + match RequirementRepository::list_by_customer_id(&pool, customer.id, page, limit).await { + Ok(reqs) => (StatusCode::OK, Json(serde_json::json!({ + "data": reqs, + "pagination": { "page": page, "limit": limit } + }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn create_requirement( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + let customer = match CustomerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), + }; + + if customer.active_requirement_count >= 2 { + return (StatusCode::TOO_MANY_REQUESTS, "Max 2 active requirements allowed").into_response(); + } + + let p_date = payload.preferred_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()); + + let db_payload = DbCreateRequirementPayload { + customer_id: customer.id, + profession_key: payload.profession_key, + title: payload.title, + description: payload.description, + location: payload.location, + budget: payload.budget, + preferred_date: p_date, + extra_data_json: payload.extra_data_json, + }; + + match RequirementRepository::create(&pool, db_payload).await { + Ok(req) => { + let _ = CustomerRepository::update_active_requirement_count(&pool, customer.id, 1).await; + (StatusCode::CREATED, Json(req)).into_response() + }, + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn get_requirement( + State(pool): State, + Path(id): Path, + _auth: AuthUser, +) -> impl IntoResponse { + match RequirementRepository::get_by_id(&pool, id).await { + Ok(Some(req)) => (StatusCode::OK, Json(req)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Requirement not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_requirement( + State(pool): State, + Path(id): Path, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + let customer = match CustomerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), + }; + + let req = match RequirementRepository::get_by_id(&pool, id).await { + Ok(Some(r)) if r.customer_id == customer.id => r, + Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), + }; + + match RequirementRepository::update(&pool, req.id, payload).await { + Ok(updated) => (StatusCode::OK, Json(updated)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn list_requests( + State(pool): State, + Path(id): Path, + auth: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let customer = match CustomerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), + }; + + let req = match RequirementRepository::get_by_id(&pool, id).await { + Ok(Some(r)) if r.customer_id == customer.id => r, + Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), + }; + + let page = q.page.unwrap_or(1); + let limit = q.limit.unwrap_or(20); + match LeadRequestRepository::list_by_requirement_id(&pool, req.id, page, limit).await { + Ok(leads) => (StatusCode::OK, Json(serde_json::json!({ + "data": leads, + "pagination": { "page": page, "limit": limit } + }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn approve_request( + State(pool): State, + Path((req_id, lead_id)): Path<(Uuid, Uuid)>, + auth: AuthUser, +) -> impl IntoResponse { + let customer = match CustomerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), + }; + + let req = match RequirementRepository::get_by_id(&pool, req_id).await { + Ok(Some(r)) if r.customer_id == customer.id => r, + Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), + }; + + let lead = match LeadRequestRepository::get_by_id(&pool, lead_id).await { + Ok(Some(l)) if l.requirement_id == req.id => l, + _ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(), + }; + + if lead.status != "PENDING" { + return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response(); + } + + match LeadRequestRepository::update_status(&pool, lead.id, "ACCEPTED").await { + Ok(updated) => { + let _ = RequirementRepository::increment_accepted_count(&pool, req.id).await; + // TODO: Reveal contact to professional + final Tracecoin deduction logic + (StatusCode::OK, Json(updated)).into_response() + }, + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn reject_request( + State(pool): State, + Path((req_id, lead_id)): Path<(Uuid, Uuid)>, + auth: AuthUser, + Json(_payload): Json, +) -> impl IntoResponse { + let customer = match CustomerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(c)) => c, + _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), + }; + + let req = match RequirementRepository::get_by_id(&pool, req_id).await { + Ok(Some(r)) if r.customer_id == customer.id => r, + Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), + }; + + let lead = match LeadRequestRepository::get_by_id(&pool, lead_id).await { + Ok(Some(l)) if l.requirement_id == req.id => l, + _ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(), + }; + + if lead.status != "PENDING" { + return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response(); + } + + match LeadRequestRepository::update_status(&pool, lead.id, "REJECTED").await { + Ok(updated) => { + // TODO: Return reserved Tracecoins to professional + (StatusCode::OK, Json(updated)).into_response() + }, + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + diff --git a/apps/customers/src/main.rs b/apps/customers/src/main.rs new file mode 100644 index 0000000..26a2f06 --- /dev/null +++ b/apps/customers/src/main.rs @@ -0,0 +1,47 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Customers Service OK" })) + .nest("/api/customers", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "8083".to_string()) + .parse() + .expect("PORT must be a number"); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + + tracing::info!("Customers service listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/developers/Cargo.toml b/apps/developers/Cargo.toml new file mode 100644 index 0000000..72ab2d0 --- /dev/null +++ b/apps/developers/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "developers" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } + diff --git a/apps/developers/src/handlers.rs b/apps/developers/src/handlers.rs new file mode 100644 index 0000000..aaf1bef --- /dev/null +++ b/apps/developers/src/handlers.rs @@ -0,0 +1,39 @@ +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use sqlx::PgPool; +use db::models::developer::{DeveloperRepository, UpsertDeveloperProfilePayload}; +use contracts::auth_middleware::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + .merge(contracts::profession_shared::shared_routes("DEVELOPER")) +} + +async fn get_profile( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match DeveloperRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match DeveloperRepository::upsert(&pool, auth.user_id, payload).await { + Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + diff --git a/apps/developers/src/main.rs b/apps/developers/src/main.rs new file mode 100644 index 0000000..6e536fb --- /dev/null +++ b/apps/developers/src/main.rs @@ -0,0 +1,42 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use contracts::profession_shared::shared_routes; + + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Developers Service OK" })) + .nest("/api/developers", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8088".to_string()).parse().unwrap(); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("Developers service listening on {}", addr); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/fitness_trainers/Cargo.toml b/apps/fitness_trainers/Cargo.toml new file mode 100644 index 0000000..308392a --- /dev/null +++ b/apps/fitness_trainers/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "fitness_trainers" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } + diff --git a/apps/fitness_trainers/src/handlers.rs b/apps/fitness_trainers/src/handlers.rs new file mode 100644 index 0000000..1850ecd --- /dev/null +++ b/apps/fitness_trainers/src/handlers.rs @@ -0,0 +1,38 @@ +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use sqlx::PgPool; +use db::models::fitness_trainer::{FitnessTrainerRepository, UpsertFitnessTrainerProfilePayload}; +use contracts::auth_middleware::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + .merge(contracts::profession_shared::shared_routes("FITNESS_TRAINER")) +} + +async fn get_profile( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match FitnessTrainerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match FitnessTrainerRepository::upsert(&pool, auth.user_id, payload).await { + Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/apps/fitness_trainers/src/main.rs b/apps/fitness_trainers/src/main.rs new file mode 100644 index 0000000..fae194a --- /dev/null +++ b/apps/fitness_trainers/src/main.rs @@ -0,0 +1,42 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use contracts::profession_shared::shared_routes; + + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Fitness Trainers Service OK" })) + .nest("/api/fitness-trainers", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8092".to_string()).parse().unwrap(); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("Fitness Trainers service listening on {}", addr); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/gateway/Cargo.toml b/apps/gateway/Cargo.toml new file mode 100644 index 0000000..cdb20ed --- /dev/null +++ b/apps/gateway/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "gateway" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tower-http = { version = "0.6", features = ["proxy", "cors"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +reqwest = { version = "0.12", features = ["json"] } +anyhow = { workspace = true } diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs new file mode 100644 index 0000000..eb2acd4 --- /dev/null +++ b/apps/gateway/src/main.rs @@ -0,0 +1,230 @@ +use axum::{ + body::Body, + extract::{Request, State}, + http::{StatusCode, Uri}, + response::IntoResponse, + routing::any, + Router, +}; +use std::net::SocketAddr; +use tower_http::cors::CorsLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[derive(Clone)] +struct Services { + users_url: String, + companies_url: String, + job_seekers_url: String, + customers_url: String, + // ── 9 separate profession services ──────────────────────────────────── + photographers_url: String, + makeup_artists_url: String, + tutors_url: String, + developers_url: String, + video_editors_url: String, + graphic_designers_url: String, + social_media_managers_url: String, + fitness_trainers_url: String, + catering_services_url: String, + // ── Payments ───────────────────────────────────────────────────────── + payments_url: String, + client: reqwest::Client, +} + +impl Services { + fn from_env() -> Self { + Self { + users_url: std::env::var("USERS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8080".to_string()), + companies_url: std::env::var("COMPANIES_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8081".to_string()), + job_seekers_url: std::env::var("JOB_SEEKERS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8082".to_string()), + customers_url: std::env::var("CUSTOMERS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8083".to_string()), + photographers_url: std::env::var("PHOTOGRAPHERS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8085".to_string()), + makeup_artists_url: std::env::var("MAKEUP_ARTISTS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8086".to_string()), + tutors_url: std::env::var("TUTORS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8087".to_string()), + developers_url: std::env::var("DEVELOPERS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8088".to_string()), + video_editors_url: std::env::var("VIDEO_EDITORS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8089".to_string()), + graphic_designers_url: std::env::var("GRAPHIC_DESIGNERS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8090".to_string()), + social_media_managers_url: std::env::var("SOCIAL_MEDIA_MANAGERS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8091".to_string()), + fitness_trainers_url: std::env::var("FITNESS_TRAINERS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8092".to_string()), + catering_services_url: std::env::var("CATERING_SERVICES_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8093".to_string()), + payments_url: std::env::var("PAYMENTS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8094".to_string()), + client: reqwest::Client::new(), + } + } + + + fn resolve_upstream(&self, path: &str) -> Option { + // Auth, users, roles, notifications, runtime-config, config, admin + if path.starts_with("/api/auth") + || path.starts_with("/api/me") + || path.starts_with("/api/runtime-config") + || path.starts_with("/api/config") + || path.starts_with("/api/admin/roles") + || path.starts_with("/api/admin/onboarding-config") + || path.starts_with("/api/admin/dashboard-config") + || path.starts_with("/api/admin/users") + || path.starts_with("/api/admin/employees") + || path.starts_with("/api/admin/departments") + || path.starts_with("/api/admin/designations") + || path.starts_with("/api/admin/approvals") + || path.starts_with("/api/onboarding") + { + Some(self.users_url.clone()) + } + // Companies + Jobs + Applications + Packages + else if path.starts_with("/api/companies") + || path.starts_with("/api/jobs") + || path.starts_with("/api/applications") + || path.starts_with("/api/pricing") + || path.starts_with("/api/admin/companies") + || path.starts_with("/api/admin/jobs") + { + Some(self.companies_url.clone()) + } + // Job Seekers + else if path.starts_with("/api/jobseeker") { + Some(self.job_seekers_url.clone()) + } + // Customers + Requirements + else if path.starts_with("/api/customers") + || path.starts_with("/api/admin/customers") + || path.starts_with("/api/admin/requirements") + { + Some(self.customers_url.clone()) + } + // ── 9 Separate Profession Services ──────────────────────────────── + else if path.starts_with("/api/photographers") { + Some(self.photographers_url.clone()) + } + else if path.starts_with("/api/makeup-artists") { + Some(self.makeup_artists_url.clone()) + } + else if path.starts_with("/api/tutors") { + Some(self.tutors_url.clone()) + } + else if path.starts_with("/api/developers") { + Some(self.developers_url.clone()) + } + else if path.starts_with("/api/video-editors") { + Some(self.video_editors_url.clone()) + } + else if path.starts_with("/api/graphic-designers") { + Some(self.graphic_designers_url.clone()) + } + else if path.starts_with("/api/social-media-managers") { + Some(self.social_media_managers_url.clone()) + } + else if path.starts_with("/api/fitness-trainers") { + Some(self.fitness_trainers_url.clone()) + } + else if path.starts_with("/api/catering-services") { + Some(self.catering_services_url.clone()) + } + // ── Payments + Invoices ─────────────────────────────────────────── + else if path.starts_with("/api/payments") + || path.starts_with("/api/admin/invoices") + || path.starts_with("/api/admin/credits") + || path.starts_with("/api/admin/revenue") + || path.starts_with("/api/admin/pricing") + { + Some(self.payments_url.clone()) + } + // Wallet / Tracecoins (shared across profession services — route to a credits service) + else if path.starts_with("/api/credits") { + Some(self.payments_url.clone()) + } + else { + None + } + } + +} + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let services = Services::from_env(); + + let app = Router::new() + .route("/api/*path", any(proxy_handler)) + .route("/health", any(|| async { "Gateway OK" })) + .layer(CorsLayer::permissive()) + .with_state(services); + + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "8000".to_string()) + .parse() + .expect("PORT must be a valid u16"); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + + tracing::info!("Gateway listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} + +async fn proxy_handler( + State(services): State, + req: Request, +) -> impl IntoResponse { + let path = req.uri().path().to_string(); + let query = req.uri().query().map(|q| format!("?{}", q)).unwrap_or_default(); + + let Some(upstream_base) = services.resolve_upstream(&path) else { + return (StatusCode::NOT_FOUND, "Route not found in gateway").into_response(); + }; + + let target_url = format!("{}{}{}", upstream_base, path, query); + let method = req.method().clone(); + let headers = req.headers().clone(); + let body = req.into_body(); + + match services + .client + .request(method, &target_url) + .headers(headers) + .body(reqwest::Body::wrap_stream(body.into_data_stream())) + .send() + .await + { + Ok(res) => { + let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let res_headers = res.headers().clone(); + let bytes = res.bytes().await.unwrap_or_default(); + let res_body = Body::from(bytes); + + let mut response = res_body.into_response(); + *response.status_mut() = status; + for (name, value) in res_headers { + if let Some(name) = name { + response.headers_mut().insert(name, value); + } + } + response + } + Err(e) => { + tracing::error!("Gateway proxy error → {}: {}", target_url, e); + (StatusCode::BAD_GATEWAY, format!("Gateway error: {}", e)).into_response() + } + } +} diff --git a/apps/graphic_designers/Cargo.toml b/apps/graphic_designers/Cargo.toml new file mode 100644 index 0000000..b92f0e2 --- /dev/null +++ b/apps/graphic_designers/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "graphic_designers" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } + diff --git a/apps/graphic_designers/src/handlers.rs b/apps/graphic_designers/src/handlers.rs new file mode 100644 index 0000000..3c50e4e --- /dev/null +++ b/apps/graphic_designers/src/handlers.rs @@ -0,0 +1,38 @@ +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{get, patch}, + Json, Router, +}; +use sqlx::PgPool; +use db::models::graphic_designer::{GraphicDesignerRepository, UpsertGraphicDesignerProfilePayload}; +use contracts::auth_middleware::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + .merge(contracts::profession_shared::shared_routes("GRAPHIC_DESIGNER")) +} + +async fn get_profile( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match GraphicDesignerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match GraphicDesignerRepository::upsert(&pool, auth.user_id, payload).await { + Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/apps/graphic_designers/src/main.rs b/apps/graphic_designers/src/main.rs new file mode 100644 index 0000000..e01bcfe --- /dev/null +++ b/apps/graphic_designers/src/main.rs @@ -0,0 +1,42 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use contracts::profession_shared::shared_routes; + + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Graphic Designers Service OK" })) + .nest("/api/graphic-designers", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8090".to_string()).parse().unwrap(); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("Graphic Designers service listening on {}", addr); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/job_seekers/Cargo.toml b/apps/job_seekers/Cargo.toml new file mode 100644 index 0000000..7c8b501 --- /dev/null +++ b/apps/job_seekers/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "job_seekers" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } + diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs new file mode 100644 index 0000000..5c7f8dc --- /dev/null +++ b/apps/job_seekers/src/handlers.rs @@ -0,0 +1,238 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; +use db::models::job_seeker::{JobSeekerRepository, UpsertJobSeekerProfilePayload}; +use db::models::job::JobRepository; +use db::models::application::{ApplicationRepository, CreateApplicationPayload}; +use contracts::auth_middleware::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + .route("/profile/resume", post(upload_resume)) + .route("/jobs", get(browse_jobs)) + .route("/jobs/:id", get(get_job)) + .route("/jobs/:id/apply", post(apply_to_job)) + .route("/applications", get(list_my_applications)) + .route("/applications/:id", get(get_my_application)) + .route("/applications/:id/withdraw", post(withdraw_application)) +} + +#[derive(Deserialize)] +pub struct JobBrowseQuery { + pub page: Option, + pub limit: Option, + pub location: Option, + pub job_type: Option, + pub search: Option, +} + +#[derive(Deserialize)] +pub struct ApplyRequest { + pub cover_letter: Option, + pub resume_url: Option, +} + +async fn get_profile( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match JobSeekerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match JobSeekerRepository::upsert(&pool, auth.user_id, payload).await { + Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn upload_resume( + State(_pool): State, + _auth: AuthUser, +) -> impl IntoResponse { + // TODO: multipart upload handler + (StatusCode::OK, Json(serde_json::json!({ "resume_url": null }))) +} + +async fn browse_jobs( + State(pool): State, + Query(q): Query, +) -> impl IntoResponse { + // Public feed of LIVE jobs + // Note: This logic should ideally be in JobRepository but for now it's simple listing + let page = q.page.unwrap_or(1); + let limit = q.limit.unwrap_or(20); + let offset = (page - 1) * limit; + + // Filter by LIVE status for public browse + let jobs = sqlx::query_as!( + db::models::job::Job, + r#" + SELECT * FROM jobs + WHERE status = 'LIVE' + AND ($1::VARCHAR IS NULL OR location ILIKE '%' || $1 || '%') + AND ($2::VARCHAR IS NULL OR job_type = $2) + AND ($3::VARCHAR IS NULL OR title ILIKE '%' || $3 || '%') + ORDER BY created_at DESC + LIMIT $4 OFFSET $5 + "#, + q.location, + q.job_type, + q.search, + limit, + offset + ) + .fetch_all(&pool) + .await; + + match jobs { + Ok(j) => (StatusCode::OK, Json(serde_json::json!({ + "data": j, + "pagination": { "page": page, "limit": limit } + }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn get_job( + State(pool): State, + Path(id): Path, +) -> impl IntoResponse { + match JobRepository::get_by_id(&pool, id).await { + Ok(Some(job)) if job.status == "LIVE" => (StatusCode::OK, Json(job)).into_response(), + Ok(Some(_)) => (StatusCode::FORBIDDEN, "Job is not live").into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Job not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn apply_to_job( + State(pool): State, + auth: AuthUser, + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + let seeker = match JobSeekerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(s)) => s, + _ => return (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(), + }; + + let job = match JobRepository::get_by_id(&pool, id).await { + Ok(Some(j)) if j.status == "LIVE" => j, + Ok(Some(_)) => return (StatusCode::BAD_REQUEST, "Job is not live").into_response(), + _ => return (StatusCode::NOT_FOUND, "Job not found").into_response(), + }; + + if seeker.active_application_count >= 50 { + return (StatusCode::TOO_MANY_REQUESTS, "Max 50 active applications").into_response(); + } + + let db_payload = CreateApplicationPayload { + job_id: job.id, + job_seeker_id: seeker.id, + cover_letter: payload.cover_letter, + resume_url: payload.resume_url.or(seeker.resume_url), + }; + + match ApplicationRepository::create(&pool, db_payload).await { + Ok(app) => { + let _ = JobSeekerRepository::update_active_application_count(&pool, seeker.id, 1).await; + (StatusCode::CREATED, Json(app)).into_response() + }, + Err(e) => { + if e.to_string().contains("unique") { + (StatusCode::CONFLICT, "Already applied to this job").into_response() + } else { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + } + } +} + +async fn list_my_applications( + State(pool): State, + auth: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let seeker = match JobSeekerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(s)) => s, + _ => return (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(), + }; + + let page = q.page.unwrap_or(1); + let limit = q.limit.unwrap_or(20); + + match ApplicationRepository::list_by_job_seeker_id(&pool, seeker.id, page, limit).await { + Ok(apps) => (StatusCode::OK, Json(serde_json::json!({ + "data": apps, + "pagination": { "page": page, "limit": limit } + }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn get_my_application( + State(pool): State, + auth: AuthUser, + Path(id): Path, +) -> impl IntoResponse { + let seeker = match JobSeekerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(s)) => s, + _ => return (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(), + }; + + match ApplicationRepository::get_by_id(&pool, id).await { + Ok(Some(app)) if app.job_seeker_id == seeker.id => (StatusCode::OK, Json(app)).into_response(), + Ok(Some(_)) => (StatusCode::FORBIDDEN, "Access denied").into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Application not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn withdraw_application( + State(pool): State, + auth: AuthUser, + Path(id): Path, +) -> impl IntoResponse { + let seeker = match JobSeekerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(s)) => s, + _ => return (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(), + }; + + let app = match ApplicationRepository::get_by_id(&pool, id).await { + Ok(Some(a)) if a.job_seeker_id == seeker.id => a, + Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + _ => return (StatusCode::NOT_FOUND, "Application not found").into_response(), + }; + + match ApplicationRepository::update_status(&pool, app.id, "WITHDRAWN").await { + Ok(updated) => { + let _ = JobSeekerRepository::update_active_application_count(&pool, seeker.id, -1).await; + (StatusCode::OK, Json(updated)).into_response() + }, + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +#[derive(Deserialize)] +pub struct PaginationQuery { + pub page: Option, + pub limit: Option, +} + diff --git a/apps/job_seekers/src/main.rs b/apps/job_seekers/src/main.rs new file mode 100644 index 0000000..99ef894 --- /dev/null +++ b/apps/job_seekers/src/main.rs @@ -0,0 +1,47 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Job Seekers Service OK" })) + .nest("/api/jobseeker", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "8082".to_string()) + .parse() + .expect("PORT must be a number"); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + + tracing::info!("Job Seekers service listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/makeup_artists/Cargo.toml b/apps/makeup_artists/Cargo.toml new file mode 100644 index 0000000..3629c3b --- /dev/null +++ b/apps/makeup_artists/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "makeup_artists" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } + diff --git a/apps/makeup_artists/src/handlers.rs b/apps/makeup_artists/src/handlers.rs new file mode 100644 index 0000000..3ffaa0d --- /dev/null +++ b/apps/makeup_artists/src/handlers.rs @@ -0,0 +1,39 @@ +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use sqlx::PgPool; +use db::models::makeup_artist::{MakeupArtistRepository, UpsertMakeupArtistProfilePayload}; +use contracts::auth_middleware::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + .merge(contracts::profession_shared::shared_routes("MAKEUP_ARTIST")) +} + +async fn get_profile( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match MakeupArtistRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match MakeupArtistRepository::upsert(&pool, auth.user_id, payload).await { + Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + diff --git a/apps/makeup_artists/src/main.rs b/apps/makeup_artists/src/main.rs new file mode 100644 index 0000000..2c5d4ee --- /dev/null +++ b/apps/makeup_artists/src/main.rs @@ -0,0 +1,42 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use contracts::profession_shared::shared_routes; + + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Makeup Artists Service OK" })) + .nest("/api/makeup-artists", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8086".to_string()).parse().unwrap(); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("Makeup Artists service listening on {}", addr); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/photographers/Cargo.toml b/apps/photographers/Cargo.toml new file mode 100644 index 0000000..08c2e29 --- /dev/null +++ b/apps/photographers/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "photographers" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } + diff --git a/apps/photographers/src/handlers.rs b/apps/photographers/src/handlers.rs new file mode 100644 index 0000000..85bb035 --- /dev/null +++ b/apps/photographers/src/handlers.rs @@ -0,0 +1,41 @@ +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use sqlx::PgPool; +use db::models::photographer::{PhotographerRepository, UpsertPhotographerProfilePayload}; +use db::models::professional::ProfessionalRepository; +use contracts::auth_middleware::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + // All shared routes (marketplace, leads, portfolio, services, wallet) + .merge(contracts::profession_shared::shared_routes("PHOTOGRAPHER")) +} + +async fn get_profile( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match PhotographerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match PhotographerRepository::upsert(&pool, auth.user_id, payload).await { + Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + diff --git a/apps/photographers/src/main.rs b/apps/photographers/src/main.rs new file mode 100644 index 0000000..851b4f1 --- /dev/null +++ b/apps/photographers/src/main.rs @@ -0,0 +1,45 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use contracts::profession_shared::shared_routes; + + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Photographers Service OK" })) + .nest("/api/photographers", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "8085".to_string()) + .parse() + .expect("PORT must be a number"); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("Photographers service listening on {}", addr); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/professionals/src/handlers.rs b/apps/professionals/src/handlers.rs new file mode 100644 index 0000000..fda72ef --- /dev/null +++ b/apps/professionals/src/handlers.rs @@ -0,0 +1,202 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, patch, post}, + Json, Router, +}; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; + +/// Shared professional router — all 9 profession apps mount this. +/// Each profession app wraps this under its own /api/ path. +pub fn router() -> Router { + Router::new() + // Professional profile + .route("/profile/me", get(get_profile).patch(update_profile)) + // Marketplace (requirements feed) + .route("/marketplace", get(browse_marketplace)) + .route("/marketplace/:id", get(get_requirement_detail)) + // Lead requests + .route("/leads/request", post(send_lead_request)) + .route("/leads/requests/me", get(my_requests)) + .route("/leads/requests/:id", delete(cancel_request)) + .route("/leads/accepted/me", get(accepted_leads)) + .route("/leads/accepted/:id", get(accepted_lead_detail)) + // Portfolio + .route("/portfolio/me", get(list_portfolio)) + .route("/portfolio", post(create_portfolio_item)) + .route("/portfolio/:id", patch(update_portfolio_item).delete(delete_portfolio_item)) + // Services + .route("/services/me", get(list_services)) + .route("/services", post(create_service)) + .route("/services/:id", patch(update_service).delete(delete_service)) + // Wallet + .route("/wallet/balance", get(wallet_balance)) + .route("/wallet/ledger", get(wallet_ledger)) + .route("/wallet/invoices", get(wallet_invoices)) + .route("/wallet/invoices/:id", get(wallet_invoice_detail)) +} + +#[derive(Deserialize)] +pub struct PaginationQuery { + pub page: Option, + pub limit: Option, + pub profession_key: Option, +} + +#[derive(Deserialize)] +pub struct LeadRequestPayload { + pub requirement_id: String, +} + +#[derive(Deserialize)] +pub struct CreatePortfolioPayload { + pub title: String, + pub description: Option, + pub tags: Option>, +} + +#[derive(Deserialize)] +pub struct CreateServicePayload { + pub name: String, + pub description: Option, + pub price: i32, // in paise + pub duration_minutes: Option, +} + +async fn get_profile(State(pool): State) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "id": null, "display_name": null, "status": "ACTIVE" }))) +} + +async fn update_profile(State(pool): State, Json(_p): Json) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "message": "Profile updated" }))) +} + +async fn browse_marketplace( + State(pool): State, + Query(q): Query, +) -> impl IntoResponse { + let _ = pool; + // Returns OPEN + non-expired requirements for this profession_key + (StatusCode::OK, Json(serde_json::json!({ + "data": [], + "pagination": { "page": q.page.unwrap_or(1), "limit": q.limit.unwrap_or(20), "total": 0 } + }))) +} + +async fn get_requirement_detail( + State(pool): State, + Path(id): Path, +) -> impl IntoResponse { + let _ = pool; + // Customer contact details NOT included — only revealed after acceptance + (StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "status": "OPEN" }))) +} + +async fn send_lead_request( + State(pool): State, + Json(payload): Json, +) -> impl IntoResponse { + let _ = pool; + // Server enforces: 25 Tracecoins reserved, requirement OPEN, max 20 requests, no duplicate + let req_id = payload.requirement_id.clone(); + (StatusCode::CREATED, Json(serde_json::json!({ + "id": Uuid::new_v4().to_string(), + "requirement_id": req_id, + "status": "PENDING", + "tracecoins_reserved": 25, + "expires_at": (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339() + }))) +} + +async fn my_requests(State(pool): State, Query(q): Query) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "data": [], "pagination": { "page": q.page.unwrap_or(1), "limit": 20 } }))) +} + +async fn cancel_request(State(pool): State, Path(id): Path) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "message": "Request cancelled" }))) +} + +async fn accepted_leads(State(pool): State, Query(q): Query) -> impl IntoResponse { + let _ = pool; + // Returns leads where status = ACCEPTED — includes customer contact info + (StatusCode::OK, Json(serde_json::json!({ "data": [], "pagination": { "page": q.page.unwrap_or(1), "limit": 20 } }))) +} + +async fn accepted_lead_detail(State(pool): State, Path(id): Path) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() }))) +} + +async fn list_portfolio(State(pool): State) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "data": [] }))) +} + +async fn create_portfolio_item( + State(pool): State, + Json(p): Json, +) -> impl IntoResponse { + let _ = pool; + (StatusCode::CREATED, Json(serde_json::json!({ "id": Uuid::new_v4().to_string(), "title": p.title }))) +} + +async fn update_portfolio_item(State(pool): State, Path(id): Path, Json(_p): Json) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "message": "Updated" }))) +} + +async fn delete_portfolio_item(State(pool): State, Path(id): Path) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "message": "Deleted" }))) +} + +async fn list_services(State(pool): State) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "data": [] }))) +} + +async fn create_service( + State(pool): State, + Json(p): Json, +) -> impl IntoResponse { + let _ = pool; + (StatusCode::CREATED, Json(serde_json::json!({ "id": Uuid::new_v4().to_string(), "name": p.name }))) +} + +async fn update_service(State(pool): State, Path(id): Path, Json(_p): Json) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "message": "Updated" }))) +} + +async fn delete_service(State(pool): State, Path(id): Path) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "message": "Deleted" }))) +} + +async fn wallet_balance(State(pool): State) -> impl IntoResponse { + let _ = pool; + // Read-only — no write on client + (StatusCode::OK, Json(serde_json::json!({ "balance": 0, "reserved": 0, "available": 0 }))) +} + +async fn wallet_ledger(State(pool): State, Query(q): Query) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "data": [], "pagination": { "page": q.page.unwrap_or(1), "limit": 20 } }))) +} + +async fn wallet_invoices(State(pool): State, Query(q): Query) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "data": [], "pagination": { "page": q.page.unwrap_or(1), "limit": 20 } }))) +} + +async fn wallet_invoice_detail(State(pool): State, Path(id): Path) -> impl IntoResponse { + let _ = pool; + (StatusCode::OK, Json(serde_json::json!({ "id": id.to_string() }))) +} diff --git a/apps/professionals/src/main.rs b/apps/professionals/src/main.rs new file mode 100644 index 0000000..bef2e8f --- /dev/null +++ b/apps/professionals/src/main.rs @@ -0,0 +1,50 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + tracing::info!("Professionals service — connected to database"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Professionals Service OK" })) + // All 9 profession types share the same router under /api/professionals + .nest("/api/professionals", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "8084".to_string()) + .parse() + .expect("PORT must be a number"); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + + tracing::info!("Professionals service listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/social_media_managers/Cargo.toml b/apps/social_media_managers/Cargo.toml new file mode 100644 index 0000000..85c7e85 --- /dev/null +++ b/apps/social_media_managers/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "social_media_managers" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } + diff --git a/apps/social_media_managers/src/handlers.rs b/apps/social_media_managers/src/handlers.rs new file mode 100644 index 0000000..062d3f3 --- /dev/null +++ b/apps/social_media_managers/src/handlers.rs @@ -0,0 +1,38 @@ +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{get, patch}, // Re-adding patch here as it's used in the router + Json, Router, +}; +use sqlx::PgPool; +use db::models::social_media_manager::{SocialMediaManagerRepository, UpsertSocialMediaManagerProfilePayload}; +use contracts::auth_middleware::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + .merge(contracts::profession_shared::shared_routes("SOCIAL_MEDIA_MANAGER")) +} + +async fn get_profile( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match SocialMediaManagerRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match SocialMediaManagerRepository::upsert(&pool, auth.user_id, payload).await { + Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/apps/social_media_managers/src/main.rs b/apps/social_media_managers/src/main.rs new file mode 100644 index 0000000..0f8b5c6 --- /dev/null +++ b/apps/social_media_managers/src/main.rs @@ -0,0 +1,42 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use contracts::profession_shared::shared_routes; + + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Social Media Managers Service OK" })) + .nest("/api/social-media-managers", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8091".to_string()).parse().unwrap(); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("Social Media Managers service listening on {}", addr); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/tutors/Cargo.toml b/apps/tutors/Cargo.toml new file mode 100644 index 0000000..192dd83 --- /dev/null +++ b/apps/tutors/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "tutors" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } + diff --git a/apps/tutors/src/handlers.rs b/apps/tutors/src/handlers.rs new file mode 100644 index 0000000..5e3f110 --- /dev/null +++ b/apps/tutors/src/handlers.rs @@ -0,0 +1,40 @@ +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use sqlx::PgPool; +use db::models::tutor::{TutorRepository, UpsertTutorProfilePayload}; +use contracts::auth_middleware::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + .merge(contracts::profession_shared::shared_routes("TUTOR")) +} + + +async fn get_profile( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match TutorRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match TutorRepository::upsert(&pool, auth.user_id, payload).await { + Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + diff --git a/apps/tutors/src/main.rs b/apps/tutors/src/main.rs new file mode 100644 index 0000000..3a91a0c --- /dev/null +++ b/apps/tutors/src/main.rs @@ -0,0 +1,42 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use contracts::profession_shared::shared_routes; + + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Tutors Service OK" })) + .nest("/api/tutors", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8087".to_string()).parse().unwrap(); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("Tutors service listening on {}", addr); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/users/Cargo.toml b/apps/users/Cargo.toml new file mode 100644 index 0000000..dbeaade --- /dev/null +++ b/apps/users/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "users" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +sqlx = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +lettre = { workspace = true } +contracts = { path = "../../crates/contracts" } + + diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs new file mode 100644 index 0000000..2e1f4bb --- /dev/null +++ b/apps/users/src/handlers/auth.rs @@ -0,0 +1,458 @@ +use auth::{ + crypto::{hash_password, verify_password}, + jwt::generate_tokens, +}; +use ax_um_state_alias::AppState; // I'll use crate::AppState +use axum::{ + extract::State, + http::{header::SET_COOKIE, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use chrono::{Duration, Utc}; +use db::models::user::{CreateUserPayload, UserRepository}; +use serde::{Deserialize, Serialize}; +use contracts::auth_middleware::AuthUser; +use crate::AppState; + +pub fn router() -> Router { + Router::new() + .route("/register", post(register)) + .route("/login", post(login)) + .route("/logout", post(logout)) + .route("/refresh", post(refresh)) + .route("/session", get(session)) + .route("/verify-email", post(verify_email)) + .route("/resend-otp", post(resend_otp)) + .route("/forgot-password", post(forgot_password)) + .route("/reset-password", post(reset_password)) + .route("/change-password", post(change_password)) +} + + +// ── DTOs ────────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct RegisterPayload { + pub full_name: String, + pub email: String, + pub phone: String, + pub password: String, +} + +#[derive(Deserialize)] +pub struct LoginPayload { + pub email: String, + pub password: String, +} + +#[derive(Deserialize)] +pub struct VerifyEmailPayload { + pub otp: String, +} + +#[derive(Deserialize)] +pub struct ForgotPasswordPayload { + pub email: String, +} + +#[derive(Deserialize)] +pub struct ResetPasswordPayload { + pub token: String, + pub new_password: String, +} + +#[derive(Deserialize)] +pub struct ChangePasswordPayload { + pub current_password: String, + pub new_password: String, +} + +#[derive(Serialize)] +pub struct RegisterResponse { + pub user_id: String, + pub email: String, + pub phone: String, + pub full_name: String, + pub status: String, + pub email_verified: bool, + pub created_at: String, +} + +#[derive(Serialize)] +pub struct LoginResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, + pub user: SessionUser, +} + +#[derive(Serialize)] +pub struct SessionUser { + pub id: String, + pub email: String, + pub full_name: String, + pub email_verified: bool, + pub roles: Vec, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, + pub code: String, + #[serde(rename = "statusCode")] + pub status_code: u16, +} + +fn err(status: StatusCode, msg: &str, code: &str) -> (StatusCode, Json) { + ( + status, + Json(ErrorResponse { + error: msg.to_string(), + code: code.to_string(), + status_code: status.as_u16(), + }), + ) +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +async fn register( + State(state): State, + Json(payload): Json, +) -> Result)> { + // Basic validation + if payload.password.len() < 8 { + return Err(err( + StatusCode::UNPROCESSABLE_ENTITY, + "Password minimum 8 characters", + "VALIDATION_ERROR", + )); + } + + let password_hash = hash_password(&payload.password).map_err(|e| { + err( + StatusCode::INTERNAL_SERVER_ERROR, + &e.to_string(), + "INTERNAL_ERROR", + ) + })?; + + let user = UserRepository::create( + &state.pool, + CreateUserPayload { + full_name: payload.full_name, + email: payload.email.to_lowercase(), + phone: payload.phone, + password_hash, + }, + ) + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("users_email_key") || msg.contains("email") && msg.contains("unique") { + err(StatusCode::CONFLICT, "Email already registered", "EMAIL_EXISTS") + } else if msg.contains("users_phone_key") || msg.contains("phone") && msg.contains("unique") { + err(StatusCode::CONFLICT, "Phone already registered", "PHONE_EXISTS") + } else { + err(StatusCode::INTERNAL_SERVER_ERROR, &msg, "DB_ERROR") + } + })?; + + // Generate and send email OTP for verification + let otp = format!("{:06}", rand::random::() % 1000000); + let expires_at = Utc::now() + Duration::minutes(15); + + UserRepository::set_email_verification_token(&state.pool, user.id, &otp, expires_at) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; + + let _ = state.mail.send_verification_email(&user.email, &user.full_name.unwrap_or_default(), &otp).await; + + Ok(( + StatusCode::CREATED, + Json(RegisterResponse { + user_id: user.id.to_string(), + email: user.email, + phone: user.phone.unwrap_or_default(), + full_name: user.full_name.unwrap_or_default(), + status: user.status, + email_verified: user.email_verified, + created_at: user.created_at.to_rfc3339(), + }), + )) +} + +async fn login( + State(state): State, + Json(payload): Json, +) -> Result)> { + let user = UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()) + .await + .map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid credentials", "INVALID_CREDENTIALS"))?; + + // Check account status + if user.status == "SUSPENDED" { + return Err(err(StatusCode::FORBIDDEN, "Account suspended", "ACCOUNT_SUSPENDED")); + } + + // Email verification check + if !user.email_verified { + return Err(err(StatusCode::UNAUTHORIZED, "Email not verified", "EMAIL_NOT_VERIFIED")); + } + + let is_valid = verify_password(&payload.password, &user.password_hash).map_err(|e| { + err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR") + })?; + + if !is_valid { + return Err(err(StatusCode::UNAUTHORIZED, "Invalid credentials", "INVALID_CREDENTIALS")); + } + + // Fetch user's active roles + let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id) + .await + .unwrap_or_default(); + + let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "changeme".to_string()); + let tokens = generate_tokens(user.id.to_string(), user_roles.first().cloned(), &jwt_secret) + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?; + + UserRepository::store_refresh_token( + &state.pool, + user.id, + &tokens.refresh_token, + Utc::now() + Duration::days(30), + ) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; + + // Set refresh token as httpOnly cookie + let cookie = format!( + "nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000", + tokens.refresh_token + ); + + let response = Json(LoginResponse { + access_token: tokens.access_token, + token_type: "Bearer".to_string(), + expires_in: 900, + user: SessionUser { + id: user.id.to_string(), + email: user.email, + full_name: user.full_name.unwrap_or_default(), + email_verified: user.email_verified, + roles: user_roles, + }, + }); + + Ok(( + StatusCode::OK, + [(SET_COOKIE, cookie)], + response, + )) +} + +async fn logout( + State(state): State, + // In real implementation: extract refresh token from cookie header +) -> impl IntoResponse { + // TODO: Revoke refresh token from cookie + let _ = &state.pool; + (StatusCode::OK, Json(serde_json::json!({ "message": "Logged out successfully" }))) +} + +async fn refresh( + State(state): State, + // In real impl: read httpOnly cookie, not body + Json(payload): Json, +) -> Result)> { + let token = payload["refresh_token"] + .as_str() + .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token missing", "REFRESH_TOKEN_INVALID"))?; + + let rt = UserRepository::get_valid_refresh_token(&state.pool, token) + .await + .map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token invalid", "REFRESH_TOKEN_INVALID"))?; + + let user = UserRepository::get_by_id(&state.pool, rt.user_id) + .await + .map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "INVALID_CREDENTIALS"))?; + + let _ = UserRepository::revoke_refresh_token(&state.pool, token).await; + + let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id) + .await + .unwrap_or_default(); + + let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "changeme".to_string()); + let tokens = generate_tokens(user.id.to_string(), user_roles.first().cloned(), &jwt_secret) + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?; + + UserRepository::store_refresh_token( + &state.pool, + user.id, + &tokens.refresh_token, + Utc::now() + Duration::days(30), + ) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "access_token": tokens.access_token, + "expires_in": 900 + })), + )) +} + + +async fn session( + auth: AuthUser, + State(state): State, +) -> Result)> { + let user = UserRepository::get_by_id(&state.pool, auth.user_id) + .await + .map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "USER_NOT_FOUND"))?; + + let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id) + .await + .unwrap_or_default(); + + Ok(Json(SessionUser { + id: user.id.to_string(), + email: user.email, + full_name: user.full_name.unwrap_or_default(), + email_verified: user.email_verified, + roles: user_roles, + })) +} + +async fn verify_email( + State(state): State, + Json(payload): Json, +) -> Result)> { + let user = UserRepository::get_by_verification_token(&state.pool, &payload.otp) + .await + .map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid verification code", "INVALID_CODE"))?; + + if let Some(expires_at) = user.email_verification_expires_at { + if expires_at < Utc::now() { + return Err(err(StatusCode::UNAUTHORIZED, "Verification code expired", "CODE_EXPIRED")); + } + } + + UserRepository::set_email_verified(&state.pool, user.id) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; + + Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Email verified successfully" })))) +} + +#[derive(Deserialize)] +pub struct ResendOtpPayload { + pub email: String, +} + +async fn resend_otp( + State(state): State, + Json(payload): Json, +) -> Result)> { + let user = UserRepository::get_by_email(&state.pool, &payload.email) + .await + .map_err(|_| (StatusCode::OK, Json(serde_json::json!({ "message": "If email exists, a new OTP has been sent" }))))?; + + let otp = format!("{:06}", rand::random::() % 1000000); + let expires_at = Utc::now() + Duration::minutes(15); + + UserRepository::set_email_verification_token(&state.pool, user.id, &otp, expires_at) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; + + let _ = state.mail.send_verification_email(&user.email, &user.full_name.unwrap_or_default(), &otp).await; + + Ok((StatusCode::OK, Json(serde_json::json!({ "message": "If email exists, a new OTP has been sent" })))) +} + +async fn forgot_password( + State(state): State, + Json(payload): Json, +) -> Result)> { + let user = UserRepository::get_by_email(&state.pool, &payload.email) + .await + .map_err(|_| (StatusCode::OK, Json(serde_json::json!({ "message": "Reset link sent if email exists" }))))?; + + let token: String = uuid::Uuid::new_v4().to_string(); + let expires_at = Utc::now() + Duration::hours(1); + + UserRepository::set_reset_token(&state.pool, user.id, &token, expires_at) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; + + let _ = state.mail.send_password_reset_email(&user.email, &user.full_name.unwrap_or_default(), &token).await; + + Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Reset link sent if email exists" })))) +} + +async fn reset_password( + State(state): State, + Json(payload): Json, +) -> Result)> { + let user = UserRepository::get_by_reset_token(&state.pool, &payload.token) + .await + .map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid or expired reset token", "INVALID_TOKEN"))?; + + if let Some(expires_at) = user.reset_password_expires_at { + if expires_at < Utc::now() { + return Err(err(StatusCode::UNAUTHORIZED, "Reset token expired", "TOKEN_EXPIRED")); + } + } + + if payload.new_password.len() < 8 { + return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password minimum 8 characters", "VALIDATION_ERROR")); + } + + let password_hash = hash_password(&payload.new_password).map_err(|e| { + err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR") + })?; + + UserRepository::update_password(&state.pool, user.id, &password_hash) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; + + UserRepository::clear_reset_token(&state.pool, user.id) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; + + Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" })))) +} + +async fn change_password( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> Result)> { + let user = UserRepository::get_by_id(&state.pool, auth.user_id) + .await + .map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "USER_NOT_FOUND"))?; + + if !verify_password(&payload.current_password, &user.password_hash).map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "AUTH_ERROR"))? { + return Err(err(StatusCode::UNAUTHORIZED, "Incorrect current password", "INVALID_PASSWORD")); + } + + if payload.new_password.len() < 8 { + return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password minimum 8 characters", "VALIDATION_ERROR")); + } + + let password_hash = hash_password(&payload.new_password).map_err(|e| { + err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR") + })?; + + UserRepository::update_password(&state.pool, user.id, &password_hash) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; + + Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password changed successfully" })))) +} diff --git a/apps/users/src/handlers/config.rs b/apps/users/src/handlers/config.rs new file mode 100644 index 0000000..bfc266a --- /dev/null +++ b/apps/users/src/handlers/config.rs @@ -0,0 +1,215 @@ +use crate::AppState; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use db::models::config::{ + ConfigRepository, CreateDashboardConfigPayload, CreateOnboardingConfigPayload, + CreateRuntimeConfigPayload, +}; +use serde::Deserialize; +use uuid::Uuid; + +pub fn onboarding_router() -> Router { + Router::new() + .route("/", get(list_onboarding_configs).post(create_onboarding_config)) + .route("/:role_id", get(get_active_onboarding_config)) + .route("/by-key/:role_key", get(get_onboarding_config_by_key)) +} + +pub fn dashboard_router() -> Router { + Router::new() + .route("/", get(list_dashboard_configs).post(create_dashboard_config)) + .route("/:role_id", get(get_active_dashboard_config)) + .route("/by-key/:role_key", get(get_dashboard_config_by_key)) +} + +pub fn runtime_router() -> Router { + Router::new() + .route("/", get(get_my_runtime_config).post(create_runtime_config)) + .route("/:role_id", get(get_active_runtime_config)) +} + +async fn get_my_runtime_config( + auth: contracts::auth_middleware::AuthUser, + State(state): State, +) -> Result { + // 1. Get user's active role from the token + // 2. Fetch runtime config for that role + // If no active role, maybe default to a "guest" or just return error + + let role_key = auth.active_role.clone().unwrap_or_else(|| "CUSTOMER".to_string()); + + match ConfigRepository::get_active_runtime_by_role_key(&state.pool, &role_key).await { + Ok(config) => Ok((StatusCode::OK, Json(config))), + Err(sqlx::Error::RowNotFound) => Err(( + StatusCode::NOT_FOUND, + format!("No runtime config found for role {}", role_key), + )), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + + + +async fn create_onboarding_config( + State(state): State, + Json(payload): Json, +) -> Result { + match ConfigRepository::create_onboarding_config(&state.pool, payload).await { + Ok(config) => Ok((StatusCode::CREATED, Json(config))), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + +async fn get_active_onboarding_config( + State(state): State, + Path(role_id): Path, +) -> Result { + match ConfigRepository::get_active_onboarding_config(&state.pool, role_id).await { + Ok(config) => Ok((StatusCode::OK, Json(config))), + Err(sqlx::Error::RowNotFound) => Err(( + StatusCode::NOT_FOUND, + "Active onboarding config not found".to_string(), + )), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + +async fn list_onboarding_configs( + State(state): State, +) -> Result { + match ConfigRepository::get_all_onboarding_configs(&state.pool).await { + Ok(configs) => Ok((StatusCode::OK, Json(configs))), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + +async fn create_dashboard_config( + State(state): State, + Json(payload): Json, +) -> Result { + match ConfigRepository::create_dashboard_config(&state.pool, payload).await { + Ok(config) => Ok((StatusCode::CREATED, Json(config))), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + +#[derive(Deserialize)] +struct DashboardQuery { + audience: String, +} + +async fn get_active_dashboard_config( + State(state): State, + Path(role_id): Path, + Query(query): Query, +) -> Result { + match ConfigRepository::get_active_dashboard_config(&state.pool, role_id, &query.audience).await { + Ok(config) => Ok((StatusCode::OK, Json(config))), + Err(sqlx::Error::RowNotFound) => Err(( + StatusCode::NOT_FOUND, + "Active dashboard config not found".to_string(), + )), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + +async fn list_dashboard_configs( + State(state): State, +) -> Result { + match ConfigRepository::get_all_dashboard_configs(&state.pool).await { + Ok(configs) => Ok((StatusCode::OK, Json(configs))), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + +async fn create_runtime_config( + State(state): State, + Json(payload): Json, +) -> Result { + match ConfigRepository::create_runtime_config(&state.pool, payload).await { + Ok(config) => Ok((StatusCode::CREATED, Json(config))), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + +async fn get_active_runtime_config( + State(state): State, + Path(role_id): Path, +) -> Result { + match ConfigRepository::get_active_runtime_config(&state.pool, role_id).await { + Ok(config) => Ok((StatusCode::OK, Json(config))), + Err(sqlx::Error::RowNotFound) => Err(( + StatusCode::NOT_FOUND, + "Active runtime config not found".to_string(), + )), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + +async fn get_onboarding_config_by_key( + State(state): State, + Path(role_key): Path, +) -> Result { + match ConfigRepository::get_active_onboarding_by_role_key(&state.pool, &role_key).await { + Ok(config) => Ok((StatusCode::OK, Json(config))), + Err(sqlx::Error::RowNotFound) => Err(( + StatusCode::NOT_FOUND, + format!("Active onboarding config for role '{}' not found", role_key), + )), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + +async fn get_dashboard_config_by_key( + State(state): State, + Path(role_key): Path, + Query(query): Query, +) -> Result { + match ConfigRepository::get_active_dashboard_by_role_key(&state.pool, &role_key, &query.audience).await { + Ok(config) => Ok((StatusCode::OK, Json(config))), + Err(sqlx::Error::RowNotFound) => Err(( + StatusCode::NOT_FOUND, + format!("Active dashboard config for role '{}' not found", role_key), + )), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs new file mode 100644 index 0000000..8ac50e0 --- /dev/null +++ b/apps/users/src/handlers/mod.rs @@ -0,0 +1,3 @@ +pub mod config; +pub mod roles; +pub mod auth; diff --git a/apps/users/src/handlers/notifications.rs b/apps/users/src/handlers/notifications.rs new file mode 100644 index 0000000..3a46dbf --- /dev/null +++ b/apps/users/src/handlers/notifications.rs @@ -0,0 +1,85 @@ +use crate::AppState; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, patch}, + Json, Router, +}; +use serde::Serialize; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/", get(list_notifications)) + .route("/unread-count", get(unread_count)) + .route("/:id/read", patch(mark_read)) + .route("/read-all", patch(mark_all_read)) +} + +#[derive(Serialize)] +pub struct NotificationDto { + pub id: String, + pub title: String, + pub body: Option, + #[serde(rename = "type")] + pub notification_type: Option, + pub is_read: bool, + pub created_at: String, +} + +#[derive(Serialize)] +pub struct PaginatedResponse { + pub data: Vec, + pub pagination: Pagination, +} + +#[derive(Serialize)] +pub struct Pagination { + pub page: i64, + pub limit: i64, + pub total: i64, + pub total_pages: i64, +} + +// TODO: Replace with real JWT extractor middleware +// For now this handler is a placeholder that shows the expected shape. + +async fn list_notifications( + State(state): State, + // TODO: axum::extract::Query for page/limit + // TODO: JWT middleware to get user_id +) -> impl IntoResponse { + let _ = state; + ( + StatusCode::OK, + Json(PaginatedResponse:: { + data: vec![], + pagination: Pagination { + page: 1, + limit: 20, + total: 0, + total_pages: 0, + }, + }), + ) +} + +async fn unread_count(State(state): State) -> impl IntoResponse { + let _ = state; + (StatusCode::OK, Json(serde_json::json!({ "unread_count": 0 }))) +} + +async fn mark_read( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + let _ = state; + (StatusCode::OK, Json(serde_json::json!({ "id": id.to_string(), "is_read": true }))) +} + +async fn mark_all_read(State(state): State) -> impl IntoResponse { + let _ = state; + (StatusCode::OK, Json(serde_json::json!({ "message": "All notifications marked as read" }))) +} + diff --git a/apps/users/src/handlers/roles.rs b/apps/users/src/handlers/roles.rs new file mode 100644 index 0000000..95524d9 --- /dev/null +++ b/apps/users/src/handlers/roles.rs @@ -0,0 +1,55 @@ +use crate::AppState; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use db::models::role::{CreateRolePayload, RoleRepository}; + +pub fn router() -> Router { + Router::new() + .route("/", get(list_roles).post(create_role)) + .route("/:key", get(get_role_by_key)) +} + +async fn create_role( + State(state): State, + Json(payload): Json, +) -> Result { + match RoleRepository::create(&state.pool, payload).await { + Ok(role) => Ok((StatusCode::CREATED, Json(role))), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + +async fn list_roles( + State(state): State, +) -> Result { + match RoleRepository::get_all(&state.pool).await { + Ok(roles) => Ok((StatusCode::OK, Json(roles))), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + +async fn get_role_by_key( + State(state): State, + Path(key): Path, +) -> Result { + match RoleRepository::get_by_key(&state.pool, &key).await { + Ok(role) => Ok((StatusCode::OK, Json(role))), + Err(sqlx::Error::RowNotFound) => Err((StatusCode::NOT_FOUND, "Role not found".to_string())), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )), + } +} + diff --git a/apps/users/src/mail.rs b/apps/users/src/mail.rs new file mode 100644 index 0000000..9d38e11 --- /dev/null +++ b/apps/users/src/mail.rs @@ -0,0 +1,74 @@ +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Message, AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; +use anyhow::Result; +use std::env; + +pub struct Mailer { + transport: AsyncSmtpTransport, + from_email: String, + from_name: String, +} + +impl Mailer { + pub fn new() -> Self { + let smtp_host = env::var("SMTP_HOST").expect("SMTP_HOST must be set"); + let smtp_port = env::var("SMTP_PORT") + .expect("SMTP_PORT must be set") + .parse::() + .expect("SMTP_PORT must be a number"); + let smtp_user = env::var("SMTP_USER").expect("SMTP_USER must be set"); + let smtp_pass = env::var("SMTP_PASS").expect("SMTP_PASS must be set"); + + let from_email = env::var("SMTP_FROM_EMAIL").expect("SMTP_FROM_EMAIL must be set"); + let from_name = env::var("SMTP_FROM_NAME").expect("SMTP_FROM_NAME must be set"); + + let credentials = Credentials::new(smtp_user, smtp_pass); + + let transport = AsyncSmtpTransport::::starttls_relay(&smtp_host) + .expect("Failed to create SMTP transport") + .port(smtp_port) + .credentials(credentials) + .build(); + + Self { + transport, + from_email, + from_name, + } + } + + pub async fn send_verification_email(&self, to_email: &str, full_name: &str, otp: &str) -> Result<()> { + let body = format!( + "Hello {},\n\nYour verification code for NXTGAUGE is: {}\n\nThis code expires in 15 minutes.\n\nRegards,\nThe NXTGAUGE Team", + full_name, otp + ); + + let email = Message::builder() + .from(format!("{} <{}>", self.from_name, self.from_email).parse()?) + .to(to_email.parse()?) + .subject("Verify your NXTGAUGE account") + .body(body)?; + + self.transport.send(email).await?; + Ok(()) + } + + pub async fn send_password_reset_email(&self, to_email: &str, full_name: &str, token: &str) -> Result<()> { + let frontend_url = env::var("FRONTEND_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); + let reset_link = format!("{}/reset-password?token={}", frontend_url, token); + + let body = format!( + "Hello {},\n\nYou requested a password reset. Click the link below to reset your password:\n\n{}\n\nIf you did not request this, please ignore this email.\n\nRegards,\nThe NXTGAUGE Team", + full_name, reset_link + ); + + let email = Message::builder() + .from(format!("{} <{}>", self.from_name, self.from_email).parse()?) + .to(to_email.parse()?) + .subject("Reset your NXTGAUGE password") + .body(body)?; + + self.transport.send(email).await?; + Ok(()) + } +} diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs new file mode 100644 index 0000000..1080e8d --- /dev/null +++ b/apps/users/src/main.rs @@ -0,0 +1,76 @@ +mod handlers; +mod mail; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use sqlx::PgPool; +use mail::Mailer; + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, + pub mail: Arc, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + + let pool = db::establish_connection(&database_url) + .await + .expect("Failed to connect to the database"); + + tracing::info!("Connected to the database"); + + let mailer = Arc::new(Mailer::new()); + + let state = AppState { + pool, + mail: mailer, + }; + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + // ── Auth ───────────────────────────────────────────────────────── + .nest("/api/auth", handlers::auth::router()) + // ── Roles & User Self-Service ───────────────────────────────────── + .nest("/api/admin/roles", handlers::roles::router()) + // ── Notifications ───────────────────────────────────────────────── + .nest("/api/me/notifications", handlers::notifications::router()) + // ── Admin: Onboarding + Dashboard Config ────────────────────────── + .nest("/api/admin/onboarding-config", handlers::config::onboarding_router()) + .nest("/api/admin/dashboard-config", handlers::config::dashboard_router()) + // ── Public Config ───────────────────────────────────────────────── + .nest("/api/config/onboarding", handlers::config::onboarding_router()) + .nest("/api/config/dashboard", handlers::config::dashboard_router()) + .nest("/api/runtime-config", handlers::config::runtime_router()) + .layer(cors) + .with_state(state); + + + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "8080".to_string()) + .parse() + .expect("PORT must be a valid u16"); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + + tracing::info!("Users service listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/video_editors/Cargo.toml b/apps/video_editors/Cargo.toml new file mode 100644 index 0000000..7549711 --- /dev/null +++ b/apps/video_editors/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "video_editors" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } + diff --git a/apps/video_editors/src/handlers.rs b/apps/video_editors/src/handlers.rs new file mode 100644 index 0000000..61b160b --- /dev/null +++ b/apps/video_editors/src/handlers.rs @@ -0,0 +1,32 @@ +use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::{get, patch}, Json, Router}; +use sqlx::PgPool; +use db::models::video_editor::{VideoEditorRepository, UpsertVideoEditorProfilePayload}; +use contracts::auth_middleware::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + .merge(contracts::profession_shared::shared_routes("VIDEO_EDITOR")) +} + +async fn get_profile( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match VideoEditorRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match VideoEditorRepository::upsert(&pool, auth.user_id, payload).await { + Ok(profile) => (StatusCode::OK, Json(profile)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/apps/video_editors/src/main.rs b/apps/video_editors/src/main.rs new file mode 100644 index 0000000..8e83cca --- /dev/null +++ b/apps/video_editors/src/main.rs @@ -0,0 +1,42 @@ +mod handlers; + +use axum::{Router, http::Method}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use contracts::profession_shared::shared_routes; + + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS]) + .allow_origin(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(|| async { "Video Editors Service OK" })) + .nest("/api/video-editors", handlers::router()) + .layer(cors) + .with_state(pool); + + let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8089".to_string()).parse().unwrap(); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("Video Editors service listening on {}", addr); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/crates/auth/Cargo.toml b/crates/auth/Cargo.toml new file mode 100644 index 0000000..3605e3b --- /dev/null +++ b/crates/auth/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "auth" +version = "0.1.0" +edition = "2021" + +[dependencies] +jsonwebtoken = "9.3" +argon2 = "0.5" +rand_core = { version = "0.6", features = ["std"] } +serde = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +anyhow = { workspace = true } +axum = { workspace = true } +db = { path = "../db" } diff --git a/crates/auth/src/crypto.rs b/crates/auth/src/crypto.rs new file mode 100644 index 0000000..965a9bc --- /dev/null +++ b/crates/auth/src/crypto.rs @@ -0,0 +1,23 @@ +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; + +pub fn hash_password(password: &str) -> anyhow::Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hashed = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!("Failed to hash password: {}", e))? + .to_string(); + Ok(hashed) +} + +pub fn verify_password(password: &str, hashed_password: &str) -> anyhow::Result { + let parsed_hash = PasswordHash::new(hashed_password) + .map_err(|e| anyhow::anyhow!("Invalid password hash format: {}", e))?; + let argon2 = Argon2::default(); + Ok(argon2 + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok()) +} diff --git a/crates/auth/src/jwt.rs b/crates/auth/src/jwt.rs new file mode 100644 index 0000000..62510de --- /dev/null +++ b/crates/auth/src/jwt.rs @@ -0,0 +1,55 @@ +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, // User ID + pub role_id: Option, + pub exp: usize, + pub iat: usize, +} + +pub struct JwtTokens { + pub access_token: String, + pub refresh_token: String, +} + +pub fn generate_tokens( + user_id: String, + role_id: Option, + jwt_secret: &str, +) -> anyhow::Result { + let now = Utc::now(); + let access_exp = now + Duration::minutes(15); + + let claims = Claims { + sub: user_id.clone(), + role_id, + iat: now.timestamp() as usize, + exp: access_exp.timestamp() as usize, + }; + + let access_token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(jwt_secret.as_bytes()), + )?; + + // Refresh token is just a long random string that we hash and store in DB + let refresh_token = uuid::Uuid::new_v4().to_string() + &uuid::Uuid::new_v4().to_string(); + + Ok(JwtTokens { + access_token, + refresh_token: refresh_token.replace("-", ""), + }) +} + +pub fn verify_access_token(token: &str, jwt_secret: &str) -> anyhow::Result { + let token_data = decode::( + token, + &DecodingKey::from_secret(jwt_secret.as_bytes()), + &Validation::default(), + )?; + Ok(token_data.claims) +} diff --git a/crates/auth/src/lib.rs b/crates/auth/src/lib.rs new file mode 100644 index 0000000..27e6823 --- /dev/null +++ b/crates/auth/src/lib.rs @@ -0,0 +1,3 @@ +pub mod crypto; +pub mod jwt; +pub mod middleware; diff --git a/crates/auth/src/middleware.rs b/crates/auth/src/middleware.rs new file mode 100644 index 0000000..c96c4e6 --- /dev/null +++ b/crates/auth/src/middleware.rs @@ -0,0 +1,35 @@ +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, +}; +use axum::async_trait; + +pub struct RequireAuth(pub crate::jwt::Claims); + +#[async_trait] +impl FromRequestParts for RequireAuth +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let auth_header = parts + .headers + .get("Authorization") + .and_then(|value| value.to_str().ok()) + .filter(|value| value.starts_with("Bearer ")); + + let token = match auth_header { + Some(header) => header.trim_start_matches("Bearer "), + None => return Err((StatusCode::UNAUTHORIZED, "Missing Bearer token")), + }; + + let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"); + + match crate::jwt::verify_access_token(token, &jwt_secret) { + Ok(claims) => Ok(RequireAuth(claims)), + Err(_) => Err((StatusCode::UNAUTHORIZED, "Invalid or expired token")), + } + } +} diff --git a/crates/contracts/src/auth_middleware.rs b/crates/contracts/src/auth_middleware.rs new file mode 100644 index 0000000..52c211b --- /dev/null +++ b/crates/contracts/src/auth_middleware.rs @@ -0,0 +1,138 @@ +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ── JWT Claims ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: String, // user_id (UUID string) + pub email: String, + pub roles: Vec, + pub active_role: String, + pub exp: usize, + pub iat: usize, +} + +// ── AuthUser extractor ──────────────────────────────────────────────────────── + +/// Axum extractor: validates the Bearer token in the Authorization header. +/// Usage: `async fn handler(auth: AuthUser, ...) -> impl IntoResponse` +#[derive(Debug, Clone)] +pub struct AuthUser { + pub user_id: Uuid, + pub email: String, + pub claims: Claims, +} + +#[axum::async_trait] +impl FromRequestParts for AuthUser +where + S: Send + Sync, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // 1. Extract Authorization header + let auth_header = parts + .headers + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .ok_or(AuthError::MissingToken)?; + + // 2. Strip "Bearer " prefix + let token = auth_header + .strip_prefix("Bearer ") + .ok_or(AuthError::InvalidToken)?; + + // 3. Decode & verify + let jwt_secret = std::env::var("JWT_SECRET") + .unwrap_or_else(|_| "dev-secret-change-me".to_string()); + + let token_data = decode::( + token, + &DecodingKey::from_secret(jwt_secret.as_bytes()), + &Validation::new(Algorithm::HS256), + ) + .map_err(|e| { + tracing::debug!("JWT decode error: {}", e); + AuthError::InvalidToken + })?; + + // 4. Parse user_id as UUID + let user_id = Uuid::parse_str(&token_data.claims.sub) + .map_err(|_| AuthError::InvalidToken)?; + + Ok(AuthUser { + user_id, + email: token_data.claims.email.clone(), + claims: token_data.claims, + }) + } +} + +// ── Auth Error types ────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub enum AuthError { + MissingToken, + InvalidToken, + InsufficientPermissions, +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + let (status, code, message) = match self { + AuthError::MissingToken => ( + StatusCode::UNAUTHORIZED, + "MISSING_TOKEN", + "Authorization header required", + ), + AuthError::InvalidToken => ( + StatusCode::UNAUTHORIZED, + "INVALID_TOKEN", + "Token is invalid or expired", + ), + AuthError::InsufficientPermissions => ( + StatusCode::FORBIDDEN, + "INSUFFICIENT_PERMISSIONS", + "You do not have permission to access this resource", + ), + }; + + ( + status, + Json(serde_json::json!({ "error": message, "code": code })), + ) + .into_response() + } +} + +// ── Role guard helper ───────────────────────────────────────────────────────── + +/// Returns Ok if the user's active_role matches the expected role key. +/// Otherwise returns AuthError::InsufficientPermissions. +pub fn require_role(auth: &AuthUser, expected_role: &str) -> Result<(), AuthError> { + if auth.claims.active_role == expected_role + || auth.claims.roles.contains(&expected_role.to_string()) + { + Ok(()) + } else { + Err(AuthError::InsufficientPermissions) + } +} + +/// Returns Ok if the user has the ADMIN role. +pub fn require_admin(auth: &AuthUser) -> Result<(), AuthError> { + if auth.claims.roles.contains(&"ADMIN".to_string()) { + Ok(()) + } else { + Err(AuthError::InsufficientPermissions) + } +} diff --git a/crates/contracts/src/lib.rs b/crates/contracts/src/lib.rs new file mode 100644 index 0000000..7f0d0bc --- /dev/null +++ b/crates/contracts/src/lib.rs @@ -0,0 +1,4 @@ +pub mod auth_middleware; +pub mod profession_shared; + +pub use auth_middleware::{AuthUser, AuthError, Claims, require_role, require_admin}; diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs new file mode 100644 index 0000000..69a4405 --- /dev/null +++ b/crates/contracts/src/profession_shared.rs @@ -0,0 +1,179 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, patch, post}, + Json, Router, +}; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; +use db::models::professional::ProfessionalRepository; +use db::models::requirement::RequirementRepository; +use db::models::lead_request::{LeadRequestRepository, CreateLeadRequestPayload}; +use crate::auth_middleware::AuthUser; + +#[derive(Deserialize)] +pub struct PaginationQuery { + pub page: Option, + pub limit: Option, +} + +#[derive(Deserialize)] +pub struct LeadRequestPayload { + pub requirement_id: Uuid, +} + +pub fn shared_routes(profession_key: &'static str) -> Router { + Router::new() + .route( + "/marketplace", + get(move |state, query| browse_marketplace(state, query, profession_key)), + ) + .route("/marketplace/:id", get(get_requirement)) + .route("/leads/request", post(send_lead_request)) + .route("/leads/requests/me", get(my_requests)) + .route("/leads/requests/:id", delete(cancel_request)) + .route("/leads/accepted/me", get(accepted_leads)) + .route("/leads/accepted/:id", get(accepted_lead_detail)) + .route("/portfolio/me", get(list_portfolio)) + // ... (other routes remain same for now) +} + +async fn browse_marketplace( + State(pool): State, + Query(q): Query, + profession_key: &str, +) -> impl IntoResponse { + let page = q.page.unwrap_or(1); + let limit = q.limit.unwrap_or(20); + + match ProfessionalRepository::get_marketplace(&pool, profession_key, page, limit).await { + Ok(items) => (StatusCode::OK, Json(serde_json::json!({ + "data": items, + "pagination": { "page": page, "limit": limit } + }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn get_requirement( + State(pool): State, + _auth: AuthUser, + Path(id): Path, +) -> impl IntoResponse { + match RequirementRepository::get_by_id(&pool, id).await { + Ok(Some(req)) if req.status == "OPEN" => (StatusCode::OK, Json(req)).into_response(), + Ok(Some(_)) => (StatusCode::FORBIDDEN, "Requirement is not open").into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Requirement not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn send_lead_request( + State(pool): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + let prof = match ProfessionalRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(p) => p, + Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + }; + + let req = match RequirementRepository::get_by_id(&pool, payload.requirement_id).await { + Ok(Some(r)) if r.status == "OPEN" => r, + Ok(Some(_)) => return (StatusCode::BAD_REQUEST, "Requirement is not open").into_response(), + _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), + }; + + if req.request_count >= 20 { + return (StatusCode::CONFLICT, "Requirement reached max requests").into_response(); + } + + // Check wallet balance + let wallet = match ProfessionalRepository::get_wallet(&pool, auth.user_id).await { + Ok(w) => w, + _ => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(), + }; + + if wallet.balance < 25 { + return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response(); + } + + let db_payload = CreateLeadRequestPayload { + requirement_id: req.id, + professional_id: prof.id, + expires_at: Utc::now() + chrono::Duration::hours(24), + }; + + match LeadRequestRepository::create(&pool, db_payload).await { + Ok(lead) => { + let _ = RequirementRepository::increment_request_count(&pool, req.id).await; + // TODO: Debit/Reserve Tracecoins in wallet ledger + (StatusCode::CREATED, Json(lead)).into_response() + }, + Err(e) => { + if e.to_string().contains("unique") { + (StatusCode::CONFLICT, "Already requested this lead").into_response() + } else { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + } + } +} + +async fn list_portfolio( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match ProfessionalRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(prof) => { + match ProfessionalRepository::get_portfolio(&pool, prof.id).await { + Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } + }, + Err(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + } +} + +async fn list_services( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match ProfessionalRepository::get_by_user_id(&pool, auth.user_id).await { + Ok(prof) => { + match ProfessionalRepository::get_services(&pool, prof.id).await { + Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } + }, + Err(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + } +} + +async fn wallet_balance( + State(pool): State, + auth: AuthUser, +) -> impl IntoResponse { + match ProfessionalRepository::get_wallet(&pool, auth.user_id).await { + Ok(w) => (StatusCode::OK, Json(w)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +// Stubs for remaining routes (ledger, invoices, etc.) +async fn my_requests(_s: State, _a: AuthUser, _q: Query) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) } +async fn cancel_request(_s: State, _a: AuthUser, _p: Path) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Done"}))) } +async fn accepted_leads(_s: State, _a: AuthUser, _q: Query) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) } +async fn accepted_lead_detail(_s: State, _a: AuthUser, _p: Path) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"id":_p.to_string()}))) } +async fn create_portfolio_item(_s: State, _a: AuthUser, _p: Json) -> impl IntoResponse { (StatusCode::CREATED, Json(serde_json::json!({"id":Uuid::new_v4().to_string()}))) } +async fn update_portfolio_item(_s: State, _a: AuthUser, _p: Path, _v: Json) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Updated"}))) } +async fn delete_portfolio_item(_s: State, _a: AuthUser, _p: Path) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Deleted"}))) } +async fn create_service(_s: State, _a: AuthUser, _p: Json) -> impl IntoResponse { (StatusCode::CREATED, Json(serde_json::json!({"id":Uuid::new_v4().to_string()}))) } +async fn update_service(_s: State, _a: AuthUser, _p: Path, _v: Json) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Updated"}))) } +async fn delete_service(_s: State, _a: AuthUser, _p: Path) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"message":"Deleted"}))) } +async fn wallet_ledger(_s: State, _a: AuthUser, _q: Query) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) } +async fn wallet_invoices(_s: State, _a: AuthUser, _q: Query) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"data":[]}))) } +async fn wallet_invoice_detail(_s: State, _a: AuthUser, _p: Path) -> impl IntoResponse { (StatusCode::OK, Json(serde_json::json!({"id":_p.to_string()}))) } + diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml new file mode 100644 index 0000000..2e7ce60 --- /dev/null +++ b/crates/db/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "db" +version = "0.1.0" +edition = "2021" + +[dependencies] +sqlx = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/db/migrations/20260317000000_init_config_schema.down.sql b/crates/db/migrations/20260317000000_init_config_schema.down.sql new file mode 100644 index 0000000..1615bfb --- /dev/null +++ b/crates/db/migrations/20260317000000_init_config_schema.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS runtime_configs; +DROP TABLE IF EXISTS dashboard_configs; +DROP TABLE IF EXISTS onboarding_configs; +DROP TABLE IF EXISTS roles; diff --git a/crates/db/migrations/20260317000000_init_config_schema.up.sql b/crates/db/migrations/20260317000000_init_config_schema.up.sql new file mode 100644 index 0000000..c7f009f --- /dev/null +++ b/crates/db/migrations/20260317000000_init_config_schema.up.sql @@ -0,0 +1,43 @@ +-- 1. ROLES +CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + audience VARCHAR(50) NOT NULL, -- INTERNAL or EXTERNAL + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 2. ONBOARDING CONFIGS +CREATE TABLE IF NOT EXISTS onboarding_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + schema_json JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT unique_active_onboarding_role UNIQUE (role_id, is_active) +); + +-- 3. DASHBOARD CONFIGS +CREATE TABLE IF NOT EXISTS dashboard_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + audience VARCHAR(50) NOT NULL, -- INTERNAL or EXTERNAL + config_json JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT unique_active_dashboard_role UNIQUE (role_id, is_active) +); + +-- 4. RUNTIME CONFIGS +CREATE TABLE IF NOT EXISTS runtime_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + config_json JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT unique_active_runtime_role UNIQUE (role_id, is_active) +); diff --git a/crates/db/migrations/20260317000001_init_users_schema.down.sql b/crates/db/migrations/20260317000001_init_users_schema.down.sql new file mode 100644 index 0000000..7150d35 --- /dev/null +++ b/crates/db/migrations/20260317000001_init_users_schema.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS refresh_tokens; +DROP TABLE IF EXISTS users; diff --git a/crates/db/migrations/20260317000001_init_users_schema.up.sql b/crates/db/migrations/20260317000001_init_users_schema.up.sql new file mode 100644 index 0000000..bfba68b --- /dev/null +++ b/crates/db/migrations/20260317000001_init_users_schema.up.sql @@ -0,0 +1,23 @@ +-- 1. USERS +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE, PENDING, SUSPENDED + role_id UUID REFERENCES roles(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 2. REFRESH TOKENS +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for fast token lookups +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash); diff --git a/crates/db/migrations/20260317000002_init_photographer_schema.down.sql b/crates/db/migrations/20260317000002_init_photographer_schema.down.sql new file mode 100644 index 0000000..421fa25 --- /dev/null +++ b/crates/db/migrations/20260317000002_init_photographer_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF NOT EXISTS photographer_profiles; diff --git a/crates/db/migrations/20260317000002_init_photographer_schema.up.sql b/crates/db/migrations/20260317000002_init_photographer_schema.up.sql new file mode 100644 index 0000000..d7651e1 --- /dev/null +++ b/crates/db/migrations/20260317000002_init_photographer_schema.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS photographer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Photographer Specific Fields + portfolio_url VARCHAR(255), + equipment_list TEXT, + years_of_experience INT, + hourly_rate DECIMAL(10, 2), + specialties TEXT[], -- e.g., ["wedding", "portrait", "commercial"] + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Ensure a user can only have one photographer profile + UNIQUE(user_id) +); diff --git a/crates/db/migrations/20260317000003_init_tutor_schema.down.sql b/crates/db/migrations/20260317000003_init_tutor_schema.down.sql new file mode 100644 index 0000000..51a071b --- /dev/null +++ b/crates/db/migrations/20260317000003_init_tutor_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF NOT EXISTS tutor_profiles; diff --git a/crates/db/migrations/20260317000003_init_tutor_schema.up.sql b/crates/db/migrations/20260317000003_init_tutor_schema.up.sql new file mode 100644 index 0000000..63ba492 --- /dev/null +++ b/crates/db/migrations/20260317000003_init_tutor_schema.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS tutor_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Tutor Specific Fields + subjects_taught TEXT[], -- e.g., ["math", "physics", "computer science"] + education_level VARCHAR(255), + certifications TEXT, + years_of_experience INT, + hourly_rate DECIMAL(10, 2), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Ensure a user can only have one tutor profile + UNIQUE(user_id) +); diff --git a/crates/db/migrations/20260317000004_init_company_schema.down.sql b/crates/db/migrations/20260317000004_init_company_schema.down.sql new file mode 100644 index 0000000..4a60d9d --- /dev/null +++ b/crates/db/migrations/20260317000004_init_company_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF NOT EXISTS company_profiles; diff --git a/crates/db/migrations/20260317000004_init_company_schema.up.sql b/crates/db/migrations/20260317000004_init_company_schema.up.sql new file mode 100644 index 0000000..caebdfe --- /dev/null +++ b/crates/db/migrations/20260317000004_init_company_schema.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS company_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Company Specific Fields + company_name VARCHAR(255) NOT NULL, + registration_number VARCHAR(100), + industry VARCHAR(150), + website_url VARCHAR(255), + employee_count INT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Ensure a user can only have one company profile + UNIQUE(user_id) +); diff --git a/crates/db/migrations/20260317184130_init_job_seeker_schema.down.sql b/crates/db/migrations/20260317184130_init_job_seeker_schema.down.sql new file mode 100644 index 0000000..26d8003 --- /dev/null +++ b/crates/db/migrations/20260317184130_init_job_seeker_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF NOT EXISTS job_seeker_profiles; diff --git a/crates/db/migrations/20260317184130_init_job_seeker_schema.up.sql b/crates/db/migrations/20260317184130_init_job_seeker_schema.up.sql new file mode 100644 index 0000000..ab3a0b6 --- /dev/null +++ b/crates/db/migrations/20260317184130_init_job_seeker_schema.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS job_seeker_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Generic Fields for Job Seeker + bio TEXT, + experience_years INT, + custom_data JSONB DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(user_id) +); diff --git a/crates/db/migrations/20260317184230_init_customer_schema.down.sql b/crates/db/migrations/20260317184230_init_customer_schema.down.sql new file mode 100644 index 0000000..65b512e --- /dev/null +++ b/crates/db/migrations/20260317184230_init_customer_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF NOT EXISTS customer_profiles; diff --git a/crates/db/migrations/20260317184230_init_customer_schema.up.sql b/crates/db/migrations/20260317184230_init_customer_schema.up.sql new file mode 100644 index 0000000..6259890 --- /dev/null +++ b/crates/db/migrations/20260317184230_init_customer_schema.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS customer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Generic Fields for Customer + bio TEXT, + experience_years INT, + custom_data JSONB DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(user_id) +); diff --git a/crates/db/migrations/20260317184330_init_makeup_artist_schema.down.sql b/crates/db/migrations/20260317184330_init_makeup_artist_schema.down.sql new file mode 100644 index 0000000..2612816 --- /dev/null +++ b/crates/db/migrations/20260317184330_init_makeup_artist_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF NOT EXISTS makeup_artist_profiles; diff --git a/crates/db/migrations/20260317184330_init_makeup_artist_schema.up.sql b/crates/db/migrations/20260317184330_init_makeup_artist_schema.up.sql new file mode 100644 index 0000000..ecfda46 --- /dev/null +++ b/crates/db/migrations/20260317184330_init_makeup_artist_schema.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS makeup_artist_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Generic Fields for Makeup Artist + bio TEXT, + experience_years INT, + custom_data JSONB DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(user_id) +); diff --git a/crates/db/migrations/20260317184430_init_developer_schema.down.sql b/crates/db/migrations/20260317184430_init_developer_schema.down.sql new file mode 100644 index 0000000..42f9c8e --- /dev/null +++ b/crates/db/migrations/20260317184430_init_developer_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF NOT EXISTS developer_profiles; diff --git a/crates/db/migrations/20260317184430_init_developer_schema.up.sql b/crates/db/migrations/20260317184430_init_developer_schema.up.sql new file mode 100644 index 0000000..4870a9e --- /dev/null +++ b/crates/db/migrations/20260317184430_init_developer_schema.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS developer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Generic Fields for Developer + bio TEXT, + experience_years INT, + custom_data JSONB DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(user_id) +); diff --git a/crates/db/migrations/20260317184530_init_video_editor_schema.down.sql b/crates/db/migrations/20260317184530_init_video_editor_schema.down.sql new file mode 100644 index 0000000..69e9182 --- /dev/null +++ b/crates/db/migrations/20260317184530_init_video_editor_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF NOT EXISTS video_editor_profiles; diff --git a/crates/db/migrations/20260317184530_init_video_editor_schema.up.sql b/crates/db/migrations/20260317184530_init_video_editor_schema.up.sql new file mode 100644 index 0000000..abdde5a --- /dev/null +++ b/crates/db/migrations/20260317184530_init_video_editor_schema.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS video_editor_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Generic Fields for Video Editor + bio TEXT, + experience_years INT, + custom_data JSONB DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(user_id) +); diff --git a/crates/db/migrations/20260317184630_init_graphic_designer_schema.down.sql b/crates/db/migrations/20260317184630_init_graphic_designer_schema.down.sql new file mode 100644 index 0000000..d20d607 --- /dev/null +++ b/crates/db/migrations/20260317184630_init_graphic_designer_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF NOT EXISTS graphic_designer_profiles; diff --git a/crates/db/migrations/20260317184630_init_graphic_designer_schema.up.sql b/crates/db/migrations/20260317184630_init_graphic_designer_schema.up.sql new file mode 100644 index 0000000..8434820 --- /dev/null +++ b/crates/db/migrations/20260317184630_init_graphic_designer_schema.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS graphic_designer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Generic Fields for Graphic Designer + bio TEXT, + experience_years INT, + custom_data JSONB DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(user_id) +); diff --git a/crates/db/migrations/20260317184730_init_social_media_manager_schema.down.sql b/crates/db/migrations/20260317184730_init_social_media_manager_schema.down.sql new file mode 100644 index 0000000..fbe4aeb --- /dev/null +++ b/crates/db/migrations/20260317184730_init_social_media_manager_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF NOT EXISTS social_media_manager_profiles; diff --git a/crates/db/migrations/20260317184730_init_social_media_manager_schema.up.sql b/crates/db/migrations/20260317184730_init_social_media_manager_schema.up.sql new file mode 100644 index 0000000..a6719ac --- /dev/null +++ b/crates/db/migrations/20260317184730_init_social_media_manager_schema.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS social_media_manager_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Generic Fields for Social Media Manager + bio TEXT, + experience_years INT, + custom_data JSONB DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(user_id) +); diff --git a/crates/db/migrations/20260317184830_init_fitness_trainer_schema.down.sql b/crates/db/migrations/20260317184830_init_fitness_trainer_schema.down.sql new file mode 100644 index 0000000..2c177d7 --- /dev/null +++ b/crates/db/migrations/20260317184830_init_fitness_trainer_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF NOT EXISTS fitness_trainer_profiles; diff --git a/crates/db/migrations/20260317184830_init_fitness_trainer_schema.up.sql b/crates/db/migrations/20260317184830_init_fitness_trainer_schema.up.sql new file mode 100644 index 0000000..30993b5 --- /dev/null +++ b/crates/db/migrations/20260317184830_init_fitness_trainer_schema.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS fitness_trainer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Generic Fields for Fitness Trainer + bio TEXT, + experience_years INT, + custom_data JSONB DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(user_id) +); diff --git a/crates/db/migrations/20260317184930_init_catering_service_schema.down.sql b/crates/db/migrations/20260317184930_init_catering_service_schema.down.sql new file mode 100644 index 0000000..1a74dc8 --- /dev/null +++ b/crates/db/migrations/20260317184930_init_catering_service_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF NOT EXISTS catering_service_profiles; diff --git a/crates/db/migrations/20260317184930_init_catering_service_schema.up.sql b/crates/db/migrations/20260317184930_init_catering_service_schema.up.sql new file mode 100644 index 0000000..2321135 --- /dev/null +++ b/crates/db/migrations/20260317184930_init_catering_service_schema.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS catering_service_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Generic Fields for Catering Service + bio TEXT, + experience_years INT, + custom_data JSONB DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(user_id) +); diff --git a/crates/db/migrations/20260317190000_complete_users_schema.down.sql b/crates/db/migrations/20260317190000_complete_users_schema.down.sql new file mode 100644 index 0000000..6224d65 --- /dev/null +++ b/crates/db/migrations/20260317190000_complete_users_schema.down.sql @@ -0,0 +1,14 @@ +DROP TABLE IF EXISTS submission_documents; +DROP TABLE IF EXISTS onboarding_submissions; +DROP TABLE IF EXISTS employees; +DROP TABLE IF EXISTS designations; +DROP TABLE IF EXISTS departments; +DROP TABLE IF EXISTS role_permissions; +DROP TABLE IF EXISTS user_roles; + +ALTER TABLE users + DROP COLUMN IF EXISTS full_name, + DROP COLUMN IF EXISTS phone, + DROP COLUMN IF EXISTS email_verified, + DROP COLUMN IF EXISTS phone_verified, + DROP COLUMN IF EXISTS deleted_at; diff --git a/crates/db/migrations/20260317190000_complete_users_schema.up.sql b/crates/db/migrations/20260317190000_complete_users_schema.up.sql new file mode 100644 index 0000000..321b0c8 --- /dev/null +++ b/crates/db/migrations/20260317190000_complete_users_schema.up.sql @@ -0,0 +1,83 @@ +-- Add missing columns to users table +ALTER TABLE users + ADD COLUMN IF NOT EXISTS full_name VARCHAR(255), + ADD COLUMN IF NOT EXISTS phone VARCHAR(20) UNIQUE, + ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS phone_verified BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +-- user_roles: many-to-many, a user can hold multiple external roles +CREATE TABLE IF NOT EXISTS user_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, SUSPENDED + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_id) +); + +-- role_permissions +CREATE TABLE IF NOT EXISTS role_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_key VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(role_id, permission_key) +); + +-- departments for internal staff +CREATE TABLE IF NOT EXISTS departments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- designations for internal staff +CREATE TABLE IF NOT EXISTS designations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- employees (internal staff records) +CREATE TABLE IF NOT EXISTS employees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + role_id UUID NOT NULL REFERENCES roles(id), + department_id UUID REFERENCES departments(id), + designation_id UUID REFERENCES designations(id), + employee_code VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- onboarding_submissions: tracks verification submissions +CREATE TABLE IF NOT EXISTS onboarding_submissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id), + config_id UUID REFERENCES onboarding_configs(id), + data_json JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', + submitted_at TIMESTAMPTZ, + reviewed_at TIMESTAMPTZ, + reviewed_by UUID REFERENCES users(id), + rejection_reason TEXT, + document_request TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- submission_documents: uploaded files for onboarding +CREATE TABLE IF NOT EXISTS submission_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + submission_id UUID NOT NULL REFERENCES onboarding_submissions(id) ON DELETE CASCADE, + document_type VARCHAR(100) NOT NULL, + file_url VARCHAR(500) NOT NULL, + file_name VARCHAR(255), + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_roles_status ON user_roles(status); +CREATE INDEX IF NOT EXISTS idx_onboarding_submissions_user_id ON onboarding_submissions(user_id); +CREATE INDEX IF NOT EXISTS idx_onboarding_submissions_status ON onboarding_submissions(status); diff --git a/crates/db/migrations/20260317190100_jobs_and_applications.down.sql b/crates/db/migrations/20260317190100_jobs_and_applications.down.sql new file mode 100644 index 0000000..6cacecb --- /dev/null +++ b/crates/db/migrations/20260317190100_jobs_and_applications.down.sql @@ -0,0 +1,24 @@ +DROP INDEX IF EXISTS idx_applications_status; +DROP INDEX IF EXISTS idx_applications_job_seeker_id; +DROP INDEX IF EXISTS idx_applications_job_id; +DROP INDEX IF EXISTS idx_jobs_status; +DROP INDEX IF EXISTS idx_jobs_company_id; +DROP TABLE IF EXISTS applications; +DROP TABLE IF EXISTS jobs; + +ALTER TABLE company_profiles + DROP COLUMN IF EXISTS business_type, + DROP COLUMN IF EXISTS gst_number, + DROP COLUMN IF EXISTS contact_name, + DROP COLUMN IF EXISTS contact_email, + DROP COLUMN IF EXISTS contact_phone, + DROP COLUMN IF EXISTS address_line1, + DROP COLUMN IF EXISTS city, + DROP COLUMN IF EXISTS state, + DROP COLUMN IF EXISTS country, + DROP COLUMN IF EXISTS postal_code, + DROP COLUMN IF EXISTS status, + DROP COLUMN IF EXISTS free_job_slots, + DROP COLUMN IF EXISTS purchased_job_slots, + DROP COLUMN IF EXISTS free_contact_views, + DROP COLUMN IF EXISTS purchased_contact_views; diff --git a/crates/db/migrations/20260317190100_jobs_and_applications.up.sql b/crates/db/migrations/20260317190100_jobs_and_applications.up.sql new file mode 100644 index 0000000..93795ee --- /dev/null +++ b/crates/db/migrations/20260317190100_jobs_and_applications.up.sql @@ -0,0 +1,61 @@ +-- Complete company profile (replacing the minimal stub) +ALTER TABLE company_profiles + ADD COLUMN IF NOT EXISTS business_type VARCHAR(100), + ADD COLUMN IF NOT EXISTS gst_number VARCHAR(50), + ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255), + ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255), + ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(20), + ADD COLUMN IF NOT EXISTS address_line1 VARCHAR(500), + ADD COLUMN IF NOT EXISTS city VARCHAR(100), + ADD COLUMN IF NOT EXISTS state VARCHAR(100), + ADD COLUMN IF NOT EXISTS country VARCHAR(100) NOT NULL DEFAULT 'India', + ADD COLUMN IF NOT EXISTS postal_code VARCHAR(20), + ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN IF NOT EXISTS free_job_slots INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS purchased_job_slots INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS free_contact_views INTEGER NOT NULL DEFAULT 30, + ADD COLUMN IF NOT EXISTS purchased_contact_views INTEGER NOT NULL DEFAULT 0; + +-- Jobs +CREATE TABLE IF NOT EXISTS jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES company_profiles(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + category VARCHAR(100), + description TEXT NOT NULL, + location VARCHAR(255) NOT NULL, + job_type VARCHAR(50) NOT NULL DEFAULT 'FULL_TIME', -- FULL_TIME, PART_TIME, CONTRACT + salary_min INTEGER, -- in paise + salary_max INTEGER, -- in paise + experience_years INTEGER, + skills TEXT[] DEFAULT '{}', + status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', + -- DRAFT, PENDING_APPROVAL, LIVE, EXPIRED, CLOSED, REJECTED + rejection_reason TEXT, + expires_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, + approved_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Applications (Job Seeker → Job) +CREATE TABLE IF NOT EXISTS applications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + job_seeker_id UUID NOT NULL REFERENCES job_seeker_profiles(id) ON DELETE CASCADE, + cover_letter TEXT, + resume_url VARCHAR(500), + status VARCHAR(50) NOT NULL DEFAULT 'APPLIED', + -- APPLIED, SHORTLISTED, INTERVIEW, OFFERED, HIRED, REJECTED, WITHDRAWN + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + contact_viewed BOOLEAN NOT NULL DEFAULT false, + UNIQUE(job_id, job_seeker_id) +); + +CREATE INDEX IF NOT EXISTS idx_jobs_company_id ON jobs(company_id); +CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); +CREATE INDEX IF NOT EXISTS idx_applications_job_id ON applications(job_id); +CREATE INDEX IF NOT EXISTS idx_applications_job_seeker_id ON applications(job_seeker_id); +CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status); diff --git a/crates/db/migrations/20260317190200_requirements_and_leads.down.sql b/crates/db/migrations/20260317190200_requirements_and_leads.down.sql new file mode 100644 index 0000000..caf44d3 --- /dev/null +++ b/crates/db/migrations/20260317190200_requirements_and_leads.down.sql @@ -0,0 +1,12 @@ +DROP INDEX IF EXISTS idx_professionals_profession_key; +DROP INDEX IF EXISTS idx_lead_requests_status; +DROP INDEX IF EXISTS idx_lead_requests_professional_id; +DROP INDEX IF EXISTS idx_lead_requests_requirement_id; +DROP INDEX IF EXISTS idx_requirements_profession_key; +DROP INDEX IF EXISTS idx_requirements_status; +DROP INDEX IF EXISTS idx_requirements_customer_id; +DROP TABLE IF EXISTS lead_requests; +DROP TABLE IF EXISTS professionals; +DROP TABLE IF EXISTS requirements; +ALTER TABLE customer_profiles DROP COLUMN IF EXISTS full_name, DROP COLUMN IF EXISTS phone, DROP COLUMN IF EXISTS city, DROP COLUMN IF EXISTS area, DROP COLUMN IF EXISTS preferred_professions, DROP COLUMN IF EXISTS active_requirement_count, DROP COLUMN IF EXISTS status; +ALTER TABLE job_seeker_profiles DROP COLUMN IF EXISTS full_name, DROP COLUMN IF EXISTS location, DROP COLUMN IF EXISTS summary, DROP COLUMN IF EXISTS experience_years, DROP COLUMN IF EXISTS skills, DROP COLUMN IF EXISTS resume_url, DROP COLUMN IF EXISTS active_application_count, DROP COLUMN IF EXISTS status; diff --git a/crates/db/migrations/20260317190200_requirements_and_leads.up.sql b/crates/db/migrations/20260317190200_requirements_and_leads.up.sql new file mode 100644 index 0000000..a653629 --- /dev/null +++ b/crates/db/migrations/20260317190200_requirements_and_leads.up.sql @@ -0,0 +1,79 @@ +-- Add missing fields to job_seeker_profiles +ALTER TABLE job_seeker_profiles + ADD COLUMN IF NOT EXISTS full_name VARCHAR(255), + ADD COLUMN IF NOT EXISTS location VARCHAR(255), + ADD COLUMN IF NOT EXISTS summary TEXT, + ADD COLUMN IF NOT EXISTS experience_years INTEGER DEFAULT 0, + ADD COLUMN IF NOT EXISTS skills TEXT[] DEFAULT '{}', + ADD COLUMN IF NOT EXISTS resume_url VARCHAR(500), + ADD COLUMN IF NOT EXISTS active_application_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE'; + +-- Requirements (customer leads) +CREATE TABLE IF NOT EXISTS requirements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_id UUID NOT NULL REFERENCES customer_profiles(id) ON DELETE CASCADE, + profession_key VARCHAR(50) NOT NULL, + title VARCHAR(200) NOT NULL, + description TEXT NOT NULL, + location VARCHAR(255) NOT NULL, + budget INTEGER, -- in paise + preferred_date DATE, + extra_data_json JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', + -- DRAFT, PENDING_APPROVAL, OPEN, CLOSED, EXPIRED, REJECTED + rejection_reason TEXT, + request_count INTEGER NOT NULL DEFAULT 0, + accepted_count INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, + approved_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- professionals unified table (parent for all 9 profession subtypes) +CREATE TABLE IF NOT EXISTS professionals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + profession_key VARCHAR(50) NOT NULL, + display_name VARCHAR(255) NOT NULL, + location VARCHAR(255), + bio TEXT, + extra_data_json JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Lead requests (professional → requirement) +CREATE TABLE IF NOT EXISTS lead_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + requirement_id UUID NOT NULL REFERENCES requirements(id) ON DELETE CASCADE, + professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + -- PENDING, ACCEPTED, REJECTED, EXPIRED, CANCELLED + tracecoins_reserved INTEGER NOT NULL DEFAULT 25, + expires_at TIMESTAMPTZ NOT NULL, + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + UNIQUE(requirement_id, professional_id) +); + +-- Add missing fields to customer_profiles +ALTER TABLE customer_profiles + ADD COLUMN IF NOT EXISTS full_name VARCHAR(255), + ADD COLUMN IF NOT EXISTS phone VARCHAR(20), + ADD COLUMN IF NOT EXISTS city VARCHAR(100), + ADD COLUMN IF NOT EXISTS area VARCHAR(100), + ADD COLUMN IF NOT EXISTS preferred_professions TEXT[] DEFAULT '{}', + ADD COLUMN IF NOT EXISTS active_requirement_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE'; + +CREATE INDEX IF NOT EXISTS idx_requirements_customer_id ON requirements(customer_id); +CREATE INDEX IF NOT EXISTS idx_requirements_status ON requirements(status); +CREATE INDEX IF NOT EXISTS idx_requirements_profession_key ON requirements(profession_key); +CREATE INDEX IF NOT EXISTS idx_lead_requests_requirement_id ON lead_requests(requirement_id); +CREATE INDEX IF NOT EXISTS idx_lead_requests_professional_id ON lead_requests(professional_id); +CREATE INDEX IF NOT EXISTS idx_lead_requests_status ON lead_requests(status); +CREATE INDEX IF NOT EXISTS idx_professionals_profession_key ON professionals(profession_key); diff --git a/crates/db/migrations/20260317190300_portfolio_payments.up.sql b/crates/db/migrations/20260317190300_portfolio_payments.up.sql new file mode 100644 index 0000000..1f8e387 --- /dev/null +++ b/crates/db/migrations/20260317190300_portfolio_payments.up.sql @@ -0,0 +1,98 @@ +-- Portfolio items (for professionals) +CREATE TABLE IF NOT EXISTS portfolio_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + tags TEXT[] DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Portfolio images (multiple images per portfolio item) +CREATE TABLE IF NOT EXISTS portfolio_images ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + portfolio_item_id UUID NOT NULL REFERENCES portfolio_items(id) ON DELETE CASCADE, + file_url VARCHAR(500) NOT NULL, + display_order INTEGER NOT NULL DEFAULT 0 +); + +-- Services (offered by professionals) +CREATE TABLE IF NOT EXISTS services ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + price INTEGER NOT NULL DEFAULT 0, -- in paise + duration_minutes INTEGER, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Tracecoin wallets (one per user) +CREATE TABLE IF NOT EXISTS tracecoin_wallets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + balance INTEGER NOT NULL DEFAULT 0, + reserved INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Tracecoin ledger (IMMUTABLE — never update or delete) +CREATE TABLE IF NOT EXISTS tracecoin_ledger ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID NOT NULL REFERENCES tracecoin_wallets(id), + type VARCHAR(20) NOT NULL, -- CREDIT, DEBIT, RESERVE, RELEASE + amount INTEGER NOT NULL, + reason VARCHAR(100) NOT NULL, -- LEAD_REQUEST, LEAD_ACCEPTED, PURCHASE, ADMIN_CREDIT, LEAD_EXPIRED + reference_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Pricing packages (Tracecoin bundles, job slots, contact views) +CREATE TABLE IF NOT EXISTS pricing_packages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + role_key VARCHAR(50) NOT NULL, + package_type VARCHAR(50) NOT NULL, -- JOB_POSTING, CONTACT_VIEWS, TRACECOIN_BUNDLE + tracecoins_amount INTEGER NOT NULL DEFAULT 0, + price_inr INTEGER NOT NULL, -- in paise + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Payments (Razorpay transactions) +CREATE TABLE IF NOT EXISTS payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + package_id UUID NOT NULL REFERENCES pricing_packages(id), + razorpay_order_id VARCHAR(100), + razorpay_payment_id VARCHAR(100), + amount_inr INTEGER NOT NULL, + tracecoins_credited INTEGER NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, SUCCESS, FAILED + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Invoices (generated for every successful payment) +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES payments(id), + user_id UUID NOT NULL REFERENCES users(id), + invoice_number VARCHAR(50) NOT NULL UNIQUE, + subtotal INTEGER NOT NULL, -- in paise + gst_amount INTEGER NOT NULL, -- in paise + total INTEGER NOT NULL, -- in paise + status VARCHAR(20) NOT NULL DEFAULT 'ISSUED', -- ISSUED, PAID + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + file_url VARCHAR(500) +); + +CREATE INDEX IF NOT EXISTS idx_portfolio_items_professional_id ON portfolio_items(professional_id); +CREATE INDEX IF NOT EXISTS idx_services_professional_id ON services(professional_id); +CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_wallet_id ON tracecoin_ledger(wallet_id); +CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id); +CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id); diff --git a/crates/db/migrations/20260317190400_notifications.up.sql b/crates/db/migrations/20260317190400_notifications.up.sql new file mode 100644 index 0000000..b426d9a --- /dev/null +++ b/crates/db/migrations/20260317190400_notifications.up.sql @@ -0,0 +1,26 @@ +-- Notifications (in-app) +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + body TEXT, + type VARCHAR(50), -- APPROVAL, LEAD, JOB, PAYMENT + reference_id UUID, + is_read BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Email logs (audit trail) +CREATE TABLE IF NOT EXISTS email_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + trigger VARCHAR(100) NOT NULL, -- PROFILE_APPROVED, JOB_APPROVED, etc. + to_email VARCHAR(255) NOT NULL, + subject VARCHAR(500), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, SENT, FAILED + sent_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read); +CREATE INDEX IF NOT EXISTS idx_email_logs_user_id ON email_logs(user_id); diff --git a/crates/db/migrations/20260317195000_profession_specific_profiles.down.sql b/crates/db/migrations/20260317195000_profession_specific_profiles.down.sql new file mode 100644 index 0000000..a6d78d5 --- /dev/null +++ b/crates/db/migrations/20260317195000_profession_specific_profiles.down.sql @@ -0,0 +1,19 @@ +-- Revert profession-specific tables +DROP TABLE IF EXISTS catering_service_profiles; +DROP TABLE IF EXISTS fitness_trainer_profiles; +DROP TABLE IF EXISTS social_media_manager_profiles; +DROP TABLE IF EXISTS graphic_designer_profiles; +DROP TABLE IF EXISTS video_editor_profiles; +DROP TABLE IF EXISTS developer_profiles; +DROP TABLE IF EXISTS makeup_artist_profiles; +DROP TABLE IF EXISTS tutor_profiles; +DROP TABLE IF EXISTS photographer_profiles; + +-- Revert portfolio/services/lead_requests foreign key additions +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS user_id; +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS profession_key; + +ALTER TABLE services DROP COLUMN IF EXISTS user_id; +ALTER TABLE services DROP COLUMN IF EXISTS profession_key; + +ALTER TABLE lead_requests DROP COLUMN IF EXISTS professional_user_id; diff --git a/crates/db/migrations/20260317195000_profession_specific_profiles.up.sql b/crates/db/migrations/20260317195000_profession_specific_profiles.up.sql new file mode 100644 index 0000000..9055d73 --- /dev/null +++ b/crates/db/migrations/20260317195000_profession_specific_profiles.up.sql @@ -0,0 +1,218 @@ +-- Drop the generic professionals table approach; use per-profession profile tables +-- Portfolio and services stay shared (referenced by user_id + profession_key) + +-- 1. PHOTOGRAPHER PROFILES +CREATE TABLE IF NOT EXISTS photographer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + display_name VARCHAR(255) NOT NULL, + bio TEXT, + location VARCHAR(255), + -- Profession-specific + specialties TEXT[] DEFAULT '{}', -- e.g. ['Wedding', 'Portrait', 'Commercial'] + camera_brands TEXT[] DEFAULT '{}', -- e.g. ['Sony', 'Canon'] + studio_available BOOLEAN NOT NULL DEFAULT false, + outdoor_shoots BOOLEAN NOT NULL DEFAULT true, + travel_radius_km INTEGER DEFAULT 50, + starting_price_inr INTEGER DEFAULT 0, -- in paise + -- Verification & status + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, SUSPENDED + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 2. TUTOR PROFILES +CREATE TABLE IF NOT EXISTS tutor_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + display_name VARCHAR(255) NOT NULL, + bio TEXT, + location VARCHAR(255), + -- Profession-specific + subjects TEXT[] DEFAULT '{}', -- e.g. ['Math', 'Physics', 'Hindi'] + board_types TEXT[] DEFAULT '{}', -- e.g. ['CBSE', 'ICSE', 'IB'] + qualification VARCHAR(255), -- e.g. 'B.Tech IIT Delhi' + teaches_online BOOLEAN NOT NULL DEFAULT true, + teaches_offline BOOLEAN NOT NULL DEFAULT true, + experience_years INTEGER DEFAULT 0, + hourly_rate_inr INTEGER DEFAULT 0, -- in paise + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 3. MAKEUP ARTIST PROFILES +CREATE TABLE IF NOT EXISTS makeup_artist_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + display_name VARCHAR(255) NOT NULL, + bio TEXT, + location VARCHAR(255), + -- Profession-specific + specializations TEXT[] DEFAULT '{}', -- e.g. ['Bridal', 'Editorial', 'SFX'] + kit_brands TEXT[] DEFAULT '{}', -- e.g. ['MAC', 'NARS', 'NYX'] + home_service BOOLEAN NOT NULL DEFAULT true, + studio_available BOOLEAN NOT NULL DEFAULT false, + starting_price_inr INTEGER DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 4. DEVELOPER PROFILES +CREATE TABLE IF NOT EXISTS developer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + display_name VARCHAR(255) NOT NULL, + bio TEXT, + location VARCHAR(255), + -- Profession-specific + tech_stack TEXT[] DEFAULT '{}', -- e.g. ['Rust', 'React', 'PostgreSQL'] + github_url VARCHAR(500), + portfolio_url VARCHAR(500), + experience_years INTEGER DEFAULT 0, + availability VARCHAR(50) DEFAULT 'FULL_TIME', -- FULL_TIME, PART_TIME, FREELANCE + hourly_rate_inr INTEGER DEFAULT 0, + remote_ok BOOLEAN NOT NULL DEFAULT true, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 5. VIDEO EDITOR PROFILES +CREATE TABLE IF NOT EXISTS video_editor_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + display_name VARCHAR(255) NOT NULL, + bio TEXT, + location VARCHAR(255), + -- Profession-specific + software_skills TEXT[] DEFAULT '{}', -- e.g. ['Premiere Pro', 'DaVinci Resolve'] + style_tags TEXT[] DEFAULT '{}', -- e.g. ['Cinematic', 'Corporate', 'Reels'] + turnaround_days INTEGER DEFAULT 7, + reel_url VARCHAR(500), + starting_price_inr INTEGER DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 6. GRAPHIC DESIGNER PROFILES +CREATE TABLE IF NOT EXISTS graphic_designer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + display_name VARCHAR(255) NOT NULL, + bio TEXT, + location VARCHAR(255), + -- Profession-specific + design_tools TEXT[] DEFAULT '{}', -- e.g. ['Figma', 'Illustrator', 'Photoshop'] + style_tags TEXT[] DEFAULT '{}', -- e.g. ['Minimalist', 'Bold', 'Corporate'] + brand_experience BOOLEAN NOT NULL DEFAULT false, + portfolio_url VARCHAR(500), + starting_price_inr INTEGER DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7. SOCIAL MEDIA MANAGER PROFILES +CREATE TABLE IF NOT EXISTS social_media_manager_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + display_name VARCHAR(255) NOT NULL, + bio TEXT, + location VARCHAR(255), + -- Profession-specific + platforms TEXT[] DEFAULT '{}', -- e.g. ['Instagram', 'LinkedIn', 'YouTube'] + industries TEXT[] DEFAULT '{}', -- e.g. ['F&B', 'Fashion', 'Real Estate'] + content_types TEXT[] DEFAULT '{}', -- e.g. ['Reels', 'Carousels', 'Stories'] + avg_follower_growth_pct INTEGER DEFAULT 0, + starting_price_inr INTEGER DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 8. FITNESS TRAINER PROFILES +CREATE TABLE IF NOT EXISTS fitness_trainer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + display_name VARCHAR(255) NOT NULL, + bio TEXT, + location VARCHAR(255), + -- Profession-specific + disciplines TEXT[] DEFAULT '{}', -- e.g. ['Yoga', 'HIIT', 'Zumba', 'CrossFit'] + certifications TEXT[] DEFAULT '{}', -- e.g. ['ACE', 'NASM', 'Yoga Alliance RYT'] + online_sessions BOOLEAN NOT NULL DEFAULT true, + home_visits BOOLEAN NOT NULL DEFAULT false, + gym_based BOOLEAN NOT NULL DEFAULT false, + per_session_rate_inr INTEGER DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 9. CATERING SERVICES PROFILES +CREATE TABLE IF NOT EXISTS catering_service_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + business_name VARCHAR(255) NOT NULL, + bio TEXT, + location VARCHAR(255), + -- Profession-specific + cuisine_types TEXT[] DEFAULT '{}', -- e.g. ['North Indian', 'Continental', 'Vegan'] + event_types TEXT[] DEFAULT '{}', -- e.g. ['Wedding', 'Corporate', 'Birthday'] + min_guests INTEGER DEFAULT 10, + max_guests INTEGER DEFAULT 500, + has_setup_team BOOLEAN NOT NULL DEFAULT true, + has_serving_staff BOOLEAN NOT NULL DEFAULT true, + price_per_head_inr INTEGER DEFAULT 0, -- in paise + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Shared: portfolio_items now uses user_id + profession_key (no foreign key to professionals) +-- Drop the professionals-table FK if it was added before +ALTER TABLE portfolio_items + ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS profession_key VARCHAR(50); + +ALTER TABLE services + ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS profession_key VARCHAR(50); + +-- Lead requests: use user_id instead of professional_id foreign key +ALTER TABLE lead_requests + ADD COLUMN IF NOT EXISTS professional_user_id UUID REFERENCES users(id) ON DELETE CASCADE; + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_photographer_profiles_status ON photographer_profiles(status); +CREATE INDEX IF NOT EXISTS idx_tutor_profiles_status ON tutor_profiles(status); +CREATE INDEX IF NOT EXISTS idx_makeup_artist_profiles_status ON makeup_artist_profiles(status); +CREATE INDEX IF NOT EXISTS idx_developer_profiles_status ON developer_profiles(status); +CREATE INDEX IF NOT EXISTS idx_video_editor_profiles_status ON video_editor_profiles(status); +CREATE INDEX IF NOT EXISTS idx_graphic_designer_profiles_status ON graphic_designer_profiles(status); +CREATE INDEX IF NOT EXISTS idx_social_media_manager_profiles_status ON social_media_manager_profiles(status); +CREATE INDEX IF NOT EXISTS idx_fitness_trainer_profiles_status ON fitness_trainer_profiles(status); +CREATE INDEX IF NOT EXISTS idx_catering_service_profiles_status ON catering_service_profiles(status); +CREATE INDEX IF NOT EXISTS idx_portfolio_items_user_id ON portfolio_items(user_id); +CREATE INDEX IF NOT EXISTS idx_services_user_id ON services(user_id); diff --git a/crates/db/migrations/20260317201500_user_verification_tokens.down.sql b/crates/db/migrations/20260317201500_user_verification_tokens.down.sql new file mode 100644 index 0000000..9638d4d --- /dev/null +++ b/crates/db/migrations/20260317201500_user_verification_tokens.down.sql @@ -0,0 +1,10 @@ +-- Remove email verification and password reset columns from users table +ALTER TABLE users + DROP COLUMN IF EXISTS email_verification_token, + DROP COLUMN IF EXISTS email_verification_expires_at, + DROP COLUMN IF EXISTS reset_password_token, + DROP COLUMN IF EXISTS reset_password_expires_at; + +-- Remove indices +DROP INDEX IF EXISTS idx_users_email_verification_token; +DROP INDEX IF EXISTS idx_users_reset_password_token; diff --git a/crates/db/migrations/20260317201500_user_verification_tokens.up.sql b/crates/db/migrations/20260317201500_user_verification_tokens.up.sql new file mode 100644 index 0000000..bc41fd6 --- /dev/null +++ b/crates/db/migrations/20260317201500_user_verification_tokens.up.sql @@ -0,0 +1,10 @@ +-- Add email verification and password reset columns to users table +ALTER TABLE users + ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(255), + ADD COLUMN IF NOT EXISTS email_verification_expires_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS reset_password_token VARCHAR(255), + ADD COLUMN IF NOT EXISTS reset_password_expires_at TIMESTAMPTZ; + +-- Add index for token lookups +CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token); +CREATE INDEX IF NOT EXISTS idx_users_reset_password_token ON users(reset_password_token); diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs new file mode 100644 index 0000000..8b774d6 --- /dev/null +++ b/crates/db/src/lib.rs @@ -0,0 +1,11 @@ +pub mod models; + +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; + +pub async fn establish_connection(database_url: &str) -> Result { + PgPoolOptions::new() + .max_connections(20) + .connect(database_url) + .await +} diff --git a/crates/db/src/models/application.rs b/crates/db/src/models/application.rs new file mode 100644 index 0000000..ca1b8bd --- /dev/null +++ b/crates/db/src/models/application.rs @@ -0,0 +1,134 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Application { + pub id: Uuid, + pub job_id: Uuid, + pub job_seeker_id: Uuid, + pub cover_letter: Option, + pub resume_url: Option, + pub status: String, // APPLIED, SHORTLISTED, INTERVIEW, OFFERED, HIRED, REJECTED, WITHDRAWN + pub applied_at: DateTime, + pub updated_at: DateTime, + pub contact_viewed: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateApplicationPayload { + pub job_id: Uuid, + pub job_seeker_id: Uuid, + pub cover_letter: Option, + pub resume_url: Option, +} + +pub struct ApplicationRepository; + +impl ApplicationRepository { + pub async fn create( + pool: &PgPool, + payload: CreateApplicationPayload, + ) -> Result { + let app = sqlx::query_as!( + Application, + r#" + INSERT INTO applications (job_id, job_seeker_id, cover_letter, resume_url) + VALUES ($1, $2, $3, $4) + RETURNING * + "#, + payload.job_id, + payload.job_seeker_id, + payload.cover_letter, + payload.resume_url + ) + .fetch_one(pool) + .await?; + + Ok(app) + } + + pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!(Application, "SELECT * FROM applications WHERE id = $1", id) + .fetch_optional(pool) + .await + } + + pub async fn list_by_job_id( + pool: &PgPool, + job_id: Uuid, + status: Option, + page: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let offset = (page - 1) * limit; + let apps = sqlx::query_as!( + Application, + r#" + SELECT * FROM applications + WHERE job_id = $1 AND ($2::VARCHAR IS NULL OR status = $2) + ORDER BY applied_at DESC + LIMIT $3 OFFSET $4 + "#, + job_id, + status, + limit, + offset + ) + .fetch_all(pool) + .await?; + + Ok(apps) + } + + pub async fn list_by_job_seeker_id( + pool: &PgPool, + job_seeker_id: Uuid, + page: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let offset = (page - 1) * limit; + let apps = sqlx::query_as!( + Application, + r#" + SELECT * FROM applications + WHERE job_seeker_id = $1 + ORDER BY applied_at DESC + LIMIT $2 OFFSET $3 + "#, + job_seeker_id, + limit, + offset + ) + .fetch_all(pool) + .await?; + Ok(apps) + } + + pub async fn update_status( + pool: &PgPool, + id: Uuid, + status: &str, + ) -> Result { + let app = sqlx::query_as!( + Application, + "UPDATE applications SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *", + status, + id + ) + .fetch_one(pool) + .await?; + Ok(app) + } + + pub async fn mark_contact_viewed(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE applications SET contact_viewed = true WHERE id = $1", + id + ) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/crates/db/src/models/catering_service.rs b/crates/db/src/models/catering_service.rs new file mode 100644 index 0000000..c911dff --- /dev/null +++ b/crates/db/src/models/catering_service.rs @@ -0,0 +1,79 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct CateringServiceProfile { + pub id: Uuid, + pub user_id: Uuid, + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertCateringServiceProfilePayload { + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, +} + +pub struct CateringServiceRepository; + +impl CateringServiceRepository { + pub async fn get_by_user_id( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let profile = sqlx::query_as!( + CateringServiceProfile, + r#" + SELECT + id, user_id, bio, experience_years, custom_data, + created_at, updated_at + FROM catering_service_profiles + WHERE user_id = $1 + "#, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(profile) + } + + pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + payload: UpsertCateringServiceProfilePayload, + ) -> Result { + let profile = 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) + } +} diff --git a/crates/db/src/models/company.rs b/crates/db/src/models/company.rs new file mode 100644 index 0000000..a7aff23 --- /dev/null +++ b/crates/db/src/models/company.rs @@ -0,0 +1,140 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct CompanyProfile { + pub id: Uuid, + pub user_id: Uuid, + pub company_name: String, + pub registration_number: Option, + pub industry: Option, + pub website_url: Option, + pub employee_count: Option, + pub business_type: Option, + pub gst_number: Option, + pub contact_name: Option, + pub contact_email: Option, + pub contact_phone: Option, + pub address_line1: Option, + pub city: Option, + pub state: Option, + pub country: String, + pub postal_code: Option, + pub status: String, + pub free_job_slots: i32, + pub purchased_job_slots: i32, + pub free_contact_views: i32, + pub purchased_contact_views: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertCompanyProfilePayload { + pub company_name: String, + pub registration_number: Option, + pub industry: Option, + pub website_url: Option, + pub employee_count: Option, + pub business_type: Option, + pub gst_number: Option, + pub contact_name: Option, + pub contact_email: Option, + pub contact_phone: Option, + pub address_line1: Option, + pub city: Option, + pub state: Option, + pub postal_code: Option, +} + +pub struct CompanyRepository; + +impl CompanyRepository { + pub async fn get_by_user_id( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let profile = sqlx::query_as!( + CompanyProfile, + r#" + SELECT + id, user_id, company_name, registration_number, industry, + website_url, employee_count, business_type, gst_number, + contact_name, contact_email, contact_phone, address_line1, + city, state, country, postal_code, status, + free_job_slots, purchased_job_slots, free_contact_views, purchased_contact_views, + created_at, updated_at + FROM company_profiles + WHERE user_id = $1 + "#, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(profile) + } + + pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + payload: UpsertCompanyProfilePayload, + ) -> Result { + let profile = sqlx::query_as!( + CompanyProfile, + r#" + INSERT INTO company_profiles ( + user_id, company_name, registration_number, industry, website_url, + employee_count, business_type, gst_number, contact_name, + contact_email, contact_phone, address_line1, city, state, postal_code + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ON CONFLICT (user_id) DO UPDATE SET + company_name = EXCLUDED.company_name, + registration_number = EXCLUDED.registration_number, + industry = EXCLUDED.industry, + website_url = EXCLUDED.website_url, + employee_count = EXCLUDED.employee_count, + business_type = EXCLUDED.business_type, + gst_number = EXCLUDED.gst_number, + contact_name = EXCLUDED.contact_name, + contact_email = EXCLUDED.contact_email, + contact_phone = EXCLUDED.contact_phone, + address_line1 = EXCLUDED.address_line1, + city = EXCLUDED.city, + state = EXCLUDED.state, + postal_code = EXCLUDED.postal_code, + updated_at = NOW() + RETURNING + id, user_id, company_name, registration_number, industry, + website_url, employee_count, business_type, gst_number, + contact_name, contact_email, contact_phone, address_line1, + city, state, country, postal_code, status, + free_job_slots, purchased_job_slots, free_contact_views, purchased_contact_views, + created_at, updated_at + "#, + user_id, + payload.company_name, + payload.registration_number, + payload.industry, + payload.website_url, + payload.employee_count, + payload.business_type, + payload.gst_number, + payload.contact_name, + payload.contact_email, + payload.contact_phone, + payload.address_line1, + payload.city, + payload.state, + payload.postal_code + ) + .fetch_one(pool) + .await?; + + Ok(profile) + } +} + diff --git a/crates/db/src/models/config.rs b/crates/db/src/models/config.rs new file mode 100644 index 0000000..30991f1 --- /dev/null +++ b/crates/db/src/models/config.rs @@ -0,0 +1,337 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct OnboardingConfigListItem { + pub id: Uuid, + pub role_id: Uuid, + pub role_key: String, + pub version: i32, + pub is_active: bool, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct OnboardingConfig { + pub id: Uuid, + pub role_id: Uuid, + pub schema_json: serde_json::Value, + pub version: i32, + pub is_active: bool, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateOnboardingConfigPayload { + pub role_id: Uuid, + pub schema_json: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct DashboardConfigListItem { + pub id: Uuid, + pub role_id: Uuid, + pub role_key: String, + pub audience: String, + pub version: i32, + pub is_active: bool, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct DashboardConfig { + pub id: Uuid, + pub role_id: Uuid, + pub audience: String, + pub config_json: serde_json::Value, + pub version: i32, + pub is_active: bool, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateDashboardConfigPayload { + pub role_id: Uuid, + pub audience: String, + pub config_json: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct RuntimeConfig { + pub id: Uuid, + pub role_id: Uuid, + pub config_json: serde_json::Value, + pub version: i32, + pub is_active: bool, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateRuntimeConfigPayload { + pub role_id: Uuid, + pub config_json: serde_json::Value, +} + +pub struct ConfigRepository; + +impl ConfigRepository { + pub async fn create_onboarding_config( + pool: &PgPool, + payload: CreateOnboardingConfigPayload, + ) -> Result { + // Soft-disable previous active configs for this role + sqlx::query!( + r#" + UPDATE onboarding_configs + SET is_active = false + WHERE role_id = $1 AND is_active = true + "#, + payload.role_id + ) + .execute(pool) + .await?; + + // Insert new config + let config = sqlx::query_as!( + OnboardingConfig, + r#" + INSERT INTO onboarding_configs (role_id, schema_json, version, is_active) + VALUES ( + $1, + $2, + COALESCE((SELECT MAX(version) FROM onboarding_configs WHERE role_id = $1), 0) + 1, + true + ) + RETURNING id, role_id, schema_json, version, is_active, updated_at + "#, + payload.role_id, + payload.schema_json + ) + .fetch_one(pool) + .await?; + + Ok(config) + } + + pub async fn get_active_onboarding_config( + pool: &PgPool, + role_id: Uuid, + ) -> Result { + let config = sqlx::query_as!( + OnboardingConfig, + r#" + SELECT id, role_id, schema_json, version, is_active, updated_at + FROM onboarding_configs + WHERE role_id = $1 AND is_active = true + "#, + role_id + ) + .fetch_one(pool) + .await?; + + Ok(config) + } + + pub async fn get_all_onboarding_configs( + pool: &PgPool, + ) -> Result, sqlx::Error> { + let configs = sqlx::query_as!( + OnboardingConfigListItem, + r#" + SELECT + c.id, c.role_id, r.key as role_key, + c.version, c.is_active, c.updated_at + FROM onboarding_configs c + JOIN roles r ON c.role_id = r.id + ORDER BY c.updated_at DESC + "# + ) + .fetch_all(pool) + .await?; + + Ok(configs) + } + + pub async fn get_active_onboarding_by_role_key( + pool: &PgPool, + role_key: &str, + ) -> Result { + let config = sqlx::query_as!( + OnboardingConfig, + r#" + SELECT c.id, c.role_id, c.schema_json, c.version, c.is_active, c.updated_at + FROM onboarding_configs c + JOIN roles r ON c.role_id = r.id + WHERE r.key = $1 AND c.is_active = true + "#, + role_key.to_uppercase() + ) + .fetch_one(pool) + .await?; + + Ok(config) + } + + pub async fn create_dashboard_config( + pool: &PgPool, + payload: CreateDashboardConfigPayload, + ) -> Result { + // Soft-disable previous active configs for this role + sqlx::query!( + r#" + UPDATE dashboard_configs + SET is_active = false + WHERE role_id = $1 AND audience = $2 AND is_active = true + "#, + payload.role_id, + payload.audience + ) + .execute(pool) + .await?; + + // Insert new config + let config = sqlx::query_as!( + DashboardConfig, + r#" + INSERT INTO dashboard_configs (role_id, audience, config_json, version, is_active) + VALUES ( + $1, + $2, + $3, + COALESCE((SELECT MAX(version) FROM dashboard_configs WHERE role_id = $1 AND audience = $2), 0) + 1, + true + ) + RETURNING id, role_id, audience, config_json, version, is_active, updated_at + "#, + payload.role_id, + payload.audience, + payload.config_json + ) + .fetch_one(pool) + .await?; + + Ok(config) + } + + pub async fn get_active_dashboard_config( + pool: &PgPool, + role_id: Uuid, + audience: &str, + ) -> Result { + let config = sqlx::query_as!( + DashboardConfig, + r#" + SELECT id, role_id, audience, config_json, version, is_active, updated_at + FROM dashboard_configs + WHERE role_id = $1 AND audience = $2 AND is_active = true + "#, + role_id, + audience + ) + .fetch_one(pool) + .await?; + + Ok(config) + } + + pub async fn get_all_dashboard_configs( + pool: &PgPool, + ) -> Result, sqlx::Error> { + let configs = sqlx::query_as!( + DashboardConfigListItem, + r#" + SELECT + c.id, c.role_id, r.key as role_key, c.audience, + c.version, c.is_active, c.updated_at + FROM dashboard_configs c + JOIN roles r ON c.role_id = r.id + ORDER BY c.updated_at DESC + "# + ) + .fetch_all(pool) + .await?; + + Ok(configs) + } + + pub async fn get_active_dashboard_by_role_key( + pool: &PgPool, + role_key: &str, + audience: &str, + ) -> Result { + let config = sqlx::query_as!( + DashboardConfig, + r#" + SELECT c.id, c.role_id, c.audience, c.config_json, c.version, c.is_active, c.updated_at + FROM dashboard_configs c + JOIN roles r ON c.role_id = r.id + WHERE r.key = $1 AND c.audience = $2 AND c.is_active = true + "#, + role_key.to_uppercase(), + audience + ) + .fetch_one(pool) + .await?; + + Ok(config) + } + + pub async fn create_runtime_config( + pool: &PgPool, + payload: CreateRuntimeConfigPayload, + ) -> Result { + // Soft-disable previous active configs for this role + sqlx::query!( + r#" + UPDATE runtime_configs + SET is_active = false + WHERE role_id = $1 AND is_active = true + "#, + payload.role_id + ) + .execute(pool) + .await?; + + // Insert new config + let config = sqlx::query_as!( + RuntimeConfig, + r#" + INSERT INTO runtime_configs (role_id, config_json, version, is_active) + VALUES ( + $1, + $2, + COALESCE((SELECT MAX(version) FROM runtime_configs WHERE role_id = $1), 0) + 1, + true + ) + RETURNING id, role_id, config_json, version, is_active, updated_at + "#, + payload.role_id, + payload.config_json + ) + .fetch_one(pool) + .await?; + + Ok(config) + } + + pub async fn get_active_runtime_config( + pool: &PgPool, + role_id: Uuid, + ) -> Result { + let config = sqlx::query_as!( + RuntimeConfig, + r#" + SELECT id, role_id, config_json, version, is_active, updated_at + FROM runtime_configs + WHERE role_id = $1 AND is_active = true + "#, + role_id + ) + .fetch_one(pool) + .await?; + + Ok(config) + } +} diff --git a/crates/db/src/models/customer.rs b/crates/db/src/models/customer.rs new file mode 100644 index 0000000..f1c3845 --- /dev/null +++ b/crates/db/src/models/customer.rs @@ -0,0 +1,116 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct CustomerProfile { + pub id: Uuid, + pub user_id: Uuid, + pub full_name: Option, + pub phone: Option, + pub city: Option, + pub area: Option, + pub preferred_professions: Vec, + pub active_requirement_count: i32, + pub status: String, + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertCustomerProfilePayload { + pub full_name: Option, + pub phone: Option, + pub city: Option, + pub area: Option, + pub preferred_professions: Option>, + pub bio: Option, + pub custom_data: Option, +} + +pub struct CustomerRepository; + +impl CustomerRepository { + pub async fn get_by_user_id( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let profile = sqlx::query_as!( + CustomerProfile, + r#" + SELECT + id, user_id, full_name, phone, city, area, preferred_professions, + active_requirement_count, status, bio, experience_years, custom_data, + created_at, updated_at + FROM customer_profiles + WHERE user_id = $1 + "#, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(profile) + } + + pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + payload: UpsertCustomerProfilePayload, + ) -> Result { + let profile = sqlx::query_as!( + CustomerProfile, + r#" + INSERT INTO customer_profiles ( + user_id, full_name, phone, city, area, preferred_professions, bio, custom_data + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (user_id) DO UPDATE SET + full_name = EXCLUDED.full_name, + phone = EXCLUDED.phone, + city = EXCLUDED.city, + area = EXCLUDED.area, + preferred_professions = EXCLUDED.preferred_professions, + bio = EXCLUDED.bio, + custom_data = EXCLUDED.custom_data, + updated_at = NOW() + RETURNING + id, user_id, full_name, phone, city, area, preferred_professions, + active_requirement_count, status, bio, experience_years, custom_data, + created_at, updated_at + "#, + user_id, + payload.full_name, + payload.phone, + payload.city, + payload.area, + &payload.preferred_professions.unwrap_or_default(), + payload.bio, + payload.custom_data + ) + .fetch_one(pool) + .await?; + + Ok(profile) + } + + pub async fn update_active_requirement_count( + pool: &PgPool, + id: Uuid, + delta: i32, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE customer_profiles SET active_requirement_count = active_requirement_count + $1 WHERE id = $2", + delta, + id + ) + .execute(pool) + .await?; + Ok(()) + } +} + diff --git a/crates/db/src/models/developer.rs b/crates/db/src/models/developer.rs new file mode 100644 index 0000000..2a226df --- /dev/null +++ b/crates/db/src/models/developer.rs @@ -0,0 +1,79 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct DeveloperProfile { + pub id: Uuid, + pub user_id: Uuid, + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertDeveloperProfilePayload { + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, +} + +pub struct DeveloperRepository; + +impl DeveloperRepository { + pub async fn get_by_user_id( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let profile = sqlx::query_as!( + DeveloperProfile, + r#" + SELECT + id, user_id, bio, experience_years, custom_data, + created_at, updated_at + FROM developer_profiles + WHERE user_id = $1 + "#, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(profile) + } + + pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + payload: UpsertDeveloperProfilePayload, + ) -> Result { + let profile = 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) + } +} diff --git a/crates/db/src/models/fitness_trainer.rs b/crates/db/src/models/fitness_trainer.rs new file mode 100644 index 0000000..a13c548 --- /dev/null +++ b/crates/db/src/models/fitness_trainer.rs @@ -0,0 +1,79 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct FitnessTrainerProfile { + pub id: Uuid, + pub user_id: Uuid, + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertFitnessTrainerProfilePayload { + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, +} + +pub struct FitnessTrainerRepository; + +impl FitnessTrainerRepository { + pub async fn get_by_user_id( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let profile = sqlx::query_as!( + FitnessTrainerProfile, + r#" + SELECT + id, user_id, bio, experience_years, custom_data, + created_at, updated_at + FROM fitness_trainer_profiles + WHERE user_id = $1 + "#, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(profile) + } + + pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + payload: UpsertFitnessTrainerProfilePayload, + ) -> Result { + let profile = 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) + } +} diff --git a/crates/db/src/models/graphic_designer.rs b/crates/db/src/models/graphic_designer.rs new file mode 100644 index 0000000..e16dbfd --- /dev/null +++ b/crates/db/src/models/graphic_designer.rs @@ -0,0 +1,79 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct GraphicDesignerProfile { + pub id: Uuid, + pub user_id: Uuid, + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertGraphicDesignerProfilePayload { + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, +} + +pub struct GraphicDesignerRepository; + +impl GraphicDesignerRepository { + pub async fn get_by_user_id( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let profile = sqlx::query_as!( + GraphicDesignerProfile, + r#" + SELECT + id, user_id, bio, experience_years, custom_data, + created_at, updated_at + FROM graphic_designer_profiles + WHERE user_id = $1 + "#, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(profile) + } + + pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + payload: UpsertGraphicDesignerProfilePayload, + ) -> Result { + let profile = 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) + } +} diff --git a/crates/db/src/models/job.rs b/crates/db/src/models/job.rs new file mode 100644 index 0000000..873cb68 --- /dev/null +++ b/crates/db/src/models/job.rs @@ -0,0 +1,173 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Job { + pub id: Uuid, + pub company_id: Uuid, + pub title: String, + pub category: Option, + pub description: String, + pub location: String, + pub job_type: String, // FULL_TIME, PART_TIME, CONTRACT + pub salary_min: Option, + pub salary_max: Option, + pub experience_years: Option, + pub skills: Vec, + pub status: String, // DRAFT, PENDING_APPROVAL, LIVE, EXPIRED, CLOSED, REJECTED + pub rejection_reason: Option, + pub expires_at: Option>, + pub approved_at: Option>, + pub approved_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateJobPayload { + pub company_id: Uuid, + pub title: String, + pub category: Option, + pub description: String, + pub location: String, + pub job_type: Option, + pub salary_min: Option, + pub salary_max: Option, + pub experience_years: Option, + pub skills: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateJobPayload { + pub title: Option, + pub category: Option, + pub description: Option, + pub location: Option, + pub job_type: Option, + pub salary_min: Option, + pub salary_max: Option, + pub experience_years: Option, + pub skills: Option>, +} + +pub struct JobRepository; + +impl JobRepository { + pub async fn create(pool: &PgPool, payload: CreateJobPayload) -> Result { + let job = sqlx::query_as!( + Job, + r#" + INSERT INTO jobs ( + company_id, title, category, description, location, + job_type, salary_min, salary_max, experience_years, skills + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING * + "#, + payload.company_id, + payload.title, + payload.category, + payload.description, + payload.location, + payload.job_type.unwrap_or_else(|| "FULL_TIME".to_string()), + payload.salary_min, + payload.salary_max, + payload.experience_years, + &payload.skills.unwrap_or_default() + ) + .fetch_one(pool) + .await?; + + Ok(job) + } + + pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!(Job, "SELECT * FROM jobs WHERE id = $1", id) + .fetch_optional(pool) + .await + } + + pub async fn list_by_company_id( + pool: &PgPool, + company_id: Uuid, + status: Option, + page: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let offset = (page - 1) * limit; + let jobs = sqlx::query_as!( + Job, + r#" + SELECT * FROM jobs + WHERE company_id = $1 AND ($2::VARCHAR IS NULL OR status = $2) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + company_id, + status, + limit, + offset + ) + .fetch_all(pool) + .await?; + + Ok(jobs) + } + + pub async fn update( + pool: &PgPool, + id: Uuid, + payload: UpdateJobPayload, + ) -> Result { + let job = sqlx::query_as!( + Job, + r#" + UPDATE jobs SET + title = COALESCE($1, title), + category = COALESCE($2, category), + description = COALESCE($3, description), + location = COALESCE($4, location), + job_type = COALESCE($5, job_type), + salary_min = COALESCE($6, salary_min), + salary_max = COALESCE($7, salary_max), + experience_years = COALESCE($8, experience_years), + skills = COALESCE($9, skills), + updated_at = NOW() + WHERE id = $10 + RETURNING * + "#, + payload.title, + payload.category, + payload.description, + payload.location, + payload.job_type, + payload.salary_min, + payload.salary_max, + payload.experience_years, + payload.skills.as_deref(), + id + ) + .fetch_one(pool) + .await?; + + Ok(job) + } + + pub async fn update_status( + pool: &PgPool, + id: Uuid, + status: &str, + ) -> Result { + let job = sqlx::query_as!( + Job, + "UPDATE jobs SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *", + status, + id + ) + .fetch_one(pool) + .await?; + Ok(job) + } +} diff --git a/crates/db/src/models/job_seeker.rs b/crates/db/src/models/job_seeker.rs new file mode 100644 index 0000000..a8ff34b --- /dev/null +++ b/crates/db/src/models/job_seeker.rs @@ -0,0 +1,120 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct JobSeekerProfile { + pub id: Uuid, + pub user_id: Uuid, + pub full_name: Option, + pub location: Option, + pub summary: Option, + pub experience_years: i32, + pub skills: Vec, + pub resume_url: Option, + pub active_application_count: i32, + pub status: String, + pub bio: Option, + pub custom_data: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertJobSeekerProfilePayload { + pub full_name: Option, + pub location: Option, + pub summary: Option, + pub experience_years: Option, + pub skills: Option>, + pub resume_url: Option, + pub bio: Option, + pub custom_data: Option, +} + +pub struct JobSeekerRepository; + +impl JobSeekerRepository { + pub async fn get_by_user_id( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let profile = sqlx::query_as!( + JobSeekerProfile, + r#" + SELECT + id, user_id, full_name, location, summary, experience_years, + skills, resume_url, active_application_count, status, bio, custom_data, + created_at, updated_at + FROM job_seeker_profiles + WHERE user_id = $1 + "#, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(profile) + } + + pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + payload: UpsertJobSeekerProfilePayload, + ) -> Result { + let profile = sqlx::query_as!( + JobSeekerProfile, + r#" + INSERT INTO job_seeker_profiles ( + user_id, full_name, location, summary, experience_years, + skills, resume_url, bio, custom_data + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (user_id) DO UPDATE SET + full_name = EXCLUDED.full_name, + location = EXCLUDED.location, + summary = EXCLUDED.summary, + experience_years = EXCLUDED.experience_years, + skills = EXCLUDED.skills, + resume_url = EXCLUDED.resume_url, + bio = EXCLUDED.bio, + custom_data = EXCLUDED.custom_data, + updated_at = NOW() + RETURNING + id, user_id, full_name, location, summary, experience_years, + skills, resume_url, active_application_count, status, bio, custom_data, + created_at, updated_at + "#, + user_id, + payload.full_name, + payload.location, + payload.summary, + payload.experience_years.unwrap_or(0), + &payload.skills.unwrap_or_default(), + payload.resume_url, + payload.bio, + payload.custom_data + ) + .fetch_one(pool) + .await?; + + Ok(profile) + } + + pub async fn update_active_application_count( + pool: &PgPool, + id: Uuid, + delta: i32, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE job_seeker_profiles SET active_application_count = active_application_count + $1 WHERE id = $2", + delta, + id + ) + .execute(pool) + .await?; + Ok(()) + } +} + diff --git a/crates/db/src/models/lead_request.rs b/crates/db/src/models/lead_request.rs new file mode 100644 index 0000000..8649fa5 --- /dev/null +++ b/crates/db/src/models/lead_request.rs @@ -0,0 +1,100 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct LeadRequest { + pub id: Uuid, + pub requirement_id: Uuid, + pub professional_id: Uuid, + pub status: String, // PENDING, ACCEPTED, REJECTED, EXPIRED, CANCELLED + pub tracecoins_reserved: i32, + pub expires_at: DateTime, + pub requested_at: DateTime, + pub resolved_at: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateLeadRequestPayload { + pub requirement_id: Uuid, + pub professional_id: Uuid, + pub expires_at: DateTime, +} + +pub struct LeadRequestRepository; + +impl LeadRequestRepository { + pub async fn create( + pool: &PgPool, + payload: CreateLeadRequestPayload, + ) -> Result { + let req = sqlx::query_as!( + LeadRequest, + r#" + INSERT INTO lead_requests (requirement_id, professional_id, expires_at) + VALUES ($1, $2, $3) + RETURNING * + "#, + payload.requirement_id, + payload.professional_id, + payload.expires_at + ) + .fetch_one(pool) + .await?; + + Ok(req) + } + + pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!(LeadRequest, "SELECT * FROM lead_requests WHERE id = $1", id) + .fetch_optional(pool) + .await + } + + pub async fn list_by_requirement_id( + pool: &PgPool, + requirement_id: Uuid, + page: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let offset = (page - 1) * limit; + let reqs = sqlx::query_as!( + LeadRequest, + r#" + SELECT * FROM lead_requests + WHERE requirement_id = $1 + ORDER BY requested_at DESC + LIMIT $2 OFFSET $3 + "#, + requirement_id, + limit, + offset + ) + .fetch_all(pool) + .await?; + + Ok(reqs) + } + + pub async fn update_status( + pool: &PgPool, + id: Uuid, + status: &str, + ) -> Result { + let req = sqlx::query_as!( + LeadRequest, + r#" + UPDATE lead_requests + SET status = $1, resolved_at = NOW(), updated_at = NOW() + WHERE id = $2 + RETURNING * + "#, + status, + id + ) + .fetch_one(pool) + .await?; + Ok(req) + } +} diff --git a/crates/db/src/models/makeup_artist.rs b/crates/db/src/models/makeup_artist.rs new file mode 100644 index 0000000..1ff8a8a --- /dev/null +++ b/crates/db/src/models/makeup_artist.rs @@ -0,0 +1,79 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct MakeupArtistProfile { + pub id: Uuid, + pub user_id: Uuid, + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertMakeupArtistProfilePayload { + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, +} + +pub struct MakeupArtistRepository; + +impl MakeupArtistRepository { + pub async fn get_by_user_id( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let profile = sqlx::query_as!( + MakeupArtistProfile, + r#" + SELECT + id, user_id, bio, experience_years, custom_data, + created_at, updated_at + FROM makeup_artist_profiles + WHERE user_id = $1 + "#, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(profile) + } + + pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + payload: UpsertMakeupArtistProfilePayload, + ) -> Result { + let profile = 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) + } +} diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs new file mode 100644 index 0000000..681ffb7 --- /dev/null +++ b/crates/db/src/models/mod.rs @@ -0,0 +1,21 @@ +pub mod config; +pub mod role; +pub mod user; +pub mod photographer; +pub mod tutor; +pub mod company; +pub mod job_seeker; +pub mod customer; +pub mod makeup_artist; +pub mod developer; +pub mod video_editor; +pub mod graphic_designer; +pub mod social_media_manager; +pub mod fitness_trainer; +pub mod catering_service; +pub mod requirement; +pub mod lead_request; +pub mod job; +pub mod application; +pub mod professional; + diff --git a/crates/db/src/models/photographer.rs b/crates/db/src/models/photographer.rs new file mode 100644 index 0000000..75f70d6 --- /dev/null +++ b/crates/db/src/models/photographer.rs @@ -0,0 +1,89 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct PhotographerProfile { + pub id: Uuid, + pub user_id: Uuid, + pub portfolio_url: Option, + pub equipment_list: Option, + pub years_of_experience: Option, + pub hourly_rate: Option, + pub specialties: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertPhotographerProfilePayload { + pub portfolio_url: Option, + pub equipment_list: Option, + pub years_of_experience: Option, + pub hourly_rate: Option, + pub specialties: Option>, +} + +pub struct PhotographerRepository; + +impl PhotographerRepository { + pub async fn get_by_user_id( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let profile = 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 + "#, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(profile) + } + + pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + payload: UpsertPhotographerProfilePayload, + ) -> Result { + let hourly_rate_bd = payload.hourly_rate.map(|v| sqlx::types::BigDecimal::try_from(v).unwrap_or_default()); + + let profile = sqlx::query_as!( + 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) + } +} diff --git a/crates/db/src/models/professional.rs b/crates/db/src/models/professional.rs new file mode 100644 index 0000000..9d9e6c4 --- /dev/null +++ b/crates/db/src/models/professional.rs @@ -0,0 +1,120 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Professional { + pub id: Uuid, + pub user_id: Uuid, + pub profession_key: String, + pub display_name: String, + pub location: Option, + pub bio: Option, + pub extra_data_json: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +use super::requirement::Requirement; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct PortfolioItem { + pub id: Uuid, + pub professional_id: Uuid, + pub title: String, + pub description: Option, + pub tags: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Service { + pub id: Uuid, + pub professional_id: Uuid, + pub name: String, + pub description: Option, + pub price: i32, + pub duration_minutes: Option, + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Wallet { + pub id: Uuid, + pub user_id: Uuid, + pub balance: i32, + pub reserved: i32, + pub updated_at: DateTime, +} + +pub struct ProfessionalRepository; + +impl ProfessionalRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result { + sqlx::query_as!( + Professional, + "SELECT * FROM professionals WHERE user_id = $1", + user_id + ) + .fetch_one(pool) + .await + } + + pub async fn get_marketplace( + pool: &PgPool, + profession_key: &str, + page: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let offset = (page - 1) * limit; + sqlx::query_as!( + Requirement, + r#" + SELECT * FROM requirements + WHERE profession_key = $1 AND status = 'OPEN' AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + profession_key, + limit, + offset + ) + .fetch_all(pool) + .await + } + + pub async fn get_portfolio(pool: &PgPool, professional_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + PortfolioItem, + "SELECT * FROM portfolio_items WHERE professional_id = $1 ORDER BY created_at DESC", + professional_id + ) + .fetch_all(pool) + .await + } + + pub async fn get_services(pool: &PgPool, professional_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + Service, + "SELECT * FROM services WHERE professional_id = $1 AND is_active = true ORDER BY name ASC", + professional_id + ) + .fetch_all(pool) + .await + } + + pub async fn get_wallet(pool: &PgPool, user_id: Uuid) -> Result { + sqlx::query_as!( + Wallet, + "SELECT * FROM tracecoin_wallets WHERE user_id = $1", + user_id + ) + .fetch_one(pool) + .await + } +} diff --git a/crates/db/src/models/requirement.rs b/crates/db/src/models/requirement.rs new file mode 100644 index 0000000..0a6f817 --- /dev/null +++ b/crates/db/src/models/requirement.rs @@ -0,0 +1,178 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Requirement { + pub id: Uuid, + pub customer_id: Uuid, + pub profession_key: String, + pub title: String, + pub description: String, + pub location: String, + pub budget: Option, + pub preferred_date: Option, + pub extra_data_json: Option, + pub status: String, // DRAFT, PENDING_APPROVAL, OPEN, CLOSED, EXPIRED, REJECTED + pub rejection_reason: Option, + pub request_count: i32, + pub accepted_count: i32, + pub expires_at: Option>, + pub approved_at: Option>, + pub approved_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateRequirementPayload { + pub customer_id: Uuid, + pub profession_key: String, + pub title: String, + pub description: String, + pub location: String, + pub budget: Option, + pub preferred_date: Option, + pub extra_data_json: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateRequirementPayload { + pub title: Option, + pub description: Option, + pub location: Option, + pub budget: Option, + pub preferred_date: Option, + pub extra_data_json: Option, +} + +pub struct RequirementRepository; + +impl RequirementRepository { + pub async fn create( + pool: &PgPool, + payload: CreateRequirementPayload, + ) -> Result { + let req = sqlx::query_as!( + Requirement, + r#" + INSERT INTO requirements ( + customer_id, profession_key, title, description, location, + budget, preferred_date, extra_data_json + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + "#, + payload.customer_id, + payload.profession_key, + payload.title, + payload.description, + payload.location, + payload.budget, + payload.preferred_date, + payload.extra_data_json + ) + .fetch_one(pool) + .await?; + + Ok(req) + } + + pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!(Requirement, "SELECT * FROM requirements WHERE id = $1", id) + .fetch_optional(pool) + .await + } + + pub async fn list_by_customer_id( + pool: &PgPool, + customer_id: Uuid, + page: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let offset = (page - 1) * limit; + let reqs = sqlx::query_as!( + Requirement, + r#" + SELECT * FROM requirements + WHERE customer_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + customer_id, + limit, + offset + ) + .fetch_all(pool) + .await?; + + Ok(reqs) + } + + pub async fn update( + pool: &PgPool, + id: Uuid, + payload: UpdateRequirementPayload, + ) -> Result { + let req = sqlx::query_as!( + Requirement, + r#" + UPDATE requirements SET + title = COALESCE($1, title), + description = COALESCE($2, description), + location = COALESCE($3, location), + budget = COALESCE($4, budget), + preferred_date = COALESCE($5, preferred_date), + extra_data_json = COALESCE($6, extra_data_json), + updated_at = NOW() + WHERE id = $7 + RETURNING * + "#, + payload.title, + payload.description, + payload.location, + payload.budget, + payload.preferred_date, + payload.extra_data_json, + id + ) + .fetch_one(pool) + .await?; + + Ok(req) + } + + pub async fn update_status( + pool: &PgPool, + id: Uuid, + status: &str, + ) -> Result { + let req = sqlx::query_as!( + Requirement, + "UPDATE requirements SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *", + status, + id + ) + .fetch_one(pool) + .await?; + Ok(req) + } + + pub async fn increment_request_count(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query!("UPDATE requirements SET request_count = request_count + 1 WHERE id = $1", id) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn increment_accepted_count(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE requirements SET accepted_count = accepted_count + 1 WHERE id = $1", + id + ) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/crates/db/src/models/role.rs b/crates/db/src/models/role.rs new file mode 100644 index 0000000..50157e4 --- /dev/null +++ b/crates/db/src/models/role.rs @@ -0,0 +1,77 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Role { + pub id: Uuid, + pub key: String, + pub name: String, + pub audience: String, + pub is_active: bool, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateRolePayload { + pub key: String, + pub name: String, + pub audience: String, +} + +pub struct RoleRepository; + +impl RoleRepository { + pub async fn create( + pool: &sqlx::PgPool, + payload: CreateRolePayload, + ) -> Result { + let role = sqlx::query_as!( + Role, + r#" + INSERT INTO roles (key, name, audience) + VALUES ($1, $2, $3) + RETURNING id, key, name, audience, is_active, created_at + "#, + payload.key, + payload.name, + payload.audience + ) + .fetch_one(pool) + .await?; + + Ok(role) + } + + pub async fn get_all(pool: &sqlx::PgPool) -> Result, sqlx::Error> { + let roles = sqlx::query_as!( + Role, + r#" + SELECT id, key, name, audience, is_active, created_at + FROM roles + ORDER BY created_at DESC + "# + ) + .fetch_all(pool) + .await?; + + Ok(roles) + } + + pub async fn get_by_key(pool: &sqlx::PgPool, key: &str) -> Result { + let role = sqlx::query_as!( + Role, + r#" + SELECT id, key, name, audience, is_active, created_at + FROM roles + WHERE key = $1 + "#, + key + ) + .fetch_one(pool) + .await?; + + Ok(role) + } +} diff --git a/crates/db/src/models/social_media_manager.rs b/crates/db/src/models/social_media_manager.rs new file mode 100644 index 0000000..2e9ca70 --- /dev/null +++ b/crates/db/src/models/social_media_manager.rs @@ -0,0 +1,79 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct SocialMediaManagerProfile { + pub id: Uuid, + pub user_id: Uuid, + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertSocialMediaManagerProfilePayload { + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, +} + +pub struct SocialMediaManagerRepository; + +impl SocialMediaManagerRepository { + pub async fn get_by_user_id( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let profile = sqlx::query_as!( + SocialMediaManagerProfile, + r#" + SELECT + id, user_id, bio, experience_years, custom_data, + created_at, updated_at + FROM social_media_manager_profiles + WHERE user_id = $1 + "#, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(profile) + } + + pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + payload: UpsertSocialMediaManagerProfilePayload, + ) -> Result { + let profile = 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) + } +} diff --git a/crates/db/src/models/tutor.rs b/crates/db/src/models/tutor.rs new file mode 100644 index 0000000..386d426 --- /dev/null +++ b/crates/db/src/models/tutor.rs @@ -0,0 +1,89 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct TutorProfile { + pub id: Uuid, + pub user_id: Uuid, + pub subjects_taught: Option>, + pub education_level: Option, + pub certifications: Option, + pub years_of_experience: Option, + pub hourly_rate: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertTutorProfilePayload { + pub subjects_taught: Option>, + pub education_level: Option, + pub certifications: Option, + pub years_of_experience: Option, + pub hourly_rate: Option, +} + +pub struct TutorRepository; + +impl TutorRepository { + pub async fn get_by_user_id( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let profile = 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 + "#, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(profile) + } + + pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + payload: UpsertTutorProfilePayload, + ) -> Result { + let hourly_rate_bd = payload.hourly_rate.map(|v| sqlx::types::BigDecimal::try_from(v).unwrap_or_default()); + + let profile = sqlx::query_as!( + 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) + } +} diff --git a/crates/db/src/models/user.rs b/crates/db/src/models/user.rs new file mode 100644 index 0000000..201b787 --- /dev/null +++ b/crates/db/src/models/user.rs @@ -0,0 +1,309 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +// ── Structs ─────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct User { + pub id: Uuid, + pub email: String, + pub password_hash: String, + pub full_name: Option, + pub phone: Option, + pub email_verified: bool, + pub phone_verified: bool, + pub status: String, // ACTIVE, SUSPENDED, BANNED + pub email_verification_token: Option, + pub email_verification_expires_at: Option>, + pub reset_password_token: Option, + pub reset_password_expires_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateUserPayload { + pub full_name: String, + pub email: String, + pub phone: String, + pub password_hash: String, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct RefreshToken { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub expires_at: DateTime, + pub revoked: bool, + pub created_at: DateTime, +} + +// ── Repository ──────────────────────────────────────────────────────────────── + +pub struct UserRepository; + +impl UserRepository { + pub async fn create(pool: &PgPool, payload: CreateUserPayload) -> Result { + let user = sqlx::query_as!( + User, + r#" + INSERT INTO users (full_name, email, phone, password_hash, email_verified, phone_verified) + VALUES ($1, $2, $3, $4, false, false) + RETURNING + id, email, password_hash, full_name, phone, + email_verified, phone_verified, status, + email_verification_token, email_verification_expires_at, + reset_password_token, reset_password_expires_at, + created_at, updated_at, deleted_at + "#, + payload.full_name, + payload.email.to_lowercase(), + payload.phone, + payload.password_hash, + ) + .fetch_one(pool) + .await?; + + Ok(user) + } + + pub async fn get_by_email(pool: &PgPool, email: &str) -> Result { + sqlx::query_as!( + User, + r#" + SELECT id, email, password_hash, full_name, phone, + email_verified, phone_verified, status, + email_verification_token, email_verification_expires_at, + reset_password_token, reset_password_expires_at, + created_at, updated_at, deleted_at + FROM users + WHERE email = $1 AND deleted_at IS NULL + "#, + email.to_lowercase() + ) + .fetch_one(pool) + .await + } + + pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result { + sqlx::query_as!( + User, + r#" + SELECT id, email, password_hash, full_name, phone, + email_verified, phone_verified, status, + email_verification_token, email_verification_expires_at, + reset_password_token, reset_password_expires_at, + created_at, updated_at, deleted_at + FROM users + WHERE id = $1 AND deleted_at IS NULL + "#, + id + ) + .fetch_one(pool) + .await + } + + /// Returns all approved role keys for a user (e.g. ["COMPANY", "JOB_SEEKER"]) + pub async fn get_user_role_keys(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + let rows = sqlx::query!( + r#" + SELECT r.key + FROM user_roles ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = $1 AND ur.status = 'APPROVED' + ORDER BY ur.approved_at ASC + "#, + user_id + ) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(|r| r.key).collect()) + } + + pub async fn set_email_verification_token( + + pool: &PgPool, + user_id: Uuid, + token: &str, + expires_at: DateTime, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE users + SET email_verification_token = $1, email_verification_expires_at = $2, updated_at = NOW() + WHERE id = $3 + "#, + token, + expires_at, + user_id + ) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn get_by_verification_token(pool: &PgPool, token: &str) -> Result { + sqlx::query_as!( + User, + r#" + SELECT id, email, password_hash, full_name, phone, + email_verified, phone_verified, status, + email_verification_token, email_verification_expires_at, + reset_password_token, reset_password_expires_at, + created_at, updated_at, deleted_at + FROM users + WHERE email_verification_token = $1 AND deleted_at IS NULL + "#, + token + ) + .fetch_one(pool) + .await + } + + pub async fn set_email_verified(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE users SET email_verified = true, email_verification_token = NULL, email_verification_expires_at = NULL, updated_at = NOW() WHERE id = $1", + user_id + ) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn set_reset_token( + pool: &PgPool, + user_id: Uuid, + token: &str, + expires_at: DateTime, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE users + SET reset_password_token = $1, reset_password_expires_at = $2, updated_at = NOW() + WHERE id = $3 + "#, + token, + expires_at, + user_id + ) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn get_by_reset_token(pool: &PgPool, token: &str) -> Result { + sqlx::query_as!( + User, + r#" + SELECT id, email, password_hash, full_name, phone, + email_verified, phone_verified, status, + email_verification_token, email_verification_expires_at, + reset_password_token, reset_password_expires_at, + created_at, updated_at, deleted_at + FROM users + WHERE reset_password_token = $1 AND deleted_at IS NULL + "#, + token + ) + .fetch_one(pool) + .await + } + + pub async fn clear_reset_token(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE users SET reset_password_token = NULL, reset_password_expires_at = NULL, updated_at = NOW() WHERE id = $1", + user_id + ) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn update_password(pool: &PgPool, user_id: Uuid, password_hash: &str) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2", + password_hash, + user_id + ) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn update_status(pool: &PgPool, user_id: Uuid, status: &str) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2", + status, + user_id + ) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn store_refresh_token( + pool: &PgPool, + user_id: Uuid, + token_hash: &str, + expires_at: DateTime, + ) -> Result { + sqlx::query_as!( + RefreshToken, + r#" + INSERT INTO refresh_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3) + RETURNING id, user_id, token_hash, expires_at, revoked, created_at + "#, + user_id, + token_hash, + expires_at + ) + .fetch_one(pool) + .await + } + + pub async fn get_valid_refresh_token( + pool: &PgPool, + token_hash: &str, + ) -> Result { + sqlx::query_as!( + RefreshToken, + r#" + SELECT id, user_id, token_hash, expires_at, revoked, created_at + FROM refresh_tokens + WHERE token_hash = $1 + AND revoked = false + AND expires_at > NOW() + "#, + token_hash + ) + .fetch_one(pool) + .await + } + + pub async fn revoke_refresh_token(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE refresh_tokens SET revoked = true WHERE token_hash = $1", + token_hash + ) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn revoke_all_for_user(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE refresh_tokens SET revoked = true WHERE user_id = $1", + user_id + ) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/crates/db/src/models/video_editor.rs b/crates/db/src/models/video_editor.rs new file mode 100644 index 0000000..cc208ec --- /dev/null +++ b/crates/db/src/models/video_editor.rs @@ -0,0 +1,79 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct VideoEditorProfile { + pub id: Uuid, + pub user_id: Uuid, + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertVideoEditorProfilePayload { + pub bio: Option, + pub experience_years: Option, + pub custom_data: Option, +} + +pub struct VideoEditorRepository; + +impl VideoEditorRepository { + pub async fn get_by_user_id( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let profile = sqlx::query_as!( + VideoEditorProfile, + r#" + SELECT + id, user_id, bio, experience_years, custom_data, + created_at, updated_at + FROM video_editor_profiles + WHERE user_id = $1 + "#, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(profile) + } + + pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + payload: UpsertVideoEditorProfilePayload, + ) -> Result { + let profile = 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) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c7004c5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,231 @@ +version: '3.9' + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: nxtgauge + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-nxtgauge_dev} + POSTGRES_DB: nxtgauge_db + ports: + - '5432:5432' + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U nxtgauge -d nxtgauge_db'] + interval: 5s + timeout: 5s + retries: 10 + + # ── Core Services ──────────────────────────────────────────────────────── + + gateway: + build: + context: . + dockerfile: apps/gateway/Dockerfile + environment: + PORT: 8000 + USERS_SERVICE_URL: http://users:8080 + COMPANIES_SERVICE_URL: http://companies:8081 + JOB_SEEKERS_SERVICE_URL: http://job_seekers:8082 + CUSTOMERS_SERVICE_URL: http://customers:8083 + PHOTOGRAPHERS_SERVICE_URL: http://photographers:8085 + MAKEUP_ARTISTS_SERVICE_URL: http://makeup_artists:8086 + TUTORS_SERVICE_URL: http://tutors:8087 + DEVELOPERS_SERVICE_URL: http://developers:8088 + VIDEO_EDITORS_SERVICE_URL: http://video_editors:8089 + GRAPHIC_DESIGNERS_SERVICE_URL: http://graphic_designers:8090 + SOCIAL_MEDIA_MANAGERS_SERVICE_URL: http://social_media_managers:8091 + FITNESS_TRAINERS_SERVICE_URL: http://fitness_trainers:8092 + CATERING_SERVICES_SERVICE_URL: http://catering_services:8093 + PAYMENTS_SERVICE_URL: http://payments:8094 + ports: + - '8000:8000' + depends_on: + postgres: + condition: service_healthy + + users: + build: + context: . + dockerfile: apps/users/Dockerfile + environment: + PORT: 8080 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + JWT_EXPIRY_MINUTES: 15 + REFRESH_TOKEN_EXPIRY_DAYS: 30 + FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + depends_on: + postgres: + condition: service_healthy + + companies: + build: + context: . + dockerfile: apps/companies/Dockerfile + environment: + PORT: 8081 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + + job_seekers: + build: + context: . + dockerfile: apps/job_seekers/Dockerfile + environment: + PORT: 8082 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + + customers: + build: + context: . + dockerfile: apps/customers/Dockerfile + environment: + PORT: 8083 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + + # ── 9 Profession Services ───────────────────────────────────────────────── + + photographers: + build: + context: . + dockerfile: apps/photographers/Dockerfile + environment: + PORT: 8085 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + + makeup_artists: + build: + context: . + dockerfile: apps/makeup_artists/Dockerfile + environment: + PORT: 8086 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + + tutors: + build: + context: . + dockerfile: apps/tutors/Dockerfile + environment: + PORT: 8087 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + + developers: + build: + context: . + dockerfile: apps/developers/Dockerfile + environment: + PORT: 8088 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + + video_editors: + build: + context: . + dockerfile: apps/video_editors/Dockerfile + environment: + PORT: 8089 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + + graphic_designers: + build: + context: . + dockerfile: apps/graphic_designers/Dockerfile + environment: + PORT: 8090 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + + social_media_managers: + build: + context: . + dockerfile: apps/social_media_managers/Dockerfile + environment: + PORT: 8091 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + + fitness_trainers: + build: + context: . + dockerfile: apps/fitness_trainers/Dockerfile + environment: + PORT: 8092 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + + catering_services: + build: + context: . + dockerfile: apps/catering_services/Dockerfile + environment: + PORT: 8093 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + + # ── Payments ────────────────────────────────────────────────────────────── + + payments: + build: + context: . + dockerfile: apps/payments/Dockerfile + environment: + PORT: 8094 + DATABASE_URL: postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@postgres:5432/nxtgauge_db + JWT_SECRET: ${JWT_SECRET} + RAZORPAY_KEY_ID: ${RAZORPAY_KEY_ID:-} + RAZORPAY_KEY_SECRET: ${RAZORPAY_KEY_SECRET:-} + depends_on: + postgres: + condition: service_healthy + +volumes: + pgdata: