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:
Ashwin Kumar 2026-03-18 22:59:47 +01:00
parent bb8155dd27
commit 9764a7acdd
32 changed files with 366 additions and 108 deletions

View file

@ -15,4 +15,5 @@ chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
serde_json = { workspace = true }

View file

@ -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")

View file

@ -15,4 +15,5 @@ chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
serde_json = { workspace = true }

View file

@ -1,4 +1,4 @@
uuse axum::{
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,

View file

@ -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")

View file

@ -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 }

View file

@ -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")

View file

@ -15,4 +15,5 @@ chrono = { workspace = true }
db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
serde_json = { workspace = true }

View file

@ -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")

View file

@ -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)))
}

View file

@ -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;

View file

@ -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(())
}
}

View file

@ -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)
}

View file

@ -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,

View file

@ -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;

View file

@ -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(),

View file

@ -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(),

View file

@ -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;

View file

@ -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;

View 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;

View 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);

View file

@ -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;

View 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);

View file

@ -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;

View 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);

View file

@ -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;

View 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);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS onboarding_states;

View 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);

View file

@ -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)
}
}

View file

@ -1,4 +1,5 @@
pub mod config;
pub mod onboarding_state;
pub mod role;
pub mod user;
pub mod photographer;

View file

@ -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)