From 9764a7acdd6a71fe57611997d763bcdbf2d28c1e Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Wed, 18 Mar 2026 22:59:47 +0100 Subject: [PATCH] feat: commit remaining service files, migrations, and model updates - gateway, companies, customers, job_seekers apps updated - users config/mod/mail handlers - auth middleware and jwt crate updates - db models: user, config, mod updates - all remaining migrations: portfolio, notifications, reviews, kb, support, coupons, onboarding states Co-Authored-By: Claude Sonnet 4.6 --- apps/companies/Cargo.toml | 1 + apps/companies/src/main.rs | 15 +--- apps/customers/Cargo.toml | 1 + apps/customers/src/handlers.rs | 2 +- apps/customers/src/main.rs | 15 +--- apps/gateway/Cargo.toml | 2 +- apps/gateway/src/main.rs | 33 +++++++- apps/job_seekers/Cargo.toml | 1 + apps/job_seekers/src/main.rs | 15 +--- apps/users/src/handlers/config.rs | 56 +++++++++---- apps/users/src/handlers/mod.rs | 6 +- apps/users/src/mail.rs | 84 ++++++++++++------- crates/auth/src/jwt.rs | 38 ++++++--- crates/contracts/src/auth_middleware.rs | 2 +- .../20260317000000_init_config_schema.up.sql | 21 +++-- ...0317000002_init_photographer_schema.up.sql | 2 +- .../20260317000003_init_tutor_schema.up.sql | 2 +- ...20260317190300_portfolio_payments.down.sql | 14 ++++ .../20260317190400_notifications.down.sql | 6 ++ .../20260317202000_reviews.down.sql | 3 + .../migrations/20260317202000_reviews.up.sql | 15 ++++ .../20260317202100_knowledge_base.down.sql | 4 + .../20260317202100_knowledge_base.up.sql | 28 +++++++ .../20260317202200_support_tickets.down.sql | 5 ++ .../20260317202200_support_tickets.up.sql | 27 ++++++ .../20260317202300_coupons_discounts.down.sql | 4 + .../20260317202300_coupons_discounts.up.sql | 30 +++++++ .../20260317202400_onboarding_states.down.sql | 1 + .../20260317202400_onboarding_states.up.sql | 16 ++++ crates/db/src/models/config.rs | 20 +++++ crates/db/src/models/mod.rs | 1 + crates/db/src/models/user.rs | 4 +- 32 files changed, 366 insertions(+), 108 deletions(-) create mode 100644 crates/db/migrations/20260317190300_portfolio_payments.down.sql create mode 100644 crates/db/migrations/20260317190400_notifications.down.sql create mode 100644 crates/db/migrations/20260317202000_reviews.down.sql create mode 100644 crates/db/migrations/20260317202000_reviews.up.sql create mode 100644 crates/db/migrations/20260317202100_knowledge_base.down.sql create mode 100644 crates/db/migrations/20260317202100_knowledge_base.up.sql create mode 100644 crates/db/migrations/20260317202200_support_tickets.down.sql create mode 100644 crates/db/migrations/20260317202200_support_tickets.up.sql create mode 100644 crates/db/migrations/20260317202300_coupons_discounts.down.sql create mode 100644 crates/db/migrations/20260317202300_coupons_discounts.up.sql create mode 100644 crates/db/migrations/20260317202400_onboarding_states.down.sql create mode 100644 crates/db/migrations/20260317202400_onboarding_states.up.sql diff --git a/apps/companies/Cargo.toml b/apps/companies/Cargo.toml index 3a9d35d..9deb171 100644 --- a/apps/companies/Cargo.toml +++ b/apps/companies/Cargo.toml @@ -15,4 +15,5 @@ chrono = { workspace = true } db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } +serde_json = { workspace = true } diff --git a/apps/companies/src/main.rs b/apps/companies/src/main.rs index 2201b88..6dea422 100644 --- a/apps/companies/src/main.rs +++ b/apps/companies/src/main.rs @@ -1,8 +1,7 @@ mod handlers; -use axum::{Router, http::Method}; +use axum::{routing::get, Router}; use std::net::SocketAddr; -use tower_http::cors::{Any, CorsLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] @@ -14,9 +13,7 @@ async fn main() { .with(tracing_subscriber::fmt::layer()) .init(); - let database_url = - std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - + 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) @@ -25,15 +22,9 @@ async fn main() { 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) + .route("/health", get(|| async { "Companies OK" })) .with_state(pool); let port: u16 = std::env::var("PORT") diff --git a/apps/customers/Cargo.toml b/apps/customers/Cargo.toml index 8c598b6..54aed40 100644 --- a/apps/customers/Cargo.toml +++ b/apps/customers/Cargo.toml @@ -15,4 +15,5 @@ chrono = { workspace = true } db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } +serde_json = { workspace = true } diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index b9ffc56..c1a5446 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -1,4 +1,4 @@ -uuse axum::{ +use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, diff --git a/apps/customers/src/main.rs b/apps/customers/src/main.rs index 26a2f06..1d8dee9 100644 --- a/apps/customers/src/main.rs +++ b/apps/customers/src/main.rs @@ -1,8 +1,7 @@ mod handlers; -use axum::{Router, http::Method}; +use axum::{routing::get, Router}; use std::net::SocketAddr; -use tower_http::cors::{Any, CorsLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] @@ -14,24 +13,18 @@ async fn main() { .with(tracing_subscriber::fmt::layer()) .init(); - let database_url = - std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - + 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); + tracing::info!("Customers service — connected to database"); let app = Router::new() - .route("/health", axum::routing::get(|| async { "Customers Service OK" })) .nest("/api/customers", handlers::router()) - .layer(cors) + .route("/health", get(|| async { "Customers OK" })) .with_state(pool); let port: u16 = std::env::var("PORT") diff --git a/apps/gateway/Cargo.toml b/apps/gateway/Cargo.toml index cdb20ed..c292353 100644 --- a/apps/gateway/Cargo.toml +++ b/apps/gateway/Cargo.toml @@ -11,5 +11,5 @@ 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"] } +reqwest = { version = "0.12", features = ["json", "stream"] } anyhow = { workspace = true } diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index eb2acd4..d2a6e29 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -1,13 +1,13 @@ use axum::{ body::Body, extract::{Request, State}, - http::{StatusCode, Uri}, + http::{HeaderValue, Method, StatusCode, Uri}, response::IntoResponse, routing::any, Router, }; use std::net::SocketAddr; -use tower_http::cors::CorsLayer; +use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[derive(Clone)] @@ -154,6 +154,31 @@ impl Services { } +fn build_cors() -> CorsLayer { + let frontend_url = std::env::var("FRONTEND_URL") + .unwrap_or_else(|_| "http://localhost:3000".to_string()); + let admin_url = std::env::var("ADMIN_URL") + .unwrap_or_else(|_| "http://localhost:3001".to_string()); + + let allowed_origins: Vec = vec![ + frontend_url.parse().expect("Invalid FRONTEND_URL"), + admin_url.parse().expect("Invalid ADMIN_URL"), + ]; + + CorsLayer::new() + .allow_origin(AllowOrigin::list(allowed_origins)) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers(AllowHeaders::mirror_request()) + .allow_credentials(true) +} + #[tokio::main] async fn main() { tracing_subscriber::registry() @@ -165,10 +190,12 @@ async fn main() { let services = Services::from_env(); + let cors = build_cors(); + let app = Router::new() .route("/api/*path", any(proxy_handler)) .route("/health", any(|| async { "Gateway OK" })) - .layer(CorsLayer::permissive()) + .layer(cors) .with_state(services); let port: u16 = std::env::var("PORT") diff --git a/apps/job_seekers/Cargo.toml b/apps/job_seekers/Cargo.toml index 7c8b501..af28103 100644 --- a/apps/job_seekers/Cargo.toml +++ b/apps/job_seekers/Cargo.toml @@ -15,4 +15,5 @@ chrono = { workspace = true } db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } +serde_json = { workspace = true } diff --git a/apps/job_seekers/src/main.rs b/apps/job_seekers/src/main.rs index 99ef894..3ac33f3 100644 --- a/apps/job_seekers/src/main.rs +++ b/apps/job_seekers/src/main.rs @@ -1,8 +1,7 @@ mod handlers; -use axum::{Router, http::Method}; +use axum::{routing::get, Router}; use std::net::SocketAddr; -use tower_http::cors::{Any, CorsLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] @@ -14,24 +13,18 @@ async fn main() { .with(tracing_subscriber::fmt::layer()) .init(); - let database_url = - std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - + 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); + tracing::info!("Job Seekers service — connected to database"); let app = Router::new() - .route("/health", axum::routing::get(|| async { "Job Seekers Service OK" })) .nest("/api/jobseeker", handlers::router()) - .layer(cors) + .route("/health", get(|| async { "Job Seekers OK" })) .with_state(pool); let port: u16 = std::env::var("PORT") diff --git a/apps/users/src/handlers/config.rs b/apps/users/src/handlers/config.rs index bfc266a..a6b86f6 100644 --- a/apps/users/src/handlers/config.rs +++ b/apps/users/src/handlers/config.rs @@ -10,6 +10,7 @@ use db::models::config::{ ConfigRepository, CreateDashboardConfigPayload, CreateOnboardingConfigPayload, CreateRuntimeConfigPayload, }; +use db::models::user::UserRepository; use serde::Deserialize; use uuid::Uuid; @@ -37,23 +38,46 @@ 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), - )), + let role_key = auth.claims.active_role.clone(); + + let config = ConfigRepository::get_active_runtime_by_role_key(&state.pool, &role_key) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => ( + StatusCode::NOT_FOUND, + format!("No runtime config found for role {}", role_key), + ), + e => (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)), + })?; + + // Fetch live user data to merge into the response so the `user` field is always fresh. + let user = UserRepository::get_by_id(&state.pool, auth.user_id) + .await + .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + + let roles = UserRepository::get_user_role_keys(&state.pool, auth.user_id) + .await + .unwrap_or_default(); + + // Merge the stored config_json with live user data. + // `config_json` holds role/modules/flags/permissions; we add the `user` sub-object. + let mut response = config.config_json.clone(); + if let Some(obj) = response.as_object_mut() { + obj.insert( + "user".to_string(), + serde_json::json!({ + "id": user.id.to_string(), + "full_name": user.full_name.unwrap_or_default(), + "email": user.email, + "roles": roles, + "active_role": role_key, + }), + ); + // Ensure role is always set from the JWT active_role (authoritative) + obj.insert("role".to_string(), serde_json::Value::String(role_key)); } + + Ok((StatusCode::OK, Json(response))) } diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index 8ac50e0..9027575 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -1,3 +1,5 @@ -pub mod config; -pub mod roles; pub mod auth; +pub mod config; +pub mod notifications; +pub mod onboarding; +pub mod roles; diff --git a/apps/users/src/mail.rs b/apps/users/src/mail.rs index 9d38e11..cc58961 100644 --- a/apps/users/src/mail.rs +++ b/apps/users/src/mail.rs @@ -1,43 +1,63 @@ use lettre::transport::smtp::authentication::Credentials; -use lettre::{Message, AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; use anyhow::Result; use std::env; pub struct Mailer { - transport: AsyncSmtpTransport, + transport: Option>, 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"); + // SMTP is optional — if vars are missing, emails are silently skipped. + // The service still starts so development works without a real SMTP server. + let smtp_host = env::var("SMTP_HOST").ok(); + let smtp_user = env::var("SMTP_USER").ok(); + let smtp_pass = env::var("SMTP_PASS").ok(); + let smtp_port: u16 = env::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(587); - 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(); + let from_email = env::var("SMTP_FROM_EMAIL") + .unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()); + let from_name = env::var("SMTP_FROM_NAME") + .unwrap_or_else(|_| "NXTGAUGE".to_string()); - Self { - transport, - from_email, - from_name, - } + let transport = match (smtp_host, smtp_user, smtp_pass) { + (Some(host), Some(user), Some(pass)) => { + let credentials = Credentials::new(user, pass); + match AsyncSmtpTransport::::starttls_relay(&host) { + Ok(builder) => { + let t = builder.port(smtp_port).credentials(credentials).build(); + tracing::info!("SMTP transport configured (host={}:{})", host, smtp_port); + Some(t) + } + Err(e) => { + tracing::warn!("SMTP transport init failed: {} — emails will be skipped", e); + None + } + } + } + _ => { + tracing::warn!( + "SMTP_HOST / SMTP_USER / SMTP_PASS not all set — email sending is disabled" + ); + None + } + }; + + Self { transport, from_email, from_name } } pub async fn send_verification_email(&self, to_email: &str, full_name: &str, otp: &str) -> Result<()> { + let Some(transport) = &self.transport else { + tracing::debug!("SMTP disabled — skipping verification email to {}", to_email); + return Ok(()); + }; + 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 @@ -49,16 +69,22 @@ impl Mailer { .subject("Verify your NXTGAUGE account") .body(body)?; - self.transport.send(email).await?; + 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 Some(transport) = &self.transport else { + tracing::debug!("SMTP disabled — skipping password reset email to {}", to_email); + return Ok(()); + }; + + 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", + "Hello {},\n\nYou requested a password reset. Click the link below:\n\n{}\n\nIf you did not request this, please ignore this email.\n\nRegards,\nThe NXTGAUGE Team", full_name, reset_link ); @@ -68,7 +94,7 @@ impl Mailer { .subject("Reset your NXTGAUGE password") .body(body)?; - self.transport.send(email).await?; + transport.send(email).await?; Ok(()) } } diff --git a/crates/auth/src/jwt.rs b/crates/auth/src/jwt.rs index 62510de..a9f1372 100644 --- a/crates/auth/src/jwt.rs +++ b/crates/auth/src/jwt.rs @@ -1,11 +1,15 @@ use chrono::{Duration, Utc}; -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation, Algorithm}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize, Deserialize)] +/// JWT claims — must match `contracts::auth_middleware::Claims` field-for-field +/// so that tokens generated here can be decoded there. +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct Claims { - pub sub: String, // User ID - pub role_id: Option, + pub sub: String, // user_id (UUID string) + pub email: String, + pub roles: Vec, + pub active_role: String, pub exp: usize, pub iat: usize, } @@ -15,17 +19,29 @@ pub struct JwtTokens { pub refresh_token: String, } +/// Generate an access token + a random refresh token string. +/// +/// The refresh token is NOT a JWT — it is a random opaque string stored +/// (hashed) in the `refresh_tokens` table. pub fn generate_tokens( user_id: String, - role_id: Option, + email: String, + roles: Vec, + active_role: Option, jwt_secret: &str, ) -> anyhow::Result { let now = Utc::now(); let access_exp = now + Duration::minutes(15); - + + let active_role = active_role + .or_else(|| roles.first().cloned()) + .unwrap_or_default(); + let claims = Claims { - sub: user_id.clone(), - role_id, + sub: user_id, + email, + roles, + active_role, iat: now.timestamp() as usize, exp: access_exp.timestamp() as usize, }; @@ -36,12 +52,12 @@ pub fn generate_tokens( &EncodingKey::from_secret(jwt_secret.as_bytes()), )?; - // Refresh token is just a long random string that we hash and store in DB + // Refresh token is an opaque random string stored in DB, not a JWT. let refresh_token = uuid::Uuid::new_v4().to_string() + &uuid::Uuid::new_v4().to_string(); Ok(JwtTokens { access_token, - refresh_token: refresh_token.replace("-", ""), + refresh_token: refresh_token.replace('-', ""), }) } @@ -49,7 +65,7 @@ pub fn verify_access_token(token: &str, jwt_secret: &str) -> anyhow::Result( token, &DecodingKey::from_secret(jwt_secret.as_bytes()), - &Validation::default(), + &Validation::new(Algorithm::HS256), )?; Ok(token_data.claims) } diff --git a/crates/contracts/src/auth_middleware.rs b/crates/contracts/src/auth_middleware.rs index 52c211b..2093af8 100644 --- a/crates/contracts/src/auth_middleware.rs +++ b/crates/contracts/src/auth_middleware.rs @@ -53,7 +53,7 @@ where // 3. Decode & verify let jwt_secret = std::env::var("JWT_SECRET") - .unwrap_or_else(|_| "dev-secret-change-me".to_string()); + .expect("JWT_SECRET must be set — refusing to start with insecure default"); let token_data = decode::( token, diff --git a/crates/db/migrations/20260317000000_init_config_schema.up.sql b/crates/db/migrations/20260317000000_init_config_schema.up.sql index c7f009f..d38cfd2 100644 --- a/crates/db/migrations/20260317000000_init_config_schema.up.sql +++ b/crates/db/migrations/20260317000000_init_config_schema.up.sql @@ -15,10 +15,13 @@ CREATE TABLE IF NOT EXISTS onboarding_configs ( 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) + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +-- Only one active onboarding config per role at a time +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_onboarding_per_role + ON onboarding_configs(role_id) WHERE is_active = true; + -- 3. DASHBOARD CONFIGS CREATE TABLE IF NOT EXISTS dashboard_configs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -27,10 +30,13 @@ CREATE TABLE IF NOT EXISTS dashboard_configs ( 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) + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +-- Only one active dashboard config per role+audience combination +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_dashboard_per_role_audience + ON dashboard_configs(role_id, audience) WHERE is_active = true; + -- 4. RUNTIME CONFIGS CREATE TABLE IF NOT EXISTS runtime_configs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -38,6 +44,9 @@ CREATE TABLE IF NOT EXISTS runtime_configs ( 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) + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + +-- Only one active runtime config per role at a time +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_runtime_per_role + ON runtime_configs(role_id) WHERE is_active = true; diff --git a/crates/db/migrations/20260317000002_init_photographer_schema.up.sql b/crates/db/migrations/20260317000002_init_photographer_schema.up.sql index d7651e1..cbcd417 100644 --- a/crates/db/migrations/20260317000002_init_photographer_schema.up.sql +++ b/crates/db/migrations/20260317000002_init_photographer_schema.up.sql @@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS photographer_profiles ( portfolio_url VARCHAR(255), equipment_list TEXT, years_of_experience INT, - hourly_rate DECIMAL(10, 2), + hourly_rate INTEGER, -- in paise (INR × 100) specialties TEXT[], -- e.g., ["wedding", "portrait", "commercial"] created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), diff --git a/crates/db/migrations/20260317000003_init_tutor_schema.up.sql b/crates/db/migrations/20260317000003_init_tutor_schema.up.sql index 63ba492..5790f7e 100644 --- a/crates/db/migrations/20260317000003_init_tutor_schema.up.sql +++ b/crates/db/migrations/20260317000003_init_tutor_schema.up.sql @@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS tutor_profiles ( education_level VARCHAR(255), certifications TEXT, years_of_experience INT, - hourly_rate DECIMAL(10, 2), + hourly_rate INTEGER, -- in paise (INR × 100) created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), diff --git a/crates/db/migrations/20260317190300_portfolio_payments.down.sql b/crates/db/migrations/20260317190300_portfolio_payments.down.sql new file mode 100644 index 0000000..18b4278 --- /dev/null +++ b/crates/db/migrations/20260317190300_portfolio_payments.down.sql @@ -0,0 +1,14 @@ +DROP INDEX IF EXISTS idx_invoices_user_id; +DROP INDEX IF EXISTS idx_payments_user_id; +DROP INDEX IF EXISTS idx_tracecoin_ledger_wallet_id; +DROP INDEX IF EXISTS idx_services_professional_id; +DROP INDEX IF EXISTS idx_portfolio_items_professional_id; + +DROP TABLE IF EXISTS invoices; +DROP TABLE IF EXISTS payments; +DROP TABLE IF EXISTS pricing_packages; +DROP TABLE IF EXISTS tracecoin_ledger; +DROP TABLE IF EXISTS tracecoin_wallets; +DROP TABLE IF EXISTS services; +DROP TABLE IF EXISTS portfolio_images; +DROP TABLE IF EXISTS portfolio_items; diff --git a/crates/db/migrations/20260317190400_notifications.down.sql b/crates/db/migrations/20260317190400_notifications.down.sql new file mode 100644 index 0000000..b9fe3c9 --- /dev/null +++ b/crates/db/migrations/20260317190400_notifications.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS idx_email_logs_user_id; +DROP INDEX IF EXISTS idx_notifications_is_read; +DROP INDEX IF EXISTS idx_notifications_user_id; + +DROP TABLE IF EXISTS email_logs; +DROP TABLE IF EXISTS notifications; diff --git a/crates/db/migrations/20260317202000_reviews.down.sql b/crates/db/migrations/20260317202000_reviews.down.sql new file mode 100644 index 0000000..17408a0 --- /dev/null +++ b/crates/db/migrations/20260317202000_reviews.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_reviews_customer_id; +DROP INDEX IF EXISTS idx_reviews_professional_id; +DROP TABLE IF EXISTS reviews; diff --git a/crates/db/migrations/20260317202000_reviews.up.sql b/crates/db/migrations/20260317202000_reviews.up.sql new file mode 100644 index 0000000..034f3c9 --- /dev/null +++ b/crates/db/migrations/20260317202000_reviews.up.sql @@ -0,0 +1,15 @@ +-- Reviews: customers leave reviews on professionals after an accepted lead +CREATE TABLE IF NOT EXISTS reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + lead_request_id UUID NOT NULL REFERENCES lead_requests(id) ON DELETE CASCADE UNIQUE, + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE, + rating SMALLINT NOT NULL CHECK (rating >= 1 AND rating <= 5), + comment TEXT, + is_published BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_reviews_professional_id ON reviews(professional_id); +CREATE INDEX IF NOT EXISTS idx_reviews_customer_id ON reviews(customer_id); diff --git a/crates/db/migrations/20260317202100_knowledge_base.down.sql b/crates/db/migrations/20260317202100_knowledge_base.down.sql new file mode 100644 index 0000000..6657e6d --- /dev/null +++ b/crates/db/migrations/20260317202100_knowledge_base.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_kb_articles_slug; +DROP INDEX IF EXISTS idx_kb_articles_category_id; +DROP TABLE IF EXISTS kb_articles; +DROP TABLE IF EXISTS kb_categories; diff --git a/crates/db/migrations/20260317202100_knowledge_base.up.sql b/crates/db/migrations/20260317202100_knowledge_base.up.sql new file mode 100644 index 0000000..0fcb81a --- /dev/null +++ b/crates/db/migrations/20260317202100_knowledge_base.up.sql @@ -0,0 +1,28 @@ +-- Knowledge Base categories +CREATE TABLE IF NOT EXISTS kb_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + display_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Knowledge Base articles +CREATE TABLE IF NOT EXISTS kb_articles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID NOT NULL REFERENCES kb_categories(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + slug VARCHAR(500) NOT NULL UNIQUE, + body TEXT NOT NULL, + target_roles TEXT[] DEFAULT '{}', -- empty = visible to all + is_published BOOLEAN NOT NULL DEFAULT false, + views INTEGER NOT NULL DEFAULT 0, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_kb_articles_category_id ON kb_articles(category_id); +CREATE INDEX IF NOT EXISTS idx_kb_articles_slug ON kb_articles(slug); diff --git a/crates/db/migrations/20260317202200_support_tickets.down.sql b/crates/db/migrations/20260317202200_support_tickets.down.sql new file mode 100644 index 0000000..144094c --- /dev/null +++ b/crates/db/migrations/20260317202200_support_tickets.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_support_ticket_messages_ticket_id; +DROP INDEX IF EXISTS idx_support_tickets_status; +DROP INDEX IF EXISTS idx_support_tickets_user_id; +DROP TABLE IF EXISTS support_ticket_messages; +DROP TABLE IF EXISTS support_tickets; diff --git a/crates/db/migrations/20260317202200_support_tickets.up.sql b/crates/db/migrations/20260317202200_support_tickets.up.sql new file mode 100644 index 0000000..a3aebcc --- /dev/null +++ b/crates/db/migrations/20260317202200_support_tickets.up.sql @@ -0,0 +1,27 @@ +-- Support tickets +CREATE TABLE IF NOT EXISTS support_tickets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subject VARCHAR(500) NOT NULL, + category VARCHAR(50) NOT NULL DEFAULT 'GENERAL', -- GENERAL, BILLING, ACCOUNT, LEAD, JOB + status VARCHAR(20) NOT NULL DEFAULT 'OPEN', -- OPEN, IN_PROGRESS, RESOLVED, CLOSED + priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL', -- LOW, NORMAL, HIGH, URGENT + assigned_to UUID REFERENCES users(id), + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Support ticket messages +CREATE TABLE IF NOT EXISTS support_ticket_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES users(id), + body TEXT NOT NULL, + is_internal BOOLEAN NOT NULL DEFAULT false, -- true = staff-only note + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_support_tickets_user_id ON support_tickets(user_id); +CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status); +CREATE INDEX IF NOT EXISTS idx_support_ticket_messages_ticket_id ON support_ticket_messages(ticket_id); diff --git a/crates/db/migrations/20260317202300_coupons_discounts.down.sql b/crates/db/migrations/20260317202300_coupons_discounts.down.sql new file mode 100644 index 0000000..1b32faf --- /dev/null +++ b/crates/db/migrations/20260317202300_coupons_discounts.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_coupon_uses_user_id; +DROP INDEX IF EXISTS idx_coupons_code; +DROP TABLE IF EXISTS coupon_uses; +DROP TABLE IF EXISTS coupons; diff --git a/crates/db/migrations/20260317202300_coupons_discounts.up.sql b/crates/db/migrations/20260317202300_coupons_discounts.up.sql new file mode 100644 index 0000000..b2ee276 --- /dev/null +++ b/crates/db/migrations/20260317202300_coupons_discounts.up.sql @@ -0,0 +1,30 @@ +-- Discount coupons for Tracecoin and package purchases +CREATE TABLE IF NOT EXISTS coupons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) NOT NULL UNIQUE, + description TEXT, + discount_type VARCHAR(20) NOT NULL, -- PERCENT, FLAT + discount_value INTEGER NOT NULL, -- percent (0-100) or paise + applies_to VARCHAR(50) NOT NULL DEFAULT 'ALL', -- ALL, TRACECOIN_BUNDLE, JOB_POSTING, CONTACT_VIEWS + min_order_amount INTEGER NOT NULL DEFAULT 0, -- paise + max_uses INTEGER, -- NULL = unlimited + uses_count INTEGER NOT NULL DEFAULT 0, + per_user_limit INTEGER NOT NULL DEFAULT 1, + valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), + valid_until TIMESTAMPTZ, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Track which users used which coupons +CREATE TABLE IF NOT EXISTS coupon_uses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + payment_id UUID REFERENCES payments(id), + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (coupon_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_coupons_code ON coupons(code); +CREATE INDEX IF NOT EXISTS idx_coupon_uses_user_id ON coupon_uses(user_id); diff --git a/crates/db/migrations/20260317202400_onboarding_states.down.sql b/crates/db/migrations/20260317202400_onboarding_states.down.sql new file mode 100644 index 0000000..80a1804 --- /dev/null +++ b/crates/db/migrations/20260317202400_onboarding_states.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS onboarding_states; diff --git a/crates/db/migrations/20260317202400_onboarding_states.up.sql b/crates/db/migrations/20260317202400_onboarding_states.up.sql new file mode 100644 index 0000000..c208715 --- /dev/null +++ b/crates/db/migrations/20260317202400_onboarding_states.up.sql @@ -0,0 +1,16 @@ +-- Onboarding state per user per role +-- Tracks progress through the schema-driven onboarding form +CREATE TABLE IF NOT EXISTS onboarding_states ( + 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(20) NOT NULL DEFAULT 'NOT_STARTED', -- NOT_STARTED | IN_PROGRESS | COMPLETED + progress_json JSONB NOT NULL DEFAULT '{}', + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- One onboarding state record per user per role +CREATE UNIQUE INDEX IF NOT EXISTS idx_onboarding_state_user_role + ON onboarding_states(user_id, role_id); diff --git a/crates/db/src/models/config.rs b/crates/db/src/models/config.rs index 30991f1..6de4537 100644 --- a/crates/db/src/models/config.rs +++ b/crates/db/src/models/config.rs @@ -334,4 +334,24 @@ impl ConfigRepository { Ok(config) } + + pub async fn get_active_runtime_by_role_key( + pool: &PgPool, + role_key: &str, + ) -> Result { + let config = sqlx::query_as!( + RuntimeConfig, + r#" + SELECT rc.id, rc.role_id, rc.config_json, rc.version, rc.is_active, rc.updated_at + FROM runtime_configs rc + JOIN roles r ON rc.role_id = r.id + WHERE r.key = $1 AND rc.is_active = true + "#, + role_key.to_uppercase() + ) + .fetch_one(pool) + .await?; + + Ok(config) + } } diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index 681ffb7..fa84d96 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod onboarding_state; pub mod role; pub mod user; pub mod photographer; diff --git a/crates/db/src/models/user.rs b/crates/db/src/models/user.rs index 201b787..ddb7c94 100644 --- a/crates/db/src/models/user.rs +++ b/crates/db/src/models/user.rs @@ -29,7 +29,7 @@ pub struct User { pub struct CreateUserPayload { pub full_name: String, pub email: String, - pub phone: String, + pub phone: Option, pub password_hash: String, } @@ -63,7 +63,7 @@ impl UserRepository { "#, payload.full_name, payload.email.to_lowercase(), - payload.phone, + payload.phone as Option, payload.password_hash, ) .fetch_one(pool)