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 <noreply@anthropic.com>
This commit is contained in:
parent
bb8155dd27
commit
9764a7acdd
32 changed files with 366 additions and 108 deletions
|
|
@ -15,4 +15,5 @@ chrono = { workspace = true }
|
|||
db = { path = "../../crates/db" }
|
||||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ chrono = { workspace = true }
|
|||
db = { path = "../../crates/db" }
|
||||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
uuse axum::{
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<HeaderValue> = 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")
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ chrono = { workspace = true }
|
|||
db = { path = "../../crates/db" }
|
||||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<AppState>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
// 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)))
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Tokio1Executor>,
|
||||
transport: Option<AsyncSmtpTransport<Tokio1Executor>>,
|
||||
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::<u16>()
|
||||
.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::<Tokio1Executor>::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::<Tokio1Executor>::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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub sub: String, // user_id (UUID string)
|
||||
pub email: String,
|
||||
pub roles: Vec<String>,
|
||||
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<String>,
|
||||
email: String,
|
||||
roles: Vec<String>,
|
||||
active_role: Option<String>,
|
||||
jwt_secret: &str,
|
||||
) -> anyhow::Result<JwtTokens> {
|
||||
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<Clai
|
|||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
&Validation::new(Algorithm::HS256),
|
||||
)?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<Claims>(
|
||||
token,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
3
crates/db/migrations/20260317202000_reviews.down.sql
Normal file
3
crates/db/migrations/20260317202000_reviews.down.sql
Normal file
|
|
@ -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;
|
||||
15
crates/db/migrations/20260317202000_reviews.up.sql
Normal file
15
crates/db/migrations/20260317202000_reviews.up.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
28
crates/db/migrations/20260317202100_knowledge_base.up.sql
Normal file
28
crates/db/migrations/20260317202100_knowledge_base.up.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
27
crates/db/migrations/20260317202200_support_tickets.up.sql
Normal file
27
crates/db/migrations/20260317202200_support_tickets.up.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
30
crates/db/migrations/20260317202300_coupons_discounts.up.sql
Normal file
30
crates/db/migrations/20260317202300_coupons_discounts.up.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS onboarding_states;
|
||||
16
crates/db/migrations/20260317202400_onboarding_states.up.sql
Normal file
16
crates/db/migrations/20260317202400_onboarding_states.up.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -334,4 +334,24 @@ impl ConfigRepository {
|
|||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn get_active_runtime_by_role_key(
|
||||
pool: &PgPool,
|
||||
role_key: &str,
|
||||
) -> Result<RuntimeConfig, sqlx::Error> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod config;
|
||||
pub mod onboarding_state;
|
||||
pub mod role;
|
||||
pub mod user;
|
||||
pub mod photographer;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ pub struct User {
|
|||
pub struct CreateUserPayload {
|
||||
pub full_name: String,
|
||||
pub email: String,
|
||||
pub phone: String,
|
||||
pub phone: Option<String>,
|
||||
pub password_hash: String,
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ impl UserRepository {
|
|||
"#,
|
||||
payload.full_name,
|
||||
payload.email.to_lowercase(),
|
||||
payload.phone,
|
||||
payload.phone as Option<String>,
|
||||
payload.password_hash,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue