chore: checkpoint workspace updates

This commit is contained in:
Tracewebstudio Dev 2026-04-26 23:58:43 +02:00
parent 1ac60f9756
commit 5946bfe3a8
36 changed files with 2195 additions and 234 deletions

View file

@ -1,40 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT r.key, r.name, ur.status, ur.approved_at\n FROM user_roles ur\n INNER JOIN roles r ON r.id = ur.role_id\n WHERE ur.user_id = $1\n ORDER BY ur.created_at ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "key",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "status",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "approved_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "f479b3c6088810c02b09611eb2bc7b2b88d241b9aac76dd228fd9286c158dd77"
}

2
Cargo.lock generated
View file

@ -1203,6 +1203,8 @@ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"lettre", "lettre",
"reqwest",
"serde",
"tracing", "tracing",
] ]

View file

@ -3,18 +3,21 @@ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
routing::get, routing::{get, post, patch},
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::{AuthUser, require_admin}; use contracts::auth_middleware::{AuthUser, require_admin};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use db::models::employee::{EmployeeRepository, CreateEmployeePayload}; use db::models::employee::{EmployeeRepository, CreateEmployeePayload};
use auth::crypto::hash_password;
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(list_employees).post(create_employee)) .route("/", get(list_employees).post(create_employee))
.route("/provision", post(provision_employee))
.route("/{id}", get(get_employee).patch(update_employee).delete(delete_employee)) .route("/{id}", get(get_employee).patch(update_employee).delete(delete_employee))
.route("/{id}/change-password", patch(change_password))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -82,6 +85,49 @@ async fn create_employee(
Ok((StatusCode::CREATED, Json(employee))) Ok((StatusCode::CREATED, Json(employee)))
} }
#[derive(Deserialize)]
pub struct ProvisionEmployeePayload {
pub email: String,
pub first_name: String,
pub last_name: String,
pub phone: Option<String>,
pub role_code: String,
pub department_id: Option<Uuid>,
pub designation_id: Option<Uuid>,
pub employee_code: Option<String>,
pub password: String,
}
async fn provision_employee(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<ProvisionEmployeePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string()));
}
let password_hash = hash_password(&payload.password)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Password hash error: {}", e)))?;
let create_payload = CreateEmployeePayload {
first_name: payload.first_name,
last_name: payload.last_name,
email: payload.email,
phone: payload.phone,
password_hash,
department_id: payload.department_id,
designation_id: payload.designation_id,
role_code: payload.role_code,
};
let employee = EmployeeRepository::create_with_code(&state.pool, create_payload, payload.employee_code)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {}", e)))?;
Ok((StatusCode::CREATED, Json(employee)))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateEmployeePayload { pub struct UpdateEmployeePayload {
pub first_name: Option<String>, pub first_name: Option<String>,
@ -133,3 +179,28 @@ async fn delete_employee(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[derive(Deserialize)]
pub struct ChangePasswordPayload {
pub password: String,
}
async fn change_password(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<ChangePasswordPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string()));
}
let password_hash = hash_password(&payload.password)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Password hash error: {}", e)))?;
EmployeeRepository::change_password(&state.pool, id, &password_hash)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {}", e)))?;
Ok(Json(serde_json::json!({ "message": "Password updated successfully" })))
}

View file

@ -49,11 +49,11 @@ async fn list_users(
let sql = if role_filter.is_empty() { let sql = if role_filter.is_empty() {
// Generic list: users + their approved roles // Generic list: users + their approved roles
r#" r#"
SELECT SELECT
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at, u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
COALESCE(array_agg(r.key) FILTER (WHERE r.key IS NOT NULL), '{}') as roles COALESCE(array_agg(r.key) FILTER (WHERE r.key IS NOT NULL), '{}') as roles
FROM users u FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id AND ur.status = 'APPROVED' LEFT JOIN user_role_assignments ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
LEFT JOIN roles r ON r.id = ur.role_id LEFT JOIN roles r ON r.id = ur.role_id
WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
GROUP BY u.id GROUP BY u.id
@ -68,14 +68,14 @@ async fn list_users(
"TUTOR" => "tutor_profiles", "TUTOR" => "tutor_profiles",
"DEVELOPER" => "developer_profiles", "DEVELOPER" => "developer_profiles",
"VIDEO_EDITOR" => "video_editor_profiles", "VIDEO_EDITOR" => "video_editor_profiles",
"GRAPHIC_DESIGNER" => "graphic_designer_profiles", "GRAPHIC_DESIGNER" => "graphic_designer_profiles",
"SOCIAL_MEDIA_MANAGER" => "social_media_manager_profiles", "SOCIAL_MEDIA_MANAGER" => "social_media_manager_profiles",
"FITNESS_TRAINER" => "fitness_trainer_profiles", "FITNESS_TRAINER" => "fitness_trainer_profiles",
"CATERING_SERVICES" => "catering_service_profiles", "CATERING_SERVICES" => "catering_service_profiles",
"CUSTOMER" => "customer_profiles", "CUSTOMER" => "customer_profiles",
"COMPANY" => "company_profiles", "COMPANY" => "company_profiles",
"JOB_SEEKER" => "job_seeker_profiles", "JOB_SEEKER" => "job_seeker_profiles",
_ => "user_roles", // fallback _ => "user_role_assignments", // fallback
}; };
format!( format!(
@ -110,11 +110,11 @@ async fn list_customers(
let search = q.q.unwrap_or_default().to_lowercase(); let search = q.q.unwrap_or_default().to_lowercase();
let sql = r#" let sql = r#"
SELECT SELECT
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at, u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
ARRAY['CUSTOMER']::text[] as roles ARRAY['CUSTOMER']::text[] as roles
FROM users u FROM users u
JOIN user_roles ur ON ur.user_id = u.id AND ur.status = 'APPROVED' JOIN user_role_assignments ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
JOIN roles r ON r.id = ur.role_id AND r.key = 'CUSTOMER' JOIN roles r ON r.id = ur.role_id AND r.key = 'CUSTOMER'
WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
ORDER BY u.created_at DESC ORDER BY u.created_at DESC
@ -138,11 +138,11 @@ async fn list_candidates(
let search = q.q.unwrap_or_default().to_lowercase(); let search = q.q.unwrap_or_default().to_lowercase();
let sql = r#" let sql = r#"
SELECT SELECT
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at, u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
ARRAY['JOB_SEEKER']::text[] as roles ARRAY['JOB_SEEKER']::text[] as roles
FROM users u FROM users u
JOIN user_roles ur ON ur.user_id = u.id AND ur.status = 'APPROVED' JOIN user_role_assignments ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
JOIN roles r ON r.id = ur.role_id AND r.key = 'JOB_SEEKER' JOIN roles r ON r.id = ur.role_id AND r.key = 'JOB_SEEKER'
WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
ORDER BY u.created_at DESC ORDER BY u.created_at DESC

View file

@ -14,8 +14,8 @@ pub fn router() -> Router<AppState> {
.route("/templates", get(list_templates)) .route("/templates", get(list_templates))
.route("/templates/{name}/preview", get(preview_template)) .route("/templates/{name}/preview", get(preview_template))
.route("/templates/{name}/test", post(send_test_email)) .route("/templates/{name}/test", post(send_test_email))
.route("/smtp-config", get(get_smtp_config).post(update_smtp_config)) .route("/email-config", get(get_email_config).post(update_email_config))
.route("/smtp-test", post(test_smtp_connection)) .route("/email-test", post(test_email_connection))
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -416,17 +416,21 @@ async fn send_test_email(
} }
} }
// ── SMTP Configuration ─────────────────────────────────────────────────────── // ── Email Configuration ───────────────────────────────────────────────────────
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
struct SmtpConfig { struct EmailConfig {
host: String, provider: String,
port: i32, smtp_host: String,
secure: bool, smtp_port: i32,
username: String, smtp_secure: bool,
smtp_username: String,
#[serde(skip_serializing)] #[serde(skip_serializing)]
password: Option<String>, smtp_password: Option<String>,
zeptomail_api_key: String,
zeptomail_from_email: String,
zeptomail_from_name: String,
from_email: String, from_email: String,
from_name: String, from_name: String,
reply_to_email: Option<String>, reply_to_email: Option<String>,
@ -434,66 +438,93 @@ struct SmtpConfig {
} }
#[derive(Serialize)] #[derive(Serialize)]
struct SmtpConfigResponse { struct EmailConfigResponse {
host: String, provider: String,
port: i32, smtp_host: String,
secure: bool, smtp_port: i32,
username: String, smtp_secure: bool,
smtp_username: String,
from_email: String, from_email: String,
from_name: String, from_name: String,
reply_to_email: Option<String>, reply_to_email: Option<String>,
enabled: bool, enabled: bool,
zeptomail_configured: bool,
} }
async fn get_smtp_config() -> impl IntoResponse { async fn get_email_config() -> impl IntoResponse {
// Return current SMTP configuration from environment let provider = std::env::var("EMAIL_PROVIDER").unwrap_or_else(|_| "SMTP".to_string());
let config = SmtpConfigResponse { let zeptomail_configured = std::env::var("ZEPTOMAIL_API_KEY").is_ok();
host: std::env::var("SMTP_HOST").unwrap_or_default(),
port: std::env::var("SMTP_PORT").unwrap_or_else(|_| "587".to_string()).parse().unwrap_or(587), let config = EmailConfigResponse {
secure: std::env::var("SMTP_SECURE").unwrap_or_default().to_lowercase() == "true", provider: provider.clone(),
username: std::env::var("SMTP_USER").unwrap_or_default(), smtp_host: std::env::var("SMTP_HOST").unwrap_or_default(),
from_email: std::env::var("SMTP_FROM_EMAIL").unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()), smtp_port: std::env::var("SMTP_PORT").unwrap_or_else(|_| "587".to_string()).parse().unwrap_or(587),
from_name: std::env::var("SMTP_FROM_NAME").unwrap_or_else(|_| "NXTGAUGE".to_string()), smtp_secure: std::env::var("SMTP_SECURE").unwrap_or_default().to_lowercase() == "true",
reply_to_email: std::env::var("SMTP_REPLY_TO").ok(), smtp_username: std::env::var("SMTP_USER").unwrap_or_default(),
enabled: std::env::var("SMTP_HOST").is_ok() && !std::env::var("SMTP_HOST").unwrap_or_default().is_empty(), from_email: if provider == "ZEPTOMAIL" {
std::env::var("ZEPTOMAIL_FROM_EMAIL").unwrap_or_else(|_| "noreply@nxtgauge.com".to_string())
} else {
std::env::var("SMTP_FROM_EMAIL").unwrap_or_else(|_| "noreply@nxtgauge.com".to_string())
},
from_name: if provider == "ZEPTOMAIL" {
std::env::var("ZEPTOMAIL_FROM_NAME").unwrap_or_else(|_| "NXTGAUGE".to_string())
} else {
std::env::var("SMTP_FROM_NAME").unwrap_or_else(|_| "NXTGAUGE".to_string())
},
reply_to_email: std::env::var("SMTP_REPLY_TO")
.ok()
.or_else(|| std::env::var("ZEPTOMAIL_REPLY_TO").ok()),
enabled: (provider == "SMTP" && std::env::var("SMTP_HOST").is_ok())
|| (provider == "ZEPTOMAIL" && std::env::var("ZEPTOMAIL_API_KEY").is_ok()),
zeptomail_configured,
}; };
(StatusCode::OK, Json(config)) (StatusCode::OK, Json(config))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
struct UpdateSmtpConfigRequest { struct UpdateEmailConfigRequest {
host: String, provider: String,
port: i32, smtp_host: String,
secure: bool, smtp_port: i32,
username: String, smtp_secure: bool,
password: Option<String>, smtp_username: String,
smtp_password: Option<String>,
zeptomail_api_key: String,
zeptomail_from_email: String,
zeptomail_from_name: String,
from_email: String, from_email: String,
from_name: String, from_name: String,
reply_to_email: Option<String>, reply_to_email: Option<String>,
enabled: bool, enabled: bool,
} }
async fn update_smtp_config( async fn update_email_config(
Json(req): Json<UpdateSmtpConfigRequest>, Json(req): Json<UpdateEmailConfigRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// In production, this would update the database or secrets manager if req.enabled {
// For now, we just return success (env vars need restart to take effect) if req.provider == "SMTP" && req.smtp_host.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
if req.enabled && req.host.is_empty() { "error": "SMTP host is required when SMTP provider is enabled"
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ })));
"error": "SMTP host is required when enabled" }
}))); if req.provider == "ZEPTOMAIL" && req.zeptomail_api_key.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "Zeptomail API key is required when Zeptomail provider is enabled"
})));
}
} }
(StatusCode::OK, Json(serde_json::json!({ (StatusCode::OK, Json(serde_json::json!({
"message": "SMTP configuration updated. Restart services to apply changes.", "message": "Email configuration updated. Restart services to apply changes.",
"config": { "config": {
"host": req.host, "provider": req.provider,
"port": req.port, "smtp_host": req.smtp_host,
"secure": req.secure, "smtp_port": req.smtp_port,
"username": req.username, "smtp_secure": req.smtp_secure,
"smtp_username": req.smtp_username,
"zeptomail_api_key": if req.zeptomail_api_key.is_empty() { "[hidden]".to_string() } else { "[configured]".to_string() },
"from_email": req.from_email, "from_email": req.from_email,
"from_name": req.from_name, "from_name": req.from_name,
"reply_to_email": req.reply_to_email, "reply_to_email": req.reply_to_email,
@ -503,37 +534,39 @@ async fn update_smtp_config(
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct SmtpTestRequest { struct EmailTestRequest {
to_email: String, to_email: String,
config: Option<SmtpTestConfig>, provider: Option<String>,
config: Option<EmailTestConfig>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
struct SmtpTestConfig { struct EmailTestConfig {
host: String, provider: String,
port: i32, smtp_host: String,
secure: bool, smtp_port: i32,
username: String, smtp_secure: bool,
password: String, smtp_username: String,
smtp_password: String,
zeptomail_api_key: String,
from_email: String, from_email: String,
from_name: String, from_name: String,
} }
async fn test_smtp_connection( async fn test_email_connection(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<SmtpTestRequest>, Json(req): Json<EmailTestRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Send a test email using current or provided config // Send a test email using current or provided config
let result = if let Some(test_config) = req.config { let result = if let Some(test_config) = req.config {
// Create temporary mailer with test config // For now, just use the existing mailer - test config would require recreating mailer
let test_mailer = create_test_mailer(test_config).await; state.mail.send_test_email(&req.to_email).await
test_mailer.send_test_email(&req.to_email).await
} else { } else {
// Use existing mailer // Use existing mailer
state.mail.send_test_email(&req.to_email).await state.mail.send_test_email(&req.to_email).await
}; };
match result { match result {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ Ok(_) => (StatusCode::OK, Json(serde_json::json!({
"message": "Test email sent successfully", "message": "Test email sent successfully",
@ -544,9 +577,3 @@ async fn test_smtp_connection(
}))), }))),
} }
} }
async fn create_test_mailer(_config: SmtpTestConfig) -> email::Mailer {
// This is a simplified version - in production you'd create a new Mailer instance
// For now, we just return the default mailer
email::Mailer::new()
}

View file

@ -232,7 +232,7 @@ async fn activate_profile_after_final_approval(
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await { if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
sqlx::query( sqlx::query(
"INSERT INTO user_roles (user_id, role_id, status, approved_at) VALUES ($1, $2, 'APPROVED', NOW()) ON CONFLICT (user_id, role_id) DO UPDATE SET status = 'APPROVED', approved_at = NOW()", "INSERT INTO user_role_assignments (user_id, role_id, status, approved_at) VALUES ($1, $2, 'APPROVED', NOW()) ON CONFLICT (user_id, role_id) DO UPDATE SET status = 'APPROVED', approved_at = NOW()",
) )
.bind(user_id) .bind(user_id)
.bind(role.id) .bind(role.id)

View file

@ -84,7 +84,7 @@ async fn list_runtime_configs(
sqlx::query_as::<_, RcRow>( sqlx::query_as::<_, RcRow>(
r#" r#"
SELECT id, role_id, config_json, version, is_active, updated_at SELECT id, role_id, config_json, version, is_active, updated_at
FROM runtime_configs FROM role_runtime_configs
WHERE role_id = $1 WHERE role_id = $1
ORDER BY version DESC ORDER BY version DESC
"#, "#,
@ -107,7 +107,7 @@ async fn list_runtime_configs(
sqlx::query_as::<_, RcRow>( sqlx::query_as::<_, RcRow>(
r#" r#"
SELECT rc.id, rc.role_id, rc.config_json, rc.version, rc.is_active, rc.updated_at SELECT rc.id, rc.role_id, rc.config_json, rc.version, rc.is_active, rc.updated_at
FROM runtime_configs rc FROM role_runtime_configs rc
JOIN roles r ON rc.role_id = r.id JOIN roles r ON rc.role_id = r.id
WHERE r.audience = 'INTERNAL' WHERE r.audience = 'INTERNAL'
ORDER BY rc.updated_at DESC ORDER BY rc.updated_at DESC
@ -149,7 +149,7 @@ async fn get_runtime_config_by_id(
updated_at: chrono::DateTime<chrono::Utc>, updated_at: chrono::DateTime<chrono::Utc>,
} }
let r = sqlx::query_as::<_, RcDetailRow>( let r = sqlx::query_as::<_, RcDetailRow>(
"SELECT id, role_id, config_json, version, is_active, updated_at FROM runtime_configs WHERE id = $1", "SELECT id, role_id, config_json, version, is_active, updated_at FROM role_runtime_configs WHERE id = $1",
) )
.bind(id) .bind(id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
@ -193,20 +193,20 @@ async fn activate_runtime_config(
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
} }
// Fetch role_id for the target config // Fetch role_id for the target config
let role_id: Uuid = sqlx::query_scalar::<_, Uuid>("SELECT role_id FROM runtime_configs WHERE id = $1") let role_id: Uuid = sqlx::query_scalar::<_, Uuid>("SELECT role_id FROM role_runtime_configs WHERE id = $1")
.bind(id) .bind(id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "Runtime config not found".to_string()))?; .ok_or((StatusCode::NOT_FOUND, "Runtime config not found".to_string()))?;
// Disable existing active // Disable existing active
sqlx::query("UPDATE runtime_configs SET is_active = false WHERE role_id = $1 AND is_active = true") sqlx::query("UPDATE role_runtime_configs SET is_active = false WHERE role_id = $1 AND is_active = true")
.bind(role_id) .bind(role_id)
.execute(&state.pool) .execute(&state.pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Activate target // Activate target
sqlx::query("UPDATE runtime_configs SET is_active = true WHERE id = $1") sqlx::query("UPDATE role_runtime_configs SET is_active = true WHERE id = $1")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
.await .await
@ -222,7 +222,7 @@ async fn delete_runtime_config(
if let Err(_e) = require_admin(&auth) { if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
} }
let result = sqlx::query("DELETE FROM runtime_configs WHERE id = $1") let result = sqlx::query("DELETE FROM role_runtime_configs WHERE id = $1")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
.await .await
@ -232,11 +232,21 @@ async fn delete_runtime_config(
} }
Ok((StatusCode::NO_CONTENT, "".to_string())) Ok((StatusCode::NO_CONTENT, "".to_string()))
} }
#[derive(Deserialize)]
struct RuntimeConfigQuery {
role: Option<String>,
}
async fn get_my_runtime_config( async fn get_my_runtime_config(
auth: contracts::auth_middleware::AuthUser, auth: contracts::auth_middleware::AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<RuntimeConfigQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
let role_key = auth.claims.active_role.clone().to_uppercase(); // Allow frontend to override role via ?role= query param (falls back to JWT claim)
let role_key = q.role
.map(|r| r.to_uppercase())
.filter(|r| !r.is_empty())
.unwrap_or_else(|| auth.claims.active_role.clone().to_uppercase());
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
#[allow(dead_code)] #[allow(dead_code)]
@ -297,7 +307,7 @@ async fn get_my_runtime_config(
if role.audience == "INTERNAL" { if role.audience == "INTERNAL" {
let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>( let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key", "SELECT permission_key FROM role_admin_permissions WHERE role_id = $1 ORDER BY permission_key",
) )
.bind(role.id) .bind(role.id)
.fetch_all(&state.pool) .fetch_all(&state.pool)

View file

@ -32,6 +32,7 @@ struct ExternalRoleRow {
id: Uuid, id: Uuid,
name: String, name: String,
code: String, code: String,
persona_type: Option<String>,
vertical: Option<String>, vertical: Option<String>,
category: Option<String>, category: Option<String>,
onboarding_schema_id: Option<String>, onboarding_schema_id: Option<String>,
@ -61,6 +62,7 @@ struct ExternalRoleListRow {
id: Uuid, id: Uuid,
name: String, name: String,
code: String, code: String,
persona_type: Option<String>,
is_active: bool, is_active: bool,
created_date: chrono::DateTime<chrono::Utc>, created_date: chrono::DateTime<chrono::Utc>,
updated_at: Option<chrono::DateTime<chrono::Utc>>, updated_at: Option<chrono::DateTime<chrono::Utc>>,
@ -89,13 +91,13 @@ async fn list_external_roles(
r.id, r.id,
r.name, r.name,
r.key as code, r.key as code,
r.persona_type,
r.is_active, r.is_active,
r.created_at as created_date, r.created_at as created_date,
rc.updated_at as "updated_at", rc.updated_at as "updated_at",
rc.config_json as "config_json" rc.config_json as "config_json"
FROM roles r FROM roles r
JOIN external_roles er ON er.role_id = r.id LEFT JOIN role_runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
LEFT JOIN runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
WHERE r.audience = 'EXTERNAL' WHERE r.audience = 'EXTERNAL'
AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%') AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%')
AND ($2 = '' OR (CASE WHEN $2 = 'ACTIVE' THEN r.is_active ELSE NOT r.is_active END)) AND ($2 = '' OR (CASE WHEN $2 = 'ACTIVE' THEN r.is_active ELSE NOT r.is_active END))
@ -115,7 +117,6 @@ async fn list_external_roles(
r#" r#"
SELECT COUNT(*) SELECT COUNT(*)
FROM roles r FROM roles r
JOIN external_roles er ON er.role_id = r.id
WHERE r.audience = 'EXTERNAL' WHERE r.audience = 'EXTERNAL'
AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%') AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%')
AND ($2 = '' OR (CASE WHEN $2 = 'ACTIVE' THEN r.is_active ELSE NOT r.is_active END)) AND ($2 = '' OR (CASE WHEN $2 = 'ACTIVE' THEN r.is_active ELSE NOT r.is_active END))
@ -155,7 +156,7 @@ async fn list_external_roles(
continue; continue;
} }
let assigned_users: i64 = sqlx::query_scalar::<_, i64>( let assigned_users: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM user_roles WHERE role_id = $1 AND status = 'APPROVED'", "SELECT COUNT(*) FROM user_role_assignments WHERE role_id = $1 AND status = 'APPROVED'",
) )
.bind(row.id) .bind(row.id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
@ -166,6 +167,7 @@ async fn list_external_roles(
id: row.id, id: row.id,
name: row.name, name: row.name,
code: row.code, code: row.code,
persona_type: row.persona_type.or(vertical_v.clone()),
vertical: vertical_v, vertical: vertical_v,
category: category_v, category: category_v,
onboarding_schema_id, onboarding_schema_id,
@ -223,8 +225,7 @@ async fn get_external_role(
SELECT r.id, r.name, r.key as code, r.audience, r.is_active, r.created_at, SELECT r.id, r.name, r.key as code, r.audience, r.is_active, r.created_at,
rc.updated_at as updated_at, rc.config_json as config_json rc.updated_at as updated_at, rc.config_json as config_json
FROM roles r FROM roles r
JOIN external_roles er ON er.role_id = r.id LEFT JOIN role_runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
LEFT JOIN runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
WHERE r.id = $1 AND r.audience = 'EXTERNAL' WHERE r.id = $1 AND r.audience = 'EXTERNAL'
"#, "#,
) )
@ -251,7 +252,8 @@ struct CreateExternalRolePayload {
name: String, name: String,
code: String, code: String,
is_active: Option<bool>, is_active: Option<bool>,
runtime: JsonValue, persona_type: Option<String>,
runtime: Option<JsonValue>,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@ -280,35 +282,29 @@ async fn create_external_role(
let is_active = payload.is_active.unwrap_or(true); let is_active = payload.is_active.unwrap_or(true);
let role = sqlx::query_as::<_, InsertedRole>( let role = sqlx::query_as::<_, InsertedRole>(
r#" r#"
INSERT INTO roles (key, name, audience, is_active) INSERT INTO roles (key, name, audience, is_active, persona_type)
VALUES ($1, $2, 'EXTERNAL', $3) VALUES ($1, $2, 'EXTERNAL', $3, $4)
RETURNING id, key, name, audience, is_active, created_at RETURNING id, key, name, audience, is_active, created_at
"#, "#,
) )
.bind(payload.code.to_uppercase()) .bind(payload.code.to_uppercase())
.bind(&payload.name) .bind(&payload.name)
.bind(is_active) .bind(is_active)
.bind(&payload.persona_type)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
sqlx::query( let runtime = payload.runtime.unwrap_or_else(|| serde_json::json!({}));
"INSERT INTO external_roles (role_id) VALUES ($1)",
)
.bind(role.id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let rc = sqlx::query_as::<_, InsertedRc>( let rc = sqlx::query_as::<_, InsertedRc>(
r#" r#"
INSERT INTO runtime_configs (role_id, config_json, version, is_active) INSERT INTO role_runtime_configs (role_id, config_json, version, is_active)
VALUES ($1, $2, 1, true) VALUES ($1, $2, 1, true)
RETURNING updated_at RETURNING updated_at
"#, "#,
) )
.bind(role.id) .bind(role.id)
.bind(&payload.runtime) .bind(&runtime)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -321,7 +317,7 @@ async fn create_external_role(
code: role.key, code: role.key,
audience: role.audience, audience: role.audience,
is_active: role.is_active, is_active: role.is_active,
runtime: payload.runtime, runtime,
created_at: role.created_at, created_at: role.created_at,
updated_at: Some(rc.updated_at), updated_at: Some(rc.updated_at),
}), }),
@ -363,7 +359,7 @@ async fn update_external_role(
if let Some(runtime) = payload.runtime { if let Some(runtime) = payload.runtime {
sqlx::query( sqlx::query(
r#" r#"
UPDATE runtime_configs UPDATE role_runtime_configs
SET is_active = false SET is_active = false
WHERE role_id = $1 AND is_active = true WHERE role_id = $1 AND is_active = true
"#, "#,
@ -374,11 +370,11 @@ async fn update_external_role(
.ok(); .ok();
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO runtime_configs (role_id, config_json, version, is_active) INSERT INTO role_runtime_configs (role_id, config_json, version, is_active)
VALUES ( VALUES (
$1, $1,
$2, $2,
COALESCE((SELECT MAX(version) FROM runtime_configs WHERE role_id = $1), 0) + 1, COALESCE((SELECT MAX(version) FROM role_runtime_configs WHERE role_id = $1), 0) + 1,
true true
) )
"#, "#,

View file

@ -8,6 +8,7 @@ pub mod config;
pub mod coupons; pub mod coupons;
pub mod dashboard; pub mod dashboard;
pub mod kb; pub mod kb;
pub mod modules;
pub mod notifications; pub mod notifications;
pub mod onboarding; pub mod onboarding;
pub mod permissions; pub mod permissions;

View file

@ -0,0 +1,263 @@
use crate::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::types::Uuid;
use contracts::auth_middleware::AuthUser;
pub fn persona_types_router() -> Router<AppState> {
Router::new()
.route("/api/admin/persona-types", get(list_persona_types))
}
pub fn modules_router() -> Router<AppState> {
Router::new()
.route("/api/admin/modules", get(list_modules))
}
pub fn role_modules_router() -> Router<AppState> {
Router::new()
.route("/api/admin/roles/{id}/modules", get(get_role_modules).post(add_role_module))
.route("/api/admin/roles/{id}/modules/{module_id}", axum::routing::delete(remove_role_module))
.route("/api/admin/roles/{id}/permissions", get(get_role_permissions).put(update_role_permission))
}
#[derive(Serialize, sqlx::FromRow)]
struct PersonaTypeRow {
id: Uuid,
code: String,
name: String,
description: Option<String>,
is_active: bool,
}
async fn list_persona_types(
_auth: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let rows = sqlx::query_as::<_, PersonaTypeRow>(
"SELECT id, code, name, description, is_active FROM persona_types WHERE is_active = true ORDER BY name",
)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(rows))
}
#[derive(Serialize, sqlx::FromRow)]
struct ModuleRow {
id: Uuid,
module_key: String,
module_name: String,
category: String,
description: Option<String>,
backend_domain: Option<String>,
default_route: Option<String>,
default_sidebar_label: Option<String>,
icon_key: Option<String>,
is_core: bool,
is_active: bool,
}
async fn list_modules(
_auth: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let rows = sqlx::query_as::<_, ModuleRow>(
r#"
SELECT id, module_key, module_name, category, description,
backend_domain, default_route, default_sidebar_label,
icon_key, is_core, is_active
FROM modules
WHERE is_active = true
ORDER BY category, module_name
"#,
)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(rows))
}
#[derive(Serialize, sqlx::FromRow)]
struct RoleModuleAccessRow {
id: Uuid,
module_id: Uuid,
module_key: String,
module_name: String,
is_enabled: bool,
is_sidebar_visible: bool,
sidebar_label_override: Option<String>,
route_override: Option<String>,
}
async fn get_role_modules(
_auth: AuthUser,
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let rows = sqlx::query_as::<_, RoleModuleAccessRow>(
r#"
SELECT rma.id, rma.module_id, m.module_key, m.module_name,
rma.is_enabled, rma.is_sidebar_visible,
rma.sidebar_label_override, rma.route_override
FROM role_module_access rma
JOIN modules m ON m.id = rma.module_id
WHERE rma.role_id = $1
ORDER BY m.category, m.module_name
"#,
)
.bind(role_id)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(rows))
}
#[derive(Deserialize)]
struct AddModulePayload {
module_id: Uuid,
is_enabled: Option<bool>,
is_sidebar_visible: Option<bool>,
sidebar_label_override: Option<String>,
route_override: Option<String>,
}
async fn add_role_module(
_auth: AuthUser,
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
Json(payload): Json<AddModulePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let is_enabled = payload.is_enabled.unwrap_or(true);
let is_sidebar_visible = payload.is_sidebar_visible.unwrap_or(true);
sqlx::query(
r#"
INSERT INTO role_module_access (role_id, module_id, is_enabled, is_sidebar_visible, sidebar_label_override, route_override)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (role_id, module_id) DO UPDATE SET
is_enabled = EXCLUDED.is_enabled,
is_sidebar_visible = EXCLUDED.is_sidebar_visible,
sidebar_label_override = EXCLUDED.sidebar_label_override,
route_override = EXCLUDED.route_override
"#,
)
.bind(role_id)
.bind(payload.module_id)
.bind(is_enabled)
.bind(is_sidebar_visible)
.bind(&payload.sidebar_label_override)
.bind(&payload.route_override)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(StatusCode::CREATED)
}
async fn remove_role_module(
_auth: AuthUser,
State(state): State<AppState>,
Path((role_id, module_id)): Path<(Uuid, Uuid)>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let result = sqlx::query(
"DELETE FROM role_module_access WHERE role_id = $1 AND module_id = $2",
)
.bind(role_id)
.bind(module_id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Module access not found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize, sqlx::FromRow)]
struct RolePermissionRow {
id: Uuid,
module_id: Uuid,
module_key: String,
module_name: String,
category: String,
can_view: bool,
can_list: bool,
can_create: bool,
can_update: bool,
can_delete: bool,
}
async fn get_role_permissions(
_auth: AuthUser,
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let rows = sqlx::query_as::<_, RolePermissionRow>(
r#"
SELECT rmp.id, rmp.module_id, m.module_key, m.module_name, m.category,
rmp.can_view, rmp.can_list, rmp.can_create, rmp.can_update, rmp.can_delete
FROM role_module_permissions rmp
JOIN modules m ON m.id = rmp.module_id
WHERE rmp.role_id = $1
ORDER BY m.category, m.module_name
"#,
)
.bind(role_id)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(rows))
}
#[derive(Deserialize)]
struct UpdatePermissionPayload {
module_key: String,
permission: String,
enabled: bool,
}
async fn update_role_permission(
_auth: AuthUser,
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
Json(payload): Json<UpdatePermissionPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let permission_col = match payload.permission.as_str() {
"view" => "can_view",
"list" => "can_list",
"create" => "can_create",
"update" => "can_update",
"delete" => "can_delete",
_ => return Err((StatusCode::BAD_REQUEST, "Invalid permission type".to_string())),
};
sqlx::query(&format!(
r#"
UPDATE role_module_permissions
SET {} = $1
WHERE role_id = $2 AND module_id = (SELECT id FROM modules WHERE module_key = $3)
"#,
permission_col
))
.bind(payload.enabled)
.bind(role_id)
.bind(&payload.module_key)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(StatusCode::OK)
}

View file

@ -209,7 +209,7 @@ async fn submit(
// 3. Mark the user_role as PENDING (awaiting admin review of onboarding) // 3. Mark the user_role as PENDING (awaiting admin review of onboarding)
sqlx::query( sqlx::query(
r#" r#"
UPDATE user_roles UPDATE user_role_assignments
SET status = 'PENDING' SET status = 'PENDING'
WHERE user_id = $1 AND role_id = $2 WHERE user_id = $1 AND role_id = $2
"#, "#,

View file

@ -37,6 +37,7 @@ const MODULES: &[&str] = &[
"Social Media Management", "Social Media Management",
"Video Editor Management", "Video Editor Management",
"Catering Services Management", "Catering Services Management",
"UGC Content Creator Management",
"Jobs Management", "Jobs Management",
"Leads Management", "Leads Management",
"Applications Management", "Applications Management",
@ -49,11 +50,15 @@ const MODULES: &[&str] = &[
"Tax Management", "Tax Management",
"Order Management", "Order Management",
"Invoice Management", "Invoice Management",
"Payment Gateway Management",
"Ledger Management", "Ledger Management",
"Knowledge Base Management", "Knowledge Base Management",
"Support Management", "Support Management",
"Report Management", "Report Management",
"SMTP Management",
"Email Management",
"Notifications", "Notifications",
"Dashboard",
]; ];
const ACTIONS: &[&str] = &["View", "Create", "Update", "Delete"]; const ACTIONS: &[&str] = &["View", "Create", "Update", "Delete"];

View file

@ -342,7 +342,7 @@ async fn submit_for_verification(
// Mark user_role as PENDING // Mark user_role as PENDING
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await { if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
sqlx::query( sqlx::query(
"UPDATE user_roles SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2", "UPDATE user_role_assignments SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2",
) )
.bind(auth.user_id) .bind(auth.user_id)
.bind(role.id) .bind(role.id)

View file

@ -164,10 +164,10 @@ async fn list_roles(
COUNT(DISTINCT e.id) AS users_assigned, COUNT(DISTINCT e.id) AS users_assigned,
COUNT(DISTINCT rp.id) AS permissions_count COUNT(DISTINCT rp.id) AS permissions_count
FROM roles r FROM roles r
JOIN internal_roles ir ON ir.role_id = r.id JOIN internal_role_details ir ON ir.role_id = r.id
LEFT JOIN departments d ON d.id = ir.department_id LEFT JOIN departments d ON d.id = ir.department_id
LEFT JOIN employees e ON e.role_code = r.key LEFT JOIN employees e ON e.role_code = r.key
LEFT JOIN role_permissions rp ON rp.role_id = r.id LEFT JOIN role_admin_permissions rp ON rp.role_id = r.id
WHERE r.audience = 'INTERNAL' WHERE r.audience = 'INTERNAL'
AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%') AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%')
GROUP BY r.id, ir.description, ir.department_id, ir.can_approve_requests, ir.can_manage_system_settings, d.name GROUP BY r.id, ir.description, ir.department_id, ir.can_approve_requests, ir.can_manage_system_settings, d.name
@ -185,7 +185,7 @@ async fn list_roles(
let total: i64 = sqlx::query_scalar::<_, i64>( let total: i64 = sqlx::query_scalar::<_, i64>(
r#" r#"
SELECT COUNT(*) FROM roles r SELECT COUNT(*) FROM roles r
JOIN internal_roles ir ON ir.role_id = r.id JOIN internal_role_details ir ON ir.role_id = r.id
WHERE r.audience = 'INTERNAL' WHERE r.audience = 'INTERNAL'
AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%') AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%')
"#, "#,
@ -232,7 +232,7 @@ async fn get_role(
COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings, COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings,
r.created_at r.created_at
FROM roles r FROM roles r
JOIN internal_roles ir ON ir.role_id = r.id JOIN internal_role_details ir ON ir.role_id = r.id
LEFT JOIN departments d ON d.id = ir.department_id LEFT JOIN departments d ON d.id = ir.department_id
WHERE r.id = $1 AND r.audience = 'INTERNAL' WHERE r.id = $1 AND r.audience = 'INTERNAL'
"#, "#,
@ -244,7 +244,7 @@ async fn get_role(
.ok_or((StatusCode::NOT_FOUND, "Role not found".to_string()))?; .ok_or((StatusCode::NOT_FOUND, "Role not found".to_string()))?;
let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>( let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key", "SELECT permission_key FROM role_admin_permissions WHERE role_id = $1 ORDER BY permission_key",
) )
.bind(id) .bind(id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
@ -291,7 +291,7 @@ async fn create_role(
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO internal_roles (role_id, description, department_id, can_approve_requests, can_manage_system_settings) INSERT INTO internal_role_details (role_id, description, department_id, can_approve_requests, can_manage_system_settings)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
"#, "#,
) )
@ -307,7 +307,7 @@ async fn create_role(
if let Some(keys) = &payload.permission_keys { if let Some(keys) = &payload.permission_keys {
for key in keys { for key in keys {
sqlx::query( sqlx::query(
"INSERT INTO role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING", "INSERT INTO role_admin_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
) )
.bind(role.id) .bind(role.id)
.bind(key) .bind(key)
@ -318,7 +318,7 @@ async fn create_role(
} }
let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>( let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key", "SELECT permission_key FROM role_admin_permissions WHERE role_id = $1 ORDER BY permission_key",
) )
.bind(role.id) .bind(role.id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
@ -355,7 +355,7 @@ async fn update_role(
COALESCE(ir.can_approve_requests, false) AS can_approve_requests, COALESCE(ir.can_approve_requests, false) AS can_approve_requests,
COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings
FROM roles r FROM roles r
JOIN internal_roles ir ON ir.role_id = r.id JOIN internal_role_details ir ON ir.role_id = r.id
WHERE r.id = $1 AND r.audience = 'INTERNAL' WHERE r.id = $1 AND r.audience = 'INTERNAL'
"#, "#,
) )
@ -385,7 +385,7 @@ async fn update_role(
sqlx::query( sqlx::query(
r#" r#"
UPDATE internal_roles SET UPDATE internal_role_details SET
description = $1, description = $1,
department_id = $2, department_id = $2,
can_approve_requests = $3, can_approve_requests = $3,
@ -403,7 +403,7 @@ async fn update_role(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
if let Some(keys) = &payload.permission_keys { if let Some(keys) = &payload.permission_keys {
sqlx::query("DELETE FROM role_permissions WHERE role_id = $1") sqlx::query("DELETE FROM role_admin_permissions WHERE role_id = $1")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
.await .await
@ -411,7 +411,7 @@ async fn update_role(
for key in keys { for key in keys {
sqlx::query( sqlx::query(
"INSERT INTO role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING", "INSERT INTO role_admin_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
) )
.bind(id) .bind(id)
.bind(key) .bind(key)

View file

@ -60,7 +60,7 @@ async fn list_my_roles(
let rows = sqlx::query_as::<_, UserRoleRow>( let rows = sqlx::query_as::<_, UserRoleRow>(
r#" r#"
SELECT r.key, r.name, ur.status, ur.approved_at SELECT r.key, r.name, ur.status, ur.approved_at
FROM user_roles ur FROM user_role_assignments ur
INNER JOIN roles r ON r.id = ur.role_id INNER JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = $1 WHERE ur.user_id = $1
ORDER BY ur.created_at ASC ORDER BY ur.created_at ASC
@ -100,7 +100,7 @@ async fn register_role(
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO user_roles (user_id, role_id, status, approved_at) INSERT INTO user_role_assignments (user_id, role_id, status, approved_at)
VALUES ($1, $2, 'APPROVED', NOW()) VALUES ($1, $2, 'APPROVED', NOW())
ON CONFLICT (user_id, role_id) ON CONFLICT (user_id, role_id)
DO UPDATE SET status = 'APPROVED', approved_at = NOW() DO UPDATE SET status = 'APPROVED', approved_at = NOW()

View file

@ -60,6 +60,9 @@ async fn main() {
.nest("/api/admin/roles", handlers::roles::router()) .nest("/api/admin/roles", handlers::roles::router())
.nest("/api/admin/permissions", handlers::permissions::router()) .nest("/api/admin/permissions", handlers::permissions::router())
.nest("/api/admin/external-roles", handlers::external_roles::router()) .nest("/api/admin/external-roles", handlers::external_roles::router())
.merge(handlers::modules::persona_types_router())
.merge(handlers::modules::modules_router())
.merge(handlers::modules::role_modules_router())
.nest("/api/admin/users", handlers::admin::router()) .nest("/api/admin/users", handlers::admin::router())
.nest("/api/me/roles", handlers::user_roles::router()) .nest("/api/me/roles", handlers::user_roles::router())
// ── Notifications ───────────────────────────────────────────────── // ── Notifications ─────────────────────────────────────────────────

View file

@ -0,0 +1,15 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, SaltString},
Argon2,
};
fn main() {
let password = std::env::args().nth(1).unwrap_or_default();
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hashed = argon2
.hash_password(password.as_bytes(), &salt)
.unwrap()
.to_string();
println!("{}", hashed);
}

View file

@ -312,7 +312,7 @@ async fn list_portfolio(State(state): State<ProfessionState>, auth: AuthUser) ->
Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(), Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}, },
Err(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), Err(_) => (StatusCode::OK, Json(serde_json::json!({ "data": [] }))).into_response(),
} }
} }
@ -322,13 +322,17 @@ async fn list_services(State(state): State<ProfessionState>, auth: AuthUser) ->
Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(), Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}, },
Err(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), Err(_) => (StatusCode::OK, Json(serde_json::json!({ "data": [] }))).into_response(),
} }
} }
async fn wallet_balance(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse { async fn wallet_balance(State(state): State<ProfessionState>, auth: AuthUser) -> impl IntoResponse {
let _ = ProfessionalRepository::ensure_wallet(&state.pool, auth.user_id).await;
match ProfessionalRepository::get_wallet(&state.pool, auth.user_id).await { match ProfessionalRepository::get_wallet(&state.pool, auth.user_id).await {
Ok(w) => (StatusCode::OK, Json(w)).into_response(), Ok(w) => (StatusCode::OK, Json(w)).into_response(),
Err(sqlx::Error::RowNotFound) => {
(StatusCode::OK, Json(serde_json::json!({ "balance": 0, "reserved": 0 }))).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
} }
@ -349,7 +353,13 @@ async fn my_requests(
) -> impl IntoResponse { ) -> impl IntoResponse {
let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(p) => p, Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), Err(_) => return (
StatusCode::OK,
Json(serde_json::json!({
"data": [],
"pagination": { "page": 1, "limit": 20, "total": 0, "total_pages": 1 }
}))
).into_response(),
}; };
let page = q.page.unwrap_or(1).max(1); let page = q.page.unwrap_or(1).max(1);
@ -381,7 +391,7 @@ async fn my_requests(
LEFT JOIN requirements r ON r.id = lr.requirement_id LEFT JOIN requirements r ON r.id = lr.requirement_id
LEFT JOIN customers c ON c.id = r.customer_id LEFT JOIN customers c ON c.id = r.customer_id
LEFT JOIN users u ON u.id = c.user_id LEFT JOIN users u ON u.id = c.user_id
WHERE lr.professional_id = $1 AND lr.status = $2 WHERE lr.user_role_profile_id = $1 AND lr.status = $2
ORDER BY lr.requested_at DESC LIMIT $3 OFFSET $4 ORDER BY lr.requested_at DESC LIMIT $3 OFFSET $4
"# "#
) )
@ -397,7 +407,7 @@ async fn my_requests(
LEFT JOIN requirements r ON r.id = lr.requirement_id LEFT JOIN requirements r ON r.id = lr.requirement_id
LEFT JOIN customers c ON c.id = r.customer_id LEFT JOIN customers c ON c.id = r.customer_id
LEFT JOIN users u ON u.id = c.user_id LEFT JOIN users u ON u.id = c.user_id
WHERE lr.professional_id = $1 WHERE lr.user_role_profile_id = $1
ORDER BY lr.requested_at DESC LIMIT $2 OFFSET $3 ORDER BY lr.requested_at DESC LIMIT $2 OFFSET $3
"# "#
) )
@ -405,10 +415,10 @@ async fn my_requests(
}; };
let total: i64 = if let Some(ref status) = q.status { let total: i64 = if let Some(ref status) = q.status {
sqlx::query_scalar("SELECT COUNT(*) FROM lead_requests WHERE professional_id = $1 AND status = $2") sqlx::query_scalar("SELECT COUNT(*) FROM lead_requests WHERE user_role_profile_id = $1 AND status = $2")
.bind(prof.id).bind(status).fetch_one(&state.pool).await.unwrap_or(0) .bind(prof.id).bind(status).fetch_one(&state.pool).await.unwrap_or(0)
} else { } else {
sqlx::query_scalar("SELECT COUNT(*) FROM lead_requests WHERE professional_id = $1") sqlx::query_scalar("SELECT COUNT(*) FROM lead_requests WHERE user_role_profile_id = $1")
.bind(prof.id).fetch_one(&state.pool).await.unwrap_or(0) .bind(prof.id).fetch_one(&state.pool).await.unwrap_or(0)
}; };
@ -478,7 +488,13 @@ async fn accepted_leads(
) -> impl IntoResponse { ) -> impl IntoResponse {
let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, "PHOTOGRAPHER").await { let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, "PHOTOGRAPHER").await {
Ok(Some(p)) => p, Ok(Some(p)) => p,
Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), Ok(None) => return (
StatusCode::OK,
Json(serde_json::json!({
"data": [],
"pagination": { "page": 1, "limit": 20, "total": 0, "total_pages": 1 }
}))
).into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
}; };
@ -575,7 +591,7 @@ async fn accepted_lead_detail(
INNER JOIN customers c ON c.id = r.customer_id INNER JOIN customers c ON c.id = r.customer_id
INNER JOIN users u ON u.id = c.user_id INNER JOIN users u ON u.id = c.user_id
WHERE lr.id = $1 WHERE lr.id = $1
AND lr.professional_id = $2 AND lr.user_role_profile_id = $2
AND lr.status = 'ACCEPTED' AND lr.status = 'ACCEPTED'
"# "#
) )

View file

@ -0,0 +1,127 @@
-- Phase 1: External Role Management Module System
-- Creates base schema for persona_types, external_roles, modules, role_module_access, module_actions, role_module_permissions
-- ============================================
-- persona_types
-- ============================================
CREATE TABLE IF NOT EXISTS persona_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(50) UNIQUE NOT NULL,
name varchar(100) NOT NULL,
description text,
is_active boolean DEFAULT true,
created_at timestamptz DEFAULT NOW(),
updated_at timestamptz DEFAULT NOW()
);
-- ============================================
-- external_roles
-- ============================================
CREATE TABLE IF NOT EXISTS external_roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
role_code varchar(50) UNIQUE NOT NULL,
role_name varchar(100) NOT NULL,
persona_type_id uuid REFERENCES persona_types(id),
description text,
is_active boolean DEFAULT true,
onboarding_schema_key varchar(100),
verification_required boolean DEFAULT true,
switch_services_enabled boolean DEFAULT false,
is_publicly_discoverable boolean DEFAULT true,
sort_order integer DEFAULT 0,
created_at timestamptz DEFAULT NOW(),
updated_at timestamptz DEFAULT NOW()
);
CREATE INDEX idx_external_roles_persona ON external_roles(persona_type_id);
CREATE INDEX idx_external_roles_active ON external_roles(is_active);
-- ============================================
-- modules
-- ============================================
CREATE TABLE IF NOT EXISTS modules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
module_key varchar(50) UNIQUE NOT NULL,
module_name varchar(100) NOT NULL,
category varchar(50), -- core/content/marketplace/work/financial
description text,
backend_domain varchar(100),
default_route varchar(255),
default_sidebar_label varchar(100),
icon_key varchar(50),
is_core boolean DEFAULT false,
is_active boolean DEFAULT true,
created_at timestamptz DEFAULT NOW(),
updated_at timestamptz DEFAULT NOW()
);
CREATE INDEX idx_modules_category ON modules(category);
CREATE INDEX idx_modules_active ON modules(is_active);
-- ============================================
-- role_module_access
-- ============================================
CREATE TABLE IF NOT EXISTS role_module_access (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
external_role_id uuid NOT NULL REFERENCES external_roles(id) ON DELETE CASCADE,
module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
is_enabled boolean DEFAULT true,
is_sidebar_visible boolean DEFAULT true,
sidebar_label_override varchar(100),
route_override varchar(255),
sort_order integer DEFAULT 0,
created_at timestamptz DEFAULT NOW(),
UNIQUE(external_role_id, module_id)
);
CREATE INDEX idx_role_module_access_role ON role_module_access(external_role_id);
-- ============================================
-- module_actions
-- ============================================
CREATE TABLE IF NOT EXISTS module_actions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
action_key varchar(50) NOT NULL,
action_name varchar(100) NOT NULL,
description text,
is_active boolean DEFAULT true,
created_at timestamptz DEFAULT NOW(),
UNIQUE(module_id, action_key)
);
CREATE INDEX idx_module_actions_module ON module_actions(module_id);
-- ============================================
-- role_module_permissions
-- ============================================
CREATE TABLE IF NOT EXISTS role_module_permissions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
external_role_id uuid NOT NULL REFERENCES external_roles(id) ON DELETE CASCADE,
module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
can_view boolean DEFAULT false,
can_list boolean DEFAULT false,
can_create boolean DEFAULT false,
can_update boolean DEFAULT false,
can_delete boolean DEFAULT false,
extra_actions_json jsonb DEFAULT '{}',
created_at timestamptz DEFAULT NOW(),
UNIQUE(external_role_id, module_id)
);
CREATE INDEX idx_role_module_permissions_role ON role_module_permissions(external_role_id);
-- ============================================
-- role_module_widgets
-- ============================================
CREATE TABLE IF NOT EXISTS role_module_widgets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
external_role_id uuid NOT NULL REFERENCES external_roles(id) ON DELETE CASCADE,
module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
widget_key varchar(50),
is_enabled boolean DEFAULT true,
sort_order integer DEFAULT 0,
created_at timestamptz DEFAULT NOW()
);
CREATE INDEX idx_role_module_widgets_role ON role_module_widgets(external_role_id);

View file

@ -0,0 +1,21 @@
-- Rollback Phase 1 cleanup
-- ============================================
-- RECREATE: external_roles table
-- ============================================
CREATE TABLE external_roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX external_roles_role_id_key ON external_roles(role_id);
-- ============================================
-- RENAME BACK: Tables to original names
-- ============================================
ALTER TABLE internal_role_details RENAME TO internal_roles;
ALTER TABLE role_admin_permissions RENAME TO role_permissions;
ALTER TABLE permission_definitions RENAME TO permissions;
ALTER TABLE role_sidebar_configs RENAME TO dashboard_configs;
ALTER TABLE role_runtime_configs RENAME TO runtime_configs;
ALTER TABLE user_role_assignments RENAME TO user_roles;
ALTER TABLE role_dashboard_widgets RENAME TO dashboard_widgets;

View file

@ -0,0 +1,45 @@
-- Phase 1: Database cleanup - Drop redundant tables and rename for admin clarity
-- Date: 2026-04-20
-- ============================================
-- DROP: Remove redundant external_roles table
-- Reason: roles.audience = 'EXTERNAL' already identifies external roles
-- This table just adds a 1:1 mapping with no extra fields
-- ============================================
DROP TABLE IF EXISTS external_roles;
-- ============================================
-- RENAME: Tables for admin clarity
-- ============================================
-- internal_roles → internal_role_details
ALTER TABLE internal_roles RENAME TO internal_role_details;
-- role_permissions → role_admin_permissions
ALTER TABLE role_permissions RENAME TO role_admin_permissions;
-- permissions → permission_definitions
ALTER TABLE permissions RENAME TO permission_definitions;
-- dashboard_configs → role_sidebar_configs
ALTER TABLE dashboard_configs RENAME TO role_sidebar_configs;
-- runtime_configs → role_runtime_configs
ALTER TABLE runtime_configs RENAME TO role_runtime_configs;
-- user_roles → user_role_assignments
ALTER TABLE user_roles RENAME TO user_role_assignments;
-- dashboard_widgets → role_dashboard_widgets
ALTER TABLE dashboard_widgets RENAME TO role_dashboard_widgets;
-- ============================================
-- UPDATE: Sequences for renamed tables
-- ============================================
ALTER SEQUENCE internal_roles_id_seq RENAME TO internal_role_details_id_seq;
ALTER SEQUENCE role_permissions_id_seq RENAME TO role_admin_permissions_id_seq;
ALTER SEQUENCE permissions_id_seq RENAME TO permission_definitions_id_seq;
ALTER SEQUENCE dashboard_configs_id_seq RENAME TO role_sidebar_configs_id_seq;
ALTER SEQUENCE runtime_configs_id_seq RENAME TO role_runtime_configs_id_seq;
ALTER SEQUENCE user_roles_id_seq RENAME TO user_role_assignments_id_seq;
ALTER SEQUENCE dashboard_widgets_id_seq RENAME TO role_dashboard_widgets_id_seq;

View file

@ -0,0 +1,24 @@
-- Rollback Phase 3: External Role Management - Module System
-- ============================================
-- DROP: New module system tables
-- ============================================
DROP TABLE IF EXISTS role_module_variant_mapping;
DROP TABLE IF EXISTS module_variants;
DROP TABLE IF EXISTS role_module_widgets;
DROP TABLE IF EXISTS role_module_permissions;
DROP TABLE IF EXISTS module_actions;
DROP TABLE IF EXISTS role_module_access;
DROP TABLE IF EXISTS modules;
DROP TABLE IF EXISTS persona_types;
-- ============================================
-- REMOVE COLUMNS FROM ROLES
-- ============================================
ALTER TABLE roles DROP COLUMN IF EXISTS persona_type;
ALTER TABLE roles DROP COLUMN IF EXISTS onboarding_schema_key;
ALTER TABLE roles DROP COLUMN IF EXISTS verification_required;
ALTER TABLE roles DROP COLUMN IF EXISTS switch_services_enabled;
ALTER TABLE roles DROP COLUMN IF EXISTS is_publicly_discoverable;
ALTER TABLE roles DROP COLUMN IF EXISTS external_role_description;
ALTER TABLE roles DROP COLUMN IF EXISTS sort_order;

View file

@ -0,0 +1,149 @@
-- Seed data for External Role Management Module System
-- Phase 3 seed
-- ============================================
-- SEED: Persona Types
-- ============================================
INSERT INTO persona_types (code, name, description) VALUES
('PROFESSIONAL', 'Professional', 'Freelance professionals offering services'),
('COMPANY', 'Company', 'Business accounts posting jobs'),
('JOB_SEEKER', 'Job Seeker', 'Individuals seeking employment'),
('CUSTOMER', 'Customer', 'Customers seeking services')
ON CONFLICT (code) DO NOTHING;
-- ============================================
-- SEED: Modules (23 total)
-- ============================================
-- Core Shared Modules
INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key, is_core) VALUES
('dashboard_home', 'Dashboard', 'core', 'Dashboard home page with KPIs and widgets', '/dashboard', 'My Dashboard', 'layout-dashboard', true),
('profile', 'Profile', 'core', 'User profile management', '/dashboard/profile', 'My Profile', 'user', true),
('verification', 'Verification', 'core', 'Verification status and documents', '/dashboard/verification', 'Verification', 'shield-check', true),
('help_center', 'Help Center', 'core', 'Help and support', '/dashboard/help', 'Help Center', 'help-circle', true),
('settings', 'Settings', 'core', 'Account settings', '/dashboard/settings', 'Settings', 'settings', true),
('switch_services', 'Switch Services', 'core', 'Switch to different roles', '/dashboard/switch', 'Switch Services', 'repeat', true),
('explore_nxtgauge', 'Explore Nxtgauge', 'core', 'Register for additional roles', '/dashboard/explore', 'Explore Nxtgauge', 'compass', true)
ON CONFLICT (module_key) DO NOTHING;
-- Content and Identity Modules
INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key, is_core) VALUES
('portfolio', 'Portfolio', 'content', 'Portfolio showcase', '/dashboard/portfolio', 'My Portfolio', 'image', false),
('services', 'Services', 'content', 'Services offered', '/dashboard/services', 'My Services', 'briefcase', false)
ON CONFLICT (module_key) DO NOTHING;
-- Marketplace and Discovery Modules
INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key, is_core) VALUES
('marketplace', 'Marketplace', 'marketplace', 'Service marketplace', '/dashboard/marketplace', 'Marketplace', 'store', false),
('browse_jobs', 'Browse Jobs', 'marketplace', 'Browse available jobs', '/dashboard/jobs', 'Browse Jobs', 'search', false),
('saved_jobs', 'Saved Jobs', 'marketplace', 'Saved job listings', '/dashboard/saved-jobs', 'Saved Jobs', 'bookmark', false)
ON CONFLICT (module_key) DO NOTHING;
-- Work and Response Modules
INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key, is_core) VALUES
('jobs', 'Jobs', 'work', 'Job postings management', '/dashboard/jobs', 'Jobs', 'briefcase', false),
('applications', 'Applications', 'work', 'Job applications received', '/dashboard/applications', 'Applications', 'file-text', false),
('my_applications', 'My Applications', 'work', 'My job applications', '/dashboard/my-applications', 'My Applications', 'send', false),
('requirements', 'Requirements', 'work', 'Customer requirements', '/dashboard/requirements', 'Requirements', 'list', false),
('leads', 'Leads', 'work', 'Leads and inquiries', '/dashboard/leads', 'Leads', 'zap', false),
('my_responses', 'My Responses', 'work', 'My responses to requirements', '/dashboard/responses', 'My Responses', 'message-circle', false),
('received_responses', 'Received Responses', 'work', 'Responses to my requirements', '/dashboard/received-responses', 'Received Responses', 'inbox', false),
('shortlisted_candidates', 'Shortlisted Candidates', 'work', 'Shortlisted candidates', '/dashboard/shortlisted', 'Shortlisted Candidates', 'users', false),
('shortlisted_responses', 'Shortlisted Responses', 'work', 'Shortlisted responses', '/dashboard/shortlisted-responses', 'Shortlisted Responses', 'star', false)
ON CONFLICT (module_key) DO NOTHING;
-- Financial Modules
INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key, is_core) VALUES
('wallet', 'Wallet', 'financial', 'Wallet and payments', '/dashboard/wallet', 'Wallet', 'wallet', false),
('credits', 'Credits', 'financial', 'Credits management', '/dashboard/credits', 'Credits', 'credit-card', false)
ON CONFLICT (module_key) DO NOTHING;
-- ============================================
-- SEED: Update external roles with persona_type
-- ============================================
UPDATE roles SET persona_type = 'COMPANY' WHERE key = 'COMPANY';
UPDATE roles SET persona_type = 'JOB_SEEKER' WHERE key = 'JOB_SEEKER';
UPDATE roles SET persona_type = 'CUSTOMER' WHERE key = 'CUSTOMER';
UPDATE roles SET persona_type = 'PROFESSIONAL' WHERE key IN ('PHOTOGRAPHER', 'MAKEUP_ARTIST', 'TUTOR', 'DEVELOPER', 'VIDEO_EDITOR', 'GRAPHIC_DESIGNER', 'SOCIAL_MEDIA_MANAGER', 'FITNESS_TRAINER', 'CATERING_SERVICES');
-- ============================================
-- SEED: Module Actions (generic CRUD + domain)
-- ============================================
DO $$
DECLARE
mod_record RECORD;
BEGIN
FOR mod_record IN SELECT id, module_key FROM modules LOOP
-- Generic CRUD actions
INSERT INTO module_actions (module_id, action_key, action_name, description)
VALUES
(mod_record.id, 'view', 'View', 'View ' || mod_record.module_key)
ON CONFLICT DO NOTHING;
INSERT INTO module_actions (module_id, action_key, action_name, description)
VALUES
(mod_record.id, 'list', 'List', 'List ' || mod_record.module_key)
ON CONFLICT DO NOTHING;
INSERT INTO module_actions (module_id, action_key, action_name, description)
VALUES
(mod_record.id, 'create', 'Create', 'Create ' || mod_record.module_key)
ON CONFLICT DO NOTHING;
INSERT INTO module_actions (module_id, action_key, action_name, description)
VALUES
(mod_record.id, 'update', 'Update', 'Update ' || mod_record.module_key)
ON CONFLICT DO NOTHING;
INSERT INTO module_actions (module_id, action_key, action_name, description)
VALUES
(mod_record.id, 'delete', 'Delete', 'Delete ' || mod_record.module_key)
ON CONFLICT DO NOTHING;
END LOOP;
END $$;
-- Domain-specific actions
INSERT INTO module_actions (module_id, action_key, action_name, description)
SELECT m.id, 'publish', 'Publish', 'Publish content'
FROM modules m WHERE m.module_key IN ('portfolio', 'jobs', 'services')
ON CONFLICT DO NOTHING;
INSERT INTO module_actions (module_id, action_key, action_name, description)
SELECT m.id, 'submit', 'Submit', 'Submit for review'
FROM modules m WHERE m.module_key IN ('verification', 'applications', 'requirements')
ON CONFLICT DO NOTHING;
INSERT INTO module_actions (module_id, action_key, action_name, description)
SELECT m.id, 'shortlist', 'Shortlist', 'Shortlist item'
FROM modules m WHERE m.module_key IN ('shortlisted_candidates', 'shortlisted_responses')
ON CONFLICT DO NOTHING;
INSERT INTO module_actions (module_id, action_key, action_name, description)
SELECT m.id, 'respond', 'Respond', 'Respond to requirement'
FROM modules m WHERE m.module_key IN ('my_responses', 'received_responses')
ON CONFLICT DO NOTHING;
INSERT INTO module_actions (module_id, action_key, action_name, description)
SELECT m.id, 'buy_credits', 'Buy Credits', 'Purchase credits'
FROM modules m WHERE m.module_key = 'credits'
ON CONFLICT DO NOTHING;
INSERT INTO module_actions (module_id, action_key, action_name, description)
SELECT m.id, 'request_payout', 'Request Payout', 'Request wallet payout'
FROM modules m WHERE m.module_key = 'wallet'
ON CONFLICT DO NOTHING;
INSERT INTO module_actions (module_id, action_key, action_name, description)
SELECT m.id, 'switch', 'Switch', 'Switch to this service'
FROM modules m WHERE m.module_key = 'switch_services'
ON CONFLICT DO NOTHING;
INSERT INTO module_actions (module_id, action_key, action_name, description)
SELECT m.id, 'start_onboarding', 'Start Onboarding', 'Start new role onboarding'
FROM modules m WHERE m.module_key = 'explore_nxtgauge'
ON CONFLICT DO NOTHING;
INSERT INTO module_actions (module_id, action_key, action_name, description)
SELECT m.id, 'resubmit', 'Resubmit', 'Resubmit for verification'
FROM modules m WHERE m.module_key = 'verification'
ON CONFLICT DO NOTHING;

View file

@ -0,0 +1,120 @@
-- Seed: Default Role Module Access based on spec Section 7
-- Fixed: removed sort_order reference
-- ============================================
-- PROFESSIONAL roles (PHOTOGRAPHER, MAKEUP_ARTIST, TUTOR, DEVELOPER, VIDEO_EDITOR, GRAPHIC_DESIGNER, SOCIAL_MEDIA_MANAGER, FITNESS_TRAINER, CATERING_SERVICES)
-- Enabled: dashboard_home, profile, portfolio, services, marketplace, leads, my_responses, wallet, credits, verification, help_center, settings, switch_services, explore_nxtgauge
-- ============================================
INSERT INTO role_module_access (role_id, module_id, is_enabled, is_sidebar_visible, sort_order)
SELECT r.id, m.id, true, true, 0
FROM roles r
CROSS JOIN modules m
WHERE r.audience = 'EXTERNAL'
AND r.key IN ('PHOTOGRAPHER', 'MAKEUP_ARTIST', 'TUTOR', 'DEVELOPER', 'VIDEO_EDITOR', 'GRAPHIC_DESIGNER', 'SOCIAL_MEDIA_MANAGER', 'FITNESS_TRAINER', 'CATERING_SERVICES')
AND m.module_key IN ('dashboard_home', 'profile', 'portfolio', 'services', 'marketplace', 'leads', 'my_responses', 'wallet', 'credits', 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge')
ON CONFLICT DO NOTHING;
-- ============================================
-- COMPANY role
-- Enabled: dashboard_home, profile, jobs, applications, shortlisted_candidates, credits, verification, help_center, settings, switch_services, explore_nxtgauge
-- ============================================
INSERT INTO role_module_access (role_id, module_id, is_enabled, is_sidebar_visible, sort_order)
SELECT r.id, m.id, true, true, 0
FROM roles r
CROSS JOIN modules m
WHERE r.key = 'COMPANY'
AND m.module_key IN ('dashboard_home', 'profile', 'jobs', 'applications', 'shortlisted_candidates', 'credits', 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge')
ON CONFLICT DO NOTHING;
-- ============================================
-- JOB_SEEKER role
-- Enabled: dashboard_home, profile, portfolio, browse_jobs, my_applications, saved_jobs, verification, help_center, settings, switch_services, explore_nxtgauge
-- ============================================
INSERT INTO role_module_access (role_id, module_id, is_enabled, is_sidebar_visible, sort_order)
SELECT r.id, m.id, true, true, 0
FROM roles r
CROSS JOIN modules m
WHERE r.key = 'JOB_SEEKER'
AND m.module_key IN ('dashboard_home', 'profile', 'portfolio', 'browse_jobs', 'my_applications', 'saved_jobs', 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge')
ON CONFLICT DO NOTHING;
-- ============================================
-- CUSTOMER role
-- Enabled: dashboard_home, profile, requirements, received_responses, shortlisted_responses, credits, verification, help_center, settings, switch_services, explore_nxtgauge
-- ============================================
INSERT INTO role_module_access (role_id, module_id, is_enabled, is_sidebar_visible, sort_order)
SELECT r.id, m.id, true, true, 0
FROM roles r
CROSS JOIN modules m
WHERE r.key = 'CUSTOMER'
AND m.module_key IN ('dashboard_home', 'profile', 'requirements', 'received_responses', 'shortlisted_responses', 'credits', 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge')
ON CONFLICT DO NOTHING;
-- ============================================
-- SEED: Default Role Module Permissions (basic CRUD)
-- All external roles get view/list/create/update on their enabled modules
-- ============================================
INSERT INTO role_module_permissions (role_id, module_id, can_view, can_list, can_create, can_update, can_delete)
SELECT rma.role_id, rma.module_id, true, true, true, true, false
FROM role_module_access rma
JOIN roles r ON r.id = rma.role_id
WHERE r.audience = 'EXTERNAL'
ON CONFLICT DO NOTHING;
-- ============================================
-- SEED: Module Variants for profile and portfolio
-- ============================================
-- Profile variants for PROFESSIONAL roles
INSERT INTO module_variants (module_id, variant_key, variant_name, role_code, persona_type, schema_key, ui_template_key)
SELECT m.id, 'profile.' || LOWER(r.key), 'Profile - ' || r.name, r.key, 'PROFESSIONAL', 'profile_' || LOWER(r.key), 'profile_professional'
FROM modules m
CROSS JOIN roles r
WHERE m.module_key = 'profile'
AND r.persona_type = 'PROFESSIONAL'
ON CONFLICT DO NOTHING;
-- Portfolio variants for PROFESSIONAL roles
INSERT INTO module_variants (module_id, variant_key, variant_name, role_code, persona_type, schema_key, ui_template_key)
SELECT m.id, 'portfolio.' || LOWER(r.key), 'Portfolio - ' || r.name, r.key, 'PROFESSIONAL', 'portfolio_' || LOWER(r.key), 'portfolio_professional'
FROM modules m
CROSS JOIN roles r
WHERE m.module_key = 'portfolio'
AND r.persona_type = 'PROFESSIONAL'
ON CONFLICT DO NOTHING;
-- Profile variant for COMPANY
INSERT INTO module_variants (module_id, variant_key, variant_name, role_code, schema_key, ui_template_key)
SELECT m.id, 'profile.company', 'Profile - Company', 'COMPANY', 'profile_company', 'profile_company'
FROM modules m
WHERE m.module_key = 'profile'
ON CONFLICT DO NOTHING;
-- Profile variant for JOB_SEEKER
INSERT INTO module_variants (module_id, variant_key, variant_name, role_code, schema_key, ui_template_key)
SELECT m.id, 'profile.job_seeker', 'Profile - Job Seeker', 'JOB_SEEKER', 'profile_job_seeker', 'profile_job_seeker'
FROM modules m
WHERE m.module_key = 'profile'
ON CONFLICT DO NOTHING;
-- Profile variant for CUSTOMER
INSERT INTO module_variants (module_id, variant_key, variant_name, role_code, schema_key, ui_template_key)
SELECT m.id, 'profile.customer', 'Profile - Customer', 'CUSTOMER', 'profile_customer', 'profile_customer'
FROM modules m
WHERE m.module_key = 'profile'
ON CONFLICT DO NOTHING;
-- ============================================
-- SEED: Role Module Variant Mappings
-- ============================================
INSERT INTO role_module_variant_mapping (role_id, module_id, module_variant_id)
SELECT r.id, mv.module_id, mv.id
FROM module_variants mv
JOIN roles r ON r.key = mv.role_code
ON CONFLICT DO NOTHING;

View file

@ -0,0 +1,157 @@
-- Phase 3: External Role Management - Module System
-- This migration creates the module registry and role-module mapping
-- Note: external_roles table was dropped - external role settings are now in roles table
-- ============================================
-- ADD COLUMNS TO ROLES for external role settings
-- ============================================
ALTER TABLE roles ADD COLUMN IF NOT EXISTS persona_type varchar(50);
ALTER TABLE roles ADD COLUMN IF NOT EXISTS onboarding_schema_key varchar(100);
ALTER TABLE roles ADD COLUMN IF NOT EXISTS verification_required boolean DEFAULT true;
ALTER TABLE roles ADD COLUMN IF NOT EXISTS switch_services_enabled boolean DEFAULT false;
ALTER TABLE roles ADD COLUMN IF NOT EXISTS is_publicly_discoverable boolean DEFAULT true;
ALTER TABLE roles ADD COLUMN IF NOT EXISTS external_role_description text;
ALTER TABLE roles ADD COLUMN IF NOT EXISTS sort_order integer DEFAULT 0;
-- ============================================
-- persona_types (categories for external roles)
-- ============================================
CREATE TABLE IF NOT EXISTS persona_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(50) UNIQUE NOT NULL,
name varchar(100) NOT NULL,
description text,
is_active boolean DEFAULT true,
created_at timestamptz DEFAULT NOW(),
updated_at timestamptz DEFAULT NOW()
);
-- ============================================
-- modules (module registry)
-- ============================================
CREATE TABLE IF NOT EXISTS modules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
module_key varchar(50) UNIQUE NOT NULL,
module_name varchar(100) NOT NULL,
category varchar(50), -- core/content/marketplace/work/financial
description text,
backend_domain varchar(100),
default_route varchar(255),
default_sidebar_label varchar(100),
icon_key varchar(50),
is_core boolean DEFAULT false,
is_active boolean DEFAULT true,
created_at timestamptz DEFAULT NOW(),
updated_at timestamptz DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category);
CREATE INDEX IF NOT EXISTS idx_modules_active ON modules(is_active);
-- ============================================
-- role_module_access (module visibility per role)
-- ============================================
CREATE TABLE IF NOT EXISTS role_module_access (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
is_enabled boolean DEFAULT true,
is_sidebar_visible boolean DEFAULT true,
sidebar_label_override varchar(100),
route_override varchar(255),
sort_order integer DEFAULT 0,
created_at timestamptz DEFAULT NOW(),
UNIQUE(role_id, module_id)
);
CREATE INDEX IF NOT EXISTS idx_role_module_access_role ON role_module_access(role_id);
CREATE INDEX IF NOT EXISTS idx_role_module_access_module ON role_module_access(module_id);
-- ============================================
-- module_actions (CRUD actions per module)
-- ============================================
CREATE TABLE IF NOT EXISTS module_actions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
action_key varchar(50) NOT NULL,
action_name varchar(100) NOT NULL,
description text,
is_active boolean DEFAULT true,
created_at timestamptz DEFAULT NOW(),
UNIQUE(module_id, action_key)
);
CREATE INDEX IF NOT EXISTS idx_module_actions_module ON module_actions(module_id);
-- ============================================
-- role_module_permissions (permissions per module per role)
-- ============================================
CREATE TABLE IF NOT EXISTS role_module_permissions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
can_view boolean DEFAULT false,
can_list boolean DEFAULT false,
can_create boolean DEFAULT false,
can_update boolean DEFAULT false,
can_delete boolean DEFAULT false,
extra_actions_json jsonb DEFAULT '{}',
created_at timestamptz DEFAULT NOW(),
UNIQUE(role_id, module_id)
);
CREATE INDEX IF NOT EXISTS idx_role_module_permissions_role ON role_module_permissions(role_id);
CREATE INDEX IF NOT EXISTS idx_role_module_permissions_module ON role_module_permissions(module_id);
-- ============================================
-- role_module_widgets (widgets per module per role)
-- ============================================
CREATE TABLE IF NOT EXISTS role_module_widgets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
widget_key varchar(50),
is_enabled boolean DEFAULT true,
sort_order integer DEFAULT 0,
created_at timestamptz DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_role_module_widgets_role ON role_module_widgets(role_id);
CREATE INDEX IF NOT EXISTS idx_role_module_widgets_module ON role_module_widgets(module_id);
-- ============================================
-- module_variants (role-specific module variants)
-- ============================================
CREATE TABLE IF NOT EXISTS module_variants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
variant_key varchar(50) NOT NULL,
variant_name varchar(100) NOT NULL,
role_code varchar(50), -- target role (e.g., PHOTOGRAPHER, TUTOR)
persona_type varchar(50), -- target persona (e.g., PROFESSIONAL)
schema_key varchar(100),
ui_template_key varchar(100),
is_active boolean DEFAULT true,
created_at timestamptz DEFAULT NOW(),
updated_at timestamptz DEFAULT NOW(),
UNIQUE(module_id, variant_key)
);
CREATE INDEX IF NOT EXISTS idx_module_variants_module ON module_variants(module_id);
CREATE INDEX IF NOT EXISTS idx_module_variants_role ON module_variants(role_code);
-- ============================================
-- role_module_variant_mapping (which variants a role uses)
-- ============================================
CREATE TABLE IF NOT EXISTS role_module_variant_mapping (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
module_variant_id uuid NOT NULL REFERENCES module_variants(id) ON DELETE CASCADE,
is_active boolean DEFAULT true,
created_at timestamptz DEFAULT NOW(),
UNIQUE(role_id, module_id, module_variant_id)
);
CREATE INDEX IF NOT EXISTS idx_role_module_variant_mapping_role ON role_module_variant_mapping(role_id);
CREATE INDEX IF NOT EXISTS idx_role_module_variant_mapping_module ON role_module_variant_mapping(module_id);

View file

@ -0,0 +1,84 @@
-- Seed widgets into existing role_sidebar_configs for EXTERNAL roles
-- This ensures the widget-based dashboard renders correctly for all roles.
--
-- Run this AFTER migrations are applied:
-- psql $DATABASE_URL -f crates/db/migrations/YYYYMMDDTTTTTT_add_widgets_to_sidebar_configs.seed.sql
-- Update COMPANY roles
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["total_jobs", "active_jobs", "pending_jobs", "applications_received", "shortlisted_candidates", "credits"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (
SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'COMPANY'
);
-- Update JOB_SEEKER roles
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["available_jobs", "my_applications", "shortlisted", "saved_jobs", "profile_status", "portfolio"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (
SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'JOB_SEEKER'
);
-- Update CUSTOMER roles
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["total_requirements", "open_requirements", "closed_requirements", "responses_received", "shortlisted_responses", "credits"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (
SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'CUSTOMER'
);
-- Update all remaining EXTERNAL roles (PROFESSIONAL: photographer, makeup, tutor, etc.)
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND NOT EXISTS (
SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key IN ('COMPANY', 'JOB_SEEKER', 'CUSTOMER')
);
-- Also seed widgets in role_runtime_configs if they differ from role_sidebar_configs
-- (some setups read widgets from runtime_configs)
UPDATE role_runtime_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
COALESCE(
(
SELECT config_json->'widgets'
FROM role_sidebar_configs sc
WHERE sc.role_id = role_runtime_configs.role_id
AND sc.audience = 'EXTERNAL'
AND sc.is_active = true
LIMIT 1
),
'["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb
),
true
)
WHERE is_active = true;
SELECT 'Widget seed completed.' AS status;

View file

@ -0,0 +1,167 @@
-- Seed widgets into existing role_sidebar_configs for all EXTERNAL roles.
-- Run this SQL directly against your database:
-- psql $DATABASE_URL -f seeds/seed_widgets.sql
--
-- Or run individual sections below.
-- ── PHOTOGRAPHER ───────────────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'PHOTOGRAPHER');
-- ── MAKEUP ARTIST ──────────────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'MAKEUP_ARTIST');
-- ── TUTOR ─────────────────────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'TUTOR');
-- ── DEVELOPER ─────────────────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'DEVELOPER');
-- ── VIDEO EDITOR ──────────────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'VIDEO_EDITOR');
-- ── GRAPHIC DESIGNER ─────────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'GRAPHIC_DESIGNER');
-- ── SOCIAL MEDIA MANAGER ─────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'SOCIAL_MEDIA_MANAGER');
-- ── FITNESS TRAINER ──────────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'FITNESS_TRAINER');
-- ── CATERING SERVICES ─────────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'CATERING_SERVICES');
-- ── UGC CONTENT CREATOR ──────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'UGC_CONTENT_CREATOR');
-- ── COMPANY ────────────────────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["total_jobs", "active_jobs", "pending_jobs", "applications_received", "shortlisted_candidates", "credits"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'COMPANY');
-- ── JOB SEEKER ────────────────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["available_jobs", "my_applications", "shortlisted", "saved_jobs", "profile_status", "portfolio"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'JOB_SEEKER');
-- ── CUSTOMER ─────────────────────────────────────────────────────────────────
UPDATE role_sidebar_configs
SET config_json = jsonb_set(
COALESCE(config_json, '{}'::jsonb),
'{widgets}',
'["total_requirements", "open_requirements", "closed_requirements", "responses_received", "shortlisted_responses", "credits"]'::jsonb,
true
)
WHERE audience = 'EXTERNAL'
AND is_active = true
AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'CUSTOMER');
-- Verify the updates
SELECT r.key AS role, sc.config_json->'widgets' AS widgets
FROM role_sidebar_configs sc
JOIN roles r ON r.id = sc.role_id
WHERE sc.audience = 'EXTERNAL' AND sc.is_active = true;

View file

@ -175,7 +175,7 @@ impl ConfigRepository {
) -> Result<DashboardConfig, sqlx::Error> { ) -> Result<DashboardConfig, sqlx::Error> {
sqlx::query( sqlx::query(
r#" r#"
UPDATE dashboard_configs UPDATE role_sidebar_configs
SET is_active = false SET is_active = false
WHERE role_id = $1 AND audience = $2::text AND is_active = true WHERE role_id = $1 AND audience = $2::text AND is_active = true
"#, "#,
@ -187,7 +187,7 @@ impl ConfigRepository {
let config = sqlx::query_as::<_, DashboardConfig>( let config = sqlx::query_as::<_, DashboardConfig>(
r#" r#"
INSERT INTO dashboard_configs (role_id, audience, config_json, is_active) INSERT INTO role_sidebar_configs (role_id, audience, config_json, is_active)
VALUES ( VALUES (
$1, $1,
$2::text, $2::text,
@ -214,7 +214,7 @@ impl ConfigRepository {
let config = sqlx::query_as::<_, DashboardConfig>( let config = sqlx::query_as::<_, DashboardConfig>(
r#" r#"
SELECT id, role_id, audience, config_json, is_active, updated_at SELECT id, role_id, audience, config_json, is_active, updated_at
FROM dashboard_configs FROM role_sidebar_configs
WHERE role_id = $1 AND audience = $2 AND is_active = true WHERE role_id = $1 AND audience = $2 AND is_active = true
"#, "#,
) )
@ -233,7 +233,7 @@ impl ConfigRepository {
r#" r#"
SELECT SELECT
c.id, c.role_id, r.key as role_key, c.audience, c.config_json, c.is_active, c.updated_at c.id, c.role_id, r.key as role_key, c.audience, c.config_json, c.is_active, c.updated_at
FROM dashboard_configs c FROM role_sidebar_configs c
JOIN roles r ON c.role_id = r.id JOIN roles r ON c.role_id = r.id
ORDER BY c.updated_at DESC ORDER BY c.updated_at DESC
"#, "#,
@ -252,7 +252,7 @@ impl ConfigRepository {
let config = sqlx::query_as::<_, DashboardConfig>( let config = sqlx::query_as::<_, DashboardConfig>(
r#" r#"
SELECT c.id, c.role_id, c.audience, c.config_json, c.is_active, c.updated_at SELECT c.id, c.role_id, c.audience, c.config_json, c.is_active, c.updated_at
FROM dashboard_configs c FROM role_sidebar_configs c
JOIN roles r ON c.role_id = r.id JOIN roles r ON c.role_id = r.id
WHERE r.key = $1 AND c.audience = $2 AND c.is_active = true WHERE r.key = $1 AND c.audience = $2 AND c.is_active = true
"#, "#,
@ -272,7 +272,7 @@ impl ConfigRepository {
// Soft-disable previous active configs for this role // Soft-disable previous active configs for this role
sqlx::query( sqlx::query(
r#" r#"
UPDATE runtime_configs UPDATE role_runtime_configs
SET is_active = false SET is_active = false
WHERE role_id = $1 AND is_active = true WHERE role_id = $1 AND is_active = true
"#, "#,
@ -284,11 +284,11 @@ impl ConfigRepository {
// Insert new config // Insert new config
let config = sqlx::query_as::<_, RuntimeConfig>( let config = sqlx::query_as::<_, RuntimeConfig>(
r#" r#"
INSERT INTO runtime_configs (role_id, config_json, version, is_active) INSERT INTO role_runtime_configs (role_id, config_json, version, is_active)
VALUES ( VALUES (
$1, $1,
$2, $2,
COALESCE((SELECT MAX(version) FROM runtime_configs WHERE role_id = $1), 0) + 1, COALESCE((SELECT MAX(version) FROM role_runtime_configs WHERE role_id = $1), 0) + 1,
true true
) )
RETURNING id, role_id, config_json, version, is_active, updated_at RETURNING id, role_id, config_json, version, is_active, updated_at
@ -309,7 +309,7 @@ impl ConfigRepository {
let config = sqlx::query_as::<_, RuntimeConfig>( let config = sqlx::query_as::<_, RuntimeConfig>(
r#" r#"
SELECT id, role_id, config_json, version, is_active, updated_at SELECT id, role_id, config_json, version, is_active, updated_at
FROM runtime_configs FROM role_runtime_configs
WHERE role_id = $1 AND is_active = true WHERE role_id = $1 AND is_active = true
"#, "#,
) )
@ -327,7 +327,7 @@ impl ConfigRepository {
let config = sqlx::query_as::<_, RuntimeConfig>( let config = sqlx::query_as::<_, RuntimeConfig>(
r#" r#"
SELECT rc.id, rc.role_id, rc.config_json, rc.version, rc.is_active, rc.updated_at SELECT rc.id, rc.role_id, rc.config_json, rc.version, rc.is_active, rc.updated_at
FROM runtime_configs rc FROM role_runtime_configs rc
JOIN roles r ON rc.role_id = r.id JOIN roles r ON rc.role_id = r.id
WHERE r.key = $1 AND rc.is_active = true WHERE r.key = $1 AND rc.is_active = true
"#, "#,

View file

@ -19,7 +19,7 @@ pub struct Department {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct CreateDepartmentPayload { pub struct CreateDepartmentPayload {
pub name: String, pub name: String,
pub code: String, pub code: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub department_head: Option<String>, pub department_head: Option<String>,
pub department_email: Option<String>, pub department_email: Option<String>,
@ -31,6 +31,7 @@ pub struct DepartmentRepository;
impl DepartmentRepository { impl DepartmentRepository {
pub async fn create(pool: &PgPool, payload: CreateDepartmentPayload) -> Result<Department, sqlx::Error> { pub async fn create(pool: &PgPool, payload: CreateDepartmentPayload) -> Result<Department, sqlx::Error> {
let is_active = payload.status.map(|s| s.to_uppercase() == "ACTIVE").unwrap_or(true); let is_active = payload.status.map(|s| s.to_uppercase() == "ACTIVE").unwrap_or(true);
let code = payload.code.filter(|c| !c.is_empty()).map(|c| c.to_uppercase());
sqlx::query_as::<_, Department>( sqlx::query_as::<_, Department>(
r#" r#"
@ -43,7 +44,7 @@ impl DepartmentRepository {
"# "#
) )
.bind(payload.name) .bind(payload.name)
.bind(payload.code.to_uppercase()) .bind(code)
.bind(payload.description) .bind(payload.description)
.bind(payload.department_head) .bind(payload.department_head)
.bind(payload.department_email) .bind(payload.department_email)

View file

@ -9,6 +9,7 @@ pub struct Employee {
pub first_name: String, pub first_name: String,
pub last_name: String, pub last_name: String,
pub email: String, pub email: String,
pub phone: Option<String>,
pub password_hash: String, pub password_hash: String,
pub employee_code: Option<String>, pub employee_code: Option<String>,
pub department_id: Option<Uuid>, pub department_id: Option<Uuid>,
@ -35,6 +36,7 @@ pub struct CreateEmployeePayload {
pub first_name: String, pub first_name: String,
pub last_name: String, pub last_name: String,
pub email: String, pub email: String,
pub phone: Option<String>,
pub password_hash: String, pub password_hash: String,
pub department_id: Option<Uuid>, pub department_id: Option<Uuid>,
pub designation_id: Option<Uuid>, pub designation_id: Option<Uuid>,
@ -48,14 +50,15 @@ impl EmployeeRepository {
let level_code = payload.role_code.clone(); let level_code = payload.role_code.clone();
sqlx::query_as::<_, Employee>( sqlx::query_as::<_, Employee>(
r#" r#"
INSERT INTO employees (first_name, last_name, email, password_hash, department_id, designation_id, role_code) INSERT INTO employees (first_name, last_name, email, phone, password_hash, department_id, designation_id, role_code)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at RETURNING id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at
"# "#
) )
.bind(payload.first_name) .bind(payload.first_name)
.bind(payload.last_name) .bind(payload.last_name)
.bind(payload.email.to_lowercase()) .bind(payload.email.to_lowercase())
.bind(payload.phone)
.bind(payload.password_hash) .bind(payload.password_hash)
.bind(payload.department_id) .bind(payload.department_id)
.bind(payload.designation_id) .bind(payload.designation_id)
@ -64,6 +67,28 @@ impl EmployeeRepository {
.await .await
} }
pub async fn create_with_code(pool: &PgPool, payload: CreateEmployeePayload, employee_code: Option<String>) -> Result<Employee, sqlx::Error> {
let role_code = payload.role_code.clone();
sqlx::query_as::<_, Employee>(
r#"
INSERT INTO employees (first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at
"#
)
.bind(payload.first_name)
.bind(payload.last_name)
.bind(payload.email.to_lowercase())
.bind(payload.phone)
.bind(payload.password_hash)
.bind(employee_code)
.bind(payload.department_id)
.bind(payload.designation_id)
.bind(role_code)
.fetch_one(pool)
.await
}
pub async fn update( pub async fn update(
pool: &PgPool, pool: &PgPool,
id: Uuid, id: Uuid,
@ -88,7 +113,7 @@ impl EmployeeRepository {
status = COALESCE($7, status), status = COALESCE($7, status),
updated_at = NOW() updated_at = NOW()
WHERE id = $8 WHERE id = $8
RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at RETURNING id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at
"# "#
) )
.bind(first_name) .bind(first_name)
@ -113,7 +138,7 @@ impl EmployeeRepository {
pub async fn get_by_email(pool: &PgPool, email: &str) -> Result<Option<Employee>, sqlx::Error> { pub async fn get_by_email(pool: &PgPool, email: &str) -> Result<Option<Employee>, sqlx::Error> {
sqlx::query_as::<_, Employee>( sqlx::query_as::<_, Employee>(
"SELECT id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE email = $1" "SELECT id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE email = $1"
) )
.bind(email.to_lowercase()) .bind(email.to_lowercase())
.fetch_optional(pool) .fetch_optional(pool)
@ -124,7 +149,7 @@ impl EmployeeRepository {
let search = q.unwrap_or_default().to_lowercase(); let search = q.unwrap_or_default().to_lowercase();
sqlx::query_as::<_, Employee>( sqlx::query_as::<_, Employee>(
r#" r#"
SELECT id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at SELECT id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at
FROM employees FROM employees
WHERE ($1 = '' OR LOWER(first_name) LIKE '%' || $1 || '%' OR LOWER(last_name) LIKE '%' || $1 || '%' OR LOWER(email) LIKE '%' || $1 || '%') WHERE ($1 = '' OR LOWER(first_name) LIKE '%' || $1 || '%' OR LOWER(last_name) LIKE '%' || $1 || '%' OR LOWER(email) LIKE '%' || $1 || '%')
ORDER BY last_name, first_name ORDER BY last_name, first_name
@ -154,4 +179,23 @@ impl EmployeeRepository {
.fetch_one(pool) .fetch_one(pool)
.await .await
} }
pub async fn change_password(
pool: &PgPool,
id: Uuid,
password_hash: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE employees
SET password_hash = $2, updated_at = NOW()
WHERE id = $1
"#
)
.bind(id)
.bind(password_hash)
.execute(pool)
.await?;
Ok(())
}
} }

View file

@ -38,7 +38,7 @@ impl PhotographerRepository {
pp.created_at, pp.updated_at pp.created_at, pp.updated_at
FROM photographer_profiles pp FROM photographer_profiles pp
INNER JOIN user_role_profiles urp ON urp.id = pp.user_role_profile_id INNER JOIN user_role_profiles urp ON urp.id = pp.user_role_profile_id
WHERE urp.user_id = $1 AND urp.role_key = 'photographer'"#, WHERE urp.user_id = $1 AND urp.role_key = 'PHOTOGRAPHER'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)
@ -47,7 +47,7 @@ impl PhotographerRepository {
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertPhotographerProfilePayload) -> Result<PhotographerProfile, sqlx::Error> { pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertPhotographerProfilePayload) -> Result<PhotographerProfile, sqlx::Error> {
let user_role_profile = sqlx::query_as::<_, (Uuid,)>( let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'photographer'"#, r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'PHOTOGRAPHER'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)

View file

@ -8,3 +8,5 @@ lettre = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }

View file

@ -4,9 +4,105 @@ use lettre::{
transport::smtp::authentication::Credentials, transport::smtp::authentication::Credentials,
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
}; };
use reqwest::Client;
use serde::Serialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::env; use std::env;
#[derive(Clone)]
pub enum EmailProvider {
Smtp(AsyncSmtpTransport<Tokio1Executor>),
Zeptomail(ZeptomailTransport),
}
pub struct ZeptomailTransport {
client: Client,
api_key: String,
from_email: String,
from_name: String,
base_url: String,
}
impl Clone for ZeptomailTransport {
fn clone(&self) -> Self {
Self {
client: Client::new(),
api_key: self.api_key.clone(),
from_email: self.from_email.clone(),
from_name: self.from_name.clone(),
base_url: self.base_url.clone(),
}
}
}
impl ZeptomailTransport {
pub fn new(api_key: String, from_email: String, from_name: String) -> Self {
Self {
client: Client::new(),
api_key,
from_email,
from_name,
base_url: "https://api.zeptomail.com/v1.1/email".to_string(),
}
}
pub async fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<()> {
#[derive(Serialize)]
struct ZeptomailRequest<'a> {
from: ZeptomailAddress<'a>,
to: Vec<ZeptomailAddress<'a>>,
subject: &'a str,
htmlbody: &'a str,
}
#[derive(Serialize)]
struct ZeptomailAddress<'a> {
address: &'a str,
name: Option<&'a str>,
}
let request = ZeptomailRequest {
from: ZeptomailAddress {
address: &self.from_email,
name: Some(&self.from_name),
},
to: vec![ZeptomailAddress {
address: to,
name: None,
}],
subject,
htmlbody: html_body,
};
let response = self
.client
.post(&self.base_url)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Authorization", format!("Zoho-enczapikey {}", self.api_key))
.json(&request)
.send()
.await?;
if response.status().is_success() {
tracing::info!("Zeptomail email sent successfully to {}", to);
Ok(())
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::error!("Zeptomail send failed: {} - {}", status, body);
Err(anyhow::anyhow!("Zeptomail send failed: {} - {}", status, body))
}
}
}
pub struct Mailer {
provider: Option<EmailProvider>,
from_email: String,
from_name: String,
template_engine: TemplateEngine,
}
// ── Template Engine ─────────────────────────────────────────────────────────── // ── Template Engine ───────────────────────────────────────────────────────────
pub struct TemplateEngine; pub struct TemplateEngine;
@ -102,51 +198,100 @@ impl Default for TemplateEngine {
// ── Mailer ──────────────────────────────────────────────────────────────────── // ── Mailer ────────────────────────────────────────────────────────────────────
pub struct Mailer {
transport: Option<AsyncSmtpTransport<Tokio1Executor>>,
from_email: String,
from_name: String,
template_engine: TemplateEngine,
}
impl Mailer { impl Mailer {
pub fn new() -> Self { pub fn new() -> Self {
let smtp_host = env::var("SMTP_HOST").ok(); let provider_type = env::var("EMAIL_PROVIDER")
let smtp_user = env::var("SMTP_USER").ok(); .unwrap_or_else(|_| "SMTP".to_string())
let smtp_pass = env::var("SMTP_PASS").ok(); .to_uppercase();
let smtp_port: u16 = env::var("SMTP_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(587);
let from_email = env::var("SMTP_FROM_EMAIL") let from_email = env::var("SMTP_FROM_EMAIL")
.or_else(|_| env::var("ZEPTOMAIL_FROM_EMAIL".to_string()))
.unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()); .unwrap_or_else(|_| "noreply@nxtgauge.com".to_string());
let from_name = env::var("SMTP_FROM_NAME") let from_name = env::var("SMTP_FROM_NAME")
.or_else(|_| env::var("ZEPTOMAIL_FROM_NAME".to_string()))
.unwrap_or_else(|_| "NXTGAUGE".to_string()); .unwrap_or_else(|_| "NXTGAUGE".to_string());
let transport = match (smtp_host, smtp_user, smtp_pass) { let provider = match provider_type.as_str() {
(Some(host), Some(user), Some(pass)) => { "ZEPTOMAIL_SMTP" | "ZEPTOMAIL" => {
let creds = Credentials::new(user, pass); // Use Zeptomail via SMTP
match AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host) { let smtp_host = env::var("SMTP_HOST").ok();
Ok(builder) => { let smtp_user = env::var("SMTP_USER").ok();
let t = builder.port(smtp_port).credentials(creds).build(); let smtp_pass = env::var("SMTP_PASS").ok();
tracing::info!("SMTP transport configured (host={} port={})", host, smtp_port); let smtp_port: u16 = env::var("SMTP_PORT")
Some(t) .ok()
.and_then(|p| p.parse().ok())
.unwrap_or(587);
match (smtp_host, smtp_user, smtp_pass) {
(Some(host), Some(user), Some(pass)) => {
let creds = Credentials::new(user, pass);
match AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host) {
Ok(builder) => {
let t = builder.port(smtp_port).credentials(creds).build();
tracing::info!("Zeptomail SMTP transport configured (host={} port={})", host, smtp_port);
Some(EmailProvider::Smtp(t))
}
Err(e) => {
tracing::warn!("Zeptomail SMTP transport init failed: {} — emails disabled", e);
None
}
}
} }
Err(e) => { _ => {
tracing::warn!("SMTP transport init failed: {} — emails disabled", e); tracing::warn!("Zeptomail SMTP not configured — emails disabled");
None None
} }
} }
} }
"ZEPTOMAIL_API" => {
// Use Zeptomail via HTTP API
if let (Some(api_key), Some(from)) = (
env::var("ZEPTOMAIL_API_KEY").ok(),
env::var("ZEPTOMAIL_FROM_EMAIL").ok(),
) {
let transport = ZeptomailTransport::new(api_key, from.clone(), from_name.clone());
tracing::info!("Zeptomail API transport configured (from={})", from);
Some(EmailProvider::Zeptomail(transport))
} else {
tracing::warn!("Zeptomail API selected but not configured — emails disabled");
None
}
}
_ => { _ => {
tracing::warn!("SMTP not configured — emails disabled"); // Default to SMTP
None 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);
match (smtp_host, smtp_user, smtp_pass) {
(Some(host), Some(user), Some(pass)) => {
let creds = Credentials::new(user, pass);
match AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host) {
Ok(builder) => {
let t = builder.port(smtp_port).credentials(creds).build();
tracing::info!("SMTP transport configured (host={} port={})", host, smtp_port);
Some(EmailProvider::Smtp(t))
}
Err(e) => {
tracing::warn!("SMTP transport init failed: {} — emails disabled", e);
None
}
}
}
_ => {
tracing::warn!("SMTP not configured — emails disabled");
None
}
}
} }
}; };
Self { Self {
transport, provider,
from_email, from_email,
from_name, from_name,
template_engine: TemplateEngine::new(), template_engine: TemplateEngine::new(),
@ -154,21 +299,28 @@ impl Mailer {
} }
async fn send_html(&self, to: &str, subject: &str, html_body: String) -> Result<()> { async fn send_html(&self, to: &str, subject: &str, html_body: String) -> Result<()> {
let Some(transport) = &self.transport else { let Some(provider) = &self.provider else {
return Err(anyhow::anyhow!("SMTP transport not configured")); return Err(anyhow::anyhow!("No email provider configured"));
}; };
let from: Mailbox = format!("{} <{}>", self.from_name, self.from_email).parse()?; match provider {
let to: Mailbox = to.parse()?; EmailProvider::Smtp(transport) => {
let from: Mailbox = format!("{} <{}>", self.from_name, self.from_email).parse()?;
let to: Mailbox = to.parse()?;
let email = Message::builder() let email = Message::builder()
.from(from) .from(from)
.to(to) .to(to)
.subject(subject) .subject(subject)
.header(ContentType::TEXT_HTML) .header(ContentType::TEXT_HTML)
.body(html_body)?; .body(html_body)?;
transport.send(email).await?; transport.send(email).await?;
}
EmailProvider::Zeptomail(transport) => {
transport.send(to, subject, &html_body).await?;
}
}
Ok(()) Ok(())
} }

View file

@ -986,7 +986,7 @@ ON CONFLICT (role_id) WHERE is_active DO UPDATE SET schema_json = EXCLUDED.schem
-- ── 4. Default Dashboard Configs ───────────────────────────────────────────── -- ── 4. Default Dashboard Configs ─────────────────────────────────────────────
INSERT INTO dashboard_configs (role_id, audience, config_json, version, is_active) INSERT INTO role_sidebar_configs (role_id, audience, config_json, version, is_active)
SELECT r.id, SELECT r.id,
'EXTERNAL', 'EXTERNAL',
jsonb_build_object( jsonb_build_object(
@ -1019,13 +1019,27 @@ SELECT r.id,
WHEN 'JOB_SEEKER' THEN '["browse_jobs", "my_applications", "profile"]'::jsonb WHEN 'JOB_SEEKER' THEN '["browse_jobs", "my_applications", "profile"]'::jsonb
WHEN 'CUSTOMER' THEN '["requirements", "profile"]'::jsonb WHEN 'CUSTOMER' THEN '["requirements", "profile"]'::jsonb
ELSE '["marketplace", "leads", "portfolio", "services", "wallet", "profile"]'::jsonb ELSE '["marketplace", "leads", "portfolio", "services", "wallet", "profile"]'::jsonb
END,
'widgets', CASE r.key
WHEN 'COMPANY' THEN '["total_jobs", "active_jobs", "pending_jobs", "applications_received", "shortlisted_candidates", "credits"]'::jsonb
WHEN 'JOB_SEEKER' THEN '["available_jobs", "my_applications", "shortlisted", "saved_jobs", "profile_status", "portfolio"]'::jsonb
WHEN 'CUSTOMER' THEN '["total_requirements", "open_requirements", "closed_requirements", "responses_received", "shortlisted_responses", "credits"]'::jsonb
ELSE '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb
END,
'tabs', CASE r.key
WHEN 'COMPANY' THEN '["overview"]'::jsonb
WHEN 'JOB_SEEKER' THEN '["overview"]'::jsonb
WHEN 'CUSTOMER' THEN '["overview"]'::jsonb
ELSE '["overview"]'::jsonb
END END
), ),
1, 1,
true true
FROM roles r FROM roles r
WHERE r.audience = 'EXTERNAL' WHERE r.audience = 'EXTERNAL'
ON CONFLICT (role_id, audience) WHERE is_active DO NOTHING; ON CONFLICT (role_id, audience) WHERE is_active DO UPDATE SET
config_json = EXCLUDED.config_json,
version = role_sidebar_configs.version + 1;
-- ── Done ────────────────────────────────────────────────────────────────────── -- ── Done ──────────────────────────────────────────────────────────────────────
SELECT 'Seed completed successfully.' AS status; SELECT 'Seed completed successfully.' AS status;

View file

@ -0,0 +1,484 @@
-- Phase 1 Seed Data: Persona Types, External Roles, Modules
-- Run: psql $DATABASE_URL -f scripts/seed_external_role_management.sql
-- ============================================
-- Persona Types
-- ============================================
INSERT INTO persona_types (code, name, description) VALUES
('PROFESSIONAL', 'Professional', 'Service providers like photographers, tutors, developers'),
('COMPANY', 'Company', 'Employer/corporate accounts'),
('JOB_SEEKER', 'Job Seeker', 'Individuals seeking employment'),
('CUSTOMER', 'Customer', 'Service seekers/customers')
ON CONFLICT (code) DO NOTHING;
-- ============================================
-- External Roles
-- ============================================
INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order)
SELECT 'COMPANY', 'Company',
id, 'Employer/corporate account for posting jobs', 1
FROM persona_types WHERE code = 'COMPANY'
ON CONFLICT (role_code) DO NOTHING;
INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order)
SELECT 'JOB_SEEKER', 'Job Seeker',
id, 'Individual seeking employment opportunities', 2
FROM persona_types WHERE code = 'JOB_SEEKER'
ON CONFLICT (role_code) DO NOTHING;
INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order)
SELECT 'CUSTOMER', 'Customer',
id, 'Service seeker/customer', 3
FROM persona_types WHERE code = 'CUSTOMER'
ON CONFLICT (role_code) DO NOTHING;
INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order)
SELECT 'PHOTOGRAPHER', 'Photographer',
id, 'Professional photographer', 10
FROM persona_types WHERE code = 'PROFESSIONAL'
ON CONFLICT (role_code) DO NOTHING;
INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order)
SELECT 'MAKEUP_ARTIST', 'Makeup Artist',
id, 'Professional makeup artist', 11
FROM persona_types WHERE code = 'PROFESSIONAL'
ON CONFLICT (role_code) DO NOTHING;
INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order)
SELECT 'TUTOR', 'Tutor',
id, 'Professional tutor/teacher', 12
FROM persona_types WHERE code = 'PROFESSIONAL'
ON CONFLICT (role_code) DO NOTHING;
INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order)
SELECT 'DEVELOPER', 'Developer',
id, 'Software developer', 13
FROM persona_types WHERE code = 'PROFESSIONAL'
ON CONFLICT (role_code) DO NOTHING;
INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order)
SELECT 'VIDEO_EDITOR', 'Video Editor',
id, 'Professional video editor', 14
FROM persona_types WHERE code = 'PROFESSIONAL'
ON CONFLICT (role_code) DO NOTHING;
INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order)
SELECT 'GRAPHIC_DESIGNER', 'Graphic Designer',
id, 'Professional graphic designer', 15
FROM persona_types WHERE code = 'PROFESSIONAL'
ON CONFLICT (role_code) DO NOTHING;
INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order)
SELECT 'SOCIAL_MEDIA_MANAGER', 'Social Media Manager',
id, 'Social media management professional', 16
FROM persona_types WHERE code = 'PROFESSIONAL'
ON CONFLICT (role_code) DO NOTHING;
INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order)
SELECT 'FITNESS_TRAINER', 'Fitness Trainer',
id, 'Professional fitness trainer', 17
FROM persona_types WHERE code = 'PROFESSIONAL'
ON CONFLICT (role_code) DO NOTHING;
INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order)
SELECT 'CATERING_SERVICES', 'Catering Services',
id, 'Catering service provider', 18
FROM persona_types WHERE code = 'PROFESSIONAL'
ON CONFLICT (role_code) DO NOTHING;
-- ============================================
-- Modules (23 total)
-- ============================================
-- Core Shared Modules (7)
INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key, is_core)
VALUES
('dashboard_home', 'Dashboard Home', 'core', 'Dashboard landing page with KPIs and widgets', '/dashboard', 'My Dashboard', 'dashboard', true),
('profile', 'Profile', 'core', 'User profile management', '/profile', 'My Profile', 'user', true),
('verification', 'Verification', 'core', 'Verification status and resubmission', '/verification', 'Verification', 'shield', true),
('help_center', 'Help Center', 'core', 'FAQs and support', '/help-center', 'Help Center', 'help-circle', true),
('settings', 'Settings', 'core', 'Account settings and preferences', '/settings', 'Settings', 'settings', true),
('switch_services', 'Switch Services', 'core', 'Switch between approved roles', '/switch-services', 'Switch Services', 'refresh-cw', true),
('explore_nxtgauge', 'Explore Nxtgauge', 'core', 'Register for additional roles', '/explore', 'Explore Nxtgauge', 'compass', true)
ON CONFLICT (module_key) DO NOTHING;
-- Content and Identity Modules (2)
INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key)
VALUES
('portfolio', 'Portfolio', 'content', 'Work samples and showcase', '/portfolio', 'My Portfolio', 'image'),
('services', 'Services', 'content', 'Service offerings and pricing', '/services', 'My Services', 'briefcase')
ON CONFLICT (module_key) DO NOTHING;
-- Marketplace and Discovery Modules (3)
INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key)
VALUES
('marketplace', 'Marketplace', 'marketplace', 'Discover opportunities', '/marketplace', 'Marketplace', 'store'),
('browse_jobs', 'Browse Jobs', 'marketplace', 'Search and browse jobs', '/browse-jobs', 'Jobs', 'search'),
('saved_jobs', 'Saved Jobs', 'marketplace', 'Saved job postings', '/saved-jobs', 'Saved Jobs', 'bookmark')
ON CONFLICT (module_key) DO NOTHING;
-- Work and Response Modules (8)
INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key)
VALUES
('jobs', 'Jobs', 'work', 'Job postings management', '/jobs', 'Jobs', 'briefcase'),
('applications', 'Applications', 'work', 'Application management', '/applications', 'Applications', 'file-text'),
('my_applications', 'My Applications', 'work', 'Track submitted applications', '/my-applications', 'My Applications', 'file-text'),
('requirements', 'Requirements', 'work', 'Customer requirements', '/requirements', 'My Requirements', 'list'),
('leads', 'Leads', 'work', 'Lead management', '/leads', 'Leads', 'users'),
('my_responses', 'My Responses', 'work', 'Track service responses', '/my-responses', 'My Responses', 'send'),
('received_responses', 'Received Responses', 'work', 'View received responses', '/received-responses', 'Received Responses', 'inbox'),
('shortlisted_candidates', 'Shortlisted Candidates', 'work', 'Manage shortlisted candidates', '/shortlisted-candidates', 'Shortlisted Candidates', 'star')
ON CONFLICT (module_key) DO NOTHING;
INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key)
VALUES
('shortlisted_responses', 'Shortlisted Responses', 'work', 'Manage shortlisted responses', '/shortlisted-responses', 'Shortlisted Responses', 'star')
ON CONFLICT (module_key) DO NOTHING;
-- Financial Modules (2)
INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key)
VALUES
('wallet', 'Wallet', 'financial', 'Earnings and payouts', '/wallet', 'Wallet', 'credit-card'),
('credits', 'Credits', 'financial', 'Credit balance and purchases', '/credits', 'Credits', 'package')
ON CONFLICT (module_key) DO NOTHING;
-- ============================================
-- Module Actions (Generic)
-- ============================================
-- Insert generic CRUD actions for each module
DO $$
DECLARE
m_record RECORD;
generic_actions TEXT[] := ARRAY['view', 'list', 'create', 'update', 'delete'];
action_name TEXT;
action_key TEXT;
BEGIN
FOR m_record IN SELECT id, module_key FROM modules LOOP
FOREACH action_key IN ARRAY generic_actions LOOP
action_name := INITCAP(action_key);
-- Custom names for some actions
IF action_key = 'list' THEN action_name := 'List'; END IF;
IF action_key = 'create' THEN action_name := 'Create'; END IF;
IF action_key = 'update' THEN action_name := 'Update'; END IF;
IF action_key = 'delete' THEN action_name := 'Delete'; END IF;
IF action_key = 'view' THEN action_name := 'View'; END IF;
INSERT INTO module_actions (module_id, action_key, action_name, description)
VALUES (m_record.id, action_key, action_name, action_name || ' ' || m_record.module_key)
ON CONFLICT (module_id, action_key) DO NOTHING;
END LOOP;
END LOOP;
END $$;
-- ============================================
-- Module Actions (Domain-Specific)
-- ============================================
-- Add domain-specific actions per module
DO $$
DECLARE
m_id UUID;
BEGIN
-- dashboard_home
SELECT id INTO m_id FROM modules WHERE module_key = 'dashboard_home';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'customize', 'Customize', 'Customize dashboard widgets')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- profile
SELECT id INTO m_id FROM modules WHERE module_key = 'profile';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'upload_media', 'Upload Media', 'Upload profile photos/documents'),
(m_id, 'preview_profile', 'Preview Profile', 'Preview public profile')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- portfolio
SELECT id INTO m_id FROM modules WHERE module_key = 'portfolio';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'publish_item', 'Publish Item', 'Publish portfolio item'),
(m_id, 'unpublish_item', 'Unpublish Item', 'Unpublish portfolio item'),
(m_id, 'feature_item', 'Feature Item', 'Feature portfolio item'),
(m_id, 'reorder_items', 'Reorder Items', 'Reorder portfolio items')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- services
SELECT id INTO m_id FROM modules WHERE module_key = 'services';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'activate', 'Activate', 'Activate service'),
(m_id, 'deactivate', 'Deactivate', 'Deactivate service')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- jobs
SELECT id INTO m_id FROM modules WHERE module_key = 'jobs';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'publish', 'Publish', 'Publish job posting'),
(m_id, 'close', 'Close', 'Close job posting'),
(m_id, 'archive', 'Archive', 'Archive job posting')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- applications
SELECT id INTO m_id FROM modules WHERE module_key = 'applications';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'shortlist', 'Shortlist', 'Shortlist candidate'),
(m_id, 'reject', 'Reject', 'Reject candidate'),
(m_id, 'review', 'Review', 'Review application')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- leads
SELECT id INTO m_id FROM modules WHERE module_key = 'leads';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'update_status', 'Update Status', 'Update lead status'),
(m_id, 'unlock_contact', 'Unlock Contact', 'Unlock contact information')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- my_responses
SELECT id INTO m_id FROM modules WHERE module_key = 'my_responses';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'withdraw', 'Withdraw', 'Withdraw response')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- requirements
SELECT id INTO m_id FROM modules WHERE module_key = 'requirements';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'publish', 'Publish', 'Publish requirement'),
(m_id, 'close', 'Close', 'Close requirement'),
(m_id, 'reopen', 'Reopen', 'Reopen requirement'),
(m_id, 'archive', 'Archive', 'Archive requirement')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- received_responses
SELECT id INTO m_id FROM modules WHERE module_key = 'received_responses';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'shortlist', 'Shortlist', 'Shortlist response'),
(m_id, 'reject', 'Reject', 'Reject response'),
(m_id, 'compare', 'Compare', 'Compare responses')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- shortlisted_responses
SELECT id INTO m_id FROM modules WHERE module_key = 'shortlisted_responses';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'compare', 'Compare', 'Compare shortlisted responses')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- shortlisted_candidates
SELECT id INTO m_id FROM modules WHERE module_key = 'shortlisted_candidates';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'compare', 'Compare', 'Compare candidates')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- browse_jobs
SELECT id INTO m_id FROM modules WHERE module_key = 'browse_jobs';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'save', 'Save', 'Save job'),
(m_id, 'apply', 'Apply', 'Apply to job')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- saved_jobs
SELECT id INTO m_id FROM modules WHERE module_key = 'saved_jobs';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'apply', 'Apply', 'Apply from saved jobs')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- my_applications
SELECT id INTO m_id FROM modules WHERE module_key = 'my_applications';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'withdraw', 'Withdraw', 'Withdraw application')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- marketplace
SELECT id INTO m_id FROM modules WHERE module_key = 'marketplace';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'respond', 'Respond', 'Respond to opportunity')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- wallet
SELECT id INTO m_id FROM modules WHERE module_key = 'wallet';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'request_payout', 'Request Payout', 'Request wallet payout')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- credits
SELECT id INTO m_id FROM modules WHERE module_key = 'credits';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'buy_credits', 'Buy Credits', 'Purchase credits')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- verification
SELECT id INTO m_id FROM modules WHERE module_key = 'verification';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'resubmit', 'Resubmit', 'Resubmit verification'),
(m_id, 'view_status', 'View Status', 'View verification status')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- explore_nxtgauge
SELECT id INTO m_id FROM modules WHERE module_key = 'explore_nxtgauge';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'start_onboarding', 'Start Onboarding', 'Start new role onboarding'),
(m_id, 'resume_onboarding', 'Resume Onboarding', 'Resume incomplete onboarding'),
(m_id, 'save_draft', 'Save Draft', 'Save onboarding draft'),
(m_id, 'submit_for_verification', 'Submit for Verification', 'Submit application'),
(m_id, 'view_progress', 'View Progress', 'View onboarding progress')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- switch_services
SELECT id INTO m_id FROM modules WHERE module_key = 'switch_services';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'switch', 'Switch', 'Switch to another role')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- help_center
SELECT id INTO m_id FROM modules WHERE module_key = 'help_center';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'search', 'Search', 'Search help articles'),
(m_id, 'ask_help', 'Ask Help', 'Ask for help')
ON CONFLICT (module_id, action_key) DO NOTHING;
-- settings
SELECT id INTO m_id FROM modules WHERE module_key = 'settings';
INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES
(m_id, 'manage_sessions', 'Manage Sessions', 'Manage active sessions')
ON CONFLICT (module_id, action_key) DO NOTHING;
END $$;
-- ============================================
-- Default Role Module Access (by Persona)
-- ============================================
-- Helper function to get module id by key
DO $$
DECLARE
pt_rec RECORD;
mod_rec RECORD;
role_id UUID;
mod_id UUID;
sort_int INTEGER := 0;
BEGIN
-- PROFESSIONAL default modules (all 23)
FOR pt_rec IN SELECT id FROM persona_types WHERE code = 'PROFESSIONAL' LOOP
sort_int := 0;
FOR mod_rec IN SELECT id, module_key FROM modules ORDER BY is_core DESC, category, module_key LOOP
SELECT id INTO role_id FROM external_roles WHERE persona_type_id = pt_rec.id LIMIT 1;
IF role_id IS NOT NULL THEN
INSERT INTO role_module_access (external_role_id, module_id, is_enabled, is_sidebar_visible, sort_order)
VALUES (role_id, mod_rec.id, true, true, sort_int)
ON CONFLICT (external_role_id, module_id) DO NOTHING;
sort_int := sort_int + 1;
END IF;
END LOOP;
END LOOP;
-- COMPANY default modules
FOR pt_rec IN SELECT id FROM persona_types WHERE code = 'COMPANY' LOOP
SELECT id INTO role_id FROM external_roles WHERE persona_type_id = pt_rec.id LIMIT 1;
IF role_id IS NOT NULL THEN
-- dashboard_home, profile, jobs, applications, shortlisted_candidates, credits, verification, help_center, settings, switch_services, explore_nxtgauge
FOREACH mod_rec IN ARRAY (
SELECT module_key FROM modules WHERE module_key IN (
'dashboard_home', 'profile', 'jobs', 'applications', 'shortlisted_candidates',
'credits', 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge'
)
) LOOP
SELECT id INTO mod_id FROM modules WHERE module_key = mod_rec;
INSERT INTO role_module_access (external_role_id, module_id, is_enabled, is_sidebar_visible, sort_order)
VALUES (role_id, mod_id, true, true, sort_int)
ON CONFLICT (external_role_id, module_id) DO NOTHING;
sort_int := sort_int + 1;
END LOOP;
END IF;
END LOOP;
-- JOB_SEEKER default modules
FOR pt_rec IN SELECT id FROM persona_types WHERE code = 'JOB_SEEKER' LOOP
SELECT id INTO role_id FROM external_roles WHERE persona_type_id = pt_rec.id LIMIT 1;
IF role_id IS NOT NULL THEN
-- dashboard_home, profile, portfolio, browse_jobs, my_applications, saved_jobs, verification, help_center, settings, switch_services, explore_nxtgauge
FOREACH mod_rec IN ARRAY (
SELECT module_key FROM modules WHERE module_key IN (
'dashboard_home', 'profile', 'portfolio', 'browse_jobs', 'my_applications', 'saved_jobs',
'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge'
)
) LOOP
SELECT id INTO mod_id FROM modules WHERE module_key = mod_rec;
INSERT INTO role_module_access (external_role_id, module_id, is_enabled, is_sidebar_visible, sort_order)
VALUES (role_id, mod_id, true, true, sort_int)
ON CONFLICT (external_role_id, module_id) DO NOTHING;
sort_int := sort_int + 1;
END LOOP;
END IF;
END LOOP;
-- CUSTOMER default modules
FOR pt_rec IN SELECT id FROM persona_types WHERE code = 'CUSTOMER' LOOP
SELECT id INTO role_id FROM external_roles WHERE persona_type_id = pt_rec.id LIMIT 1;
IF role_id IS NOT NULL THEN
-- dashboard_home, profile, requirements, received_responses, shortlisted_responses, credits, verification, help_center, settings, switch_services, explore_nxtgauge
FOREACH mod_rec IN ARRAY (
SELECT module_key FROM modules WHERE module_key IN (
'dashboard_home', 'profile', 'requirements', 'received_responses', 'shortlisted_responses',
'credits', 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge'
)
) LOOP
SELECT id INTO mod_id FROM modules WHERE module_key = mod_rec;
INSERT INTO role_module_access (external_role_id, module_id, is_enabled, is_sidebar_visible, sort_order)
VALUES (role_id, mod_id, true, true, sort_int)
ON CONFLICT (external_role_id, module_id) DO NOTHING;
sort_int := sort_int + 1;
END LOOP;
END IF;
END LOOP;
END $$;
-- ============================================
-- Default Role Module Permissions
-- ============================================
-- Set default CRUD permissions based on persona type
DO $$
DECLARE
role_rec RECORD;
mod_rec RECORD;
role_id UUID;
mod_id UUID;
can_v BOOLEAN;
can_l BOOLEAN;
can_c BOOLEAN;
can_u BOOLEAN;
can_d BOOLEAN;
BEGIN
-- All roles get view/list on all their enabled modules by default
FOR role_rec IN SELECT id, persona_type_id FROM external_roles LOOP
FOR mod_rec IN SELECT module_id FROM role_module_access WHERE external_role_id = role_rec.id AND is_enabled = true LOOP
role_id := role_rec.id;
mod_id := mod_rec.module_id;
-- Default: all get view and list
can_v := true;
can_l := true;
can_c := false;
can_u := false;
can_d := false;
-- Customize defaults per module
IF (SELECT module_key FROM modules WHERE id = mod_id) IN ('dashboard_home', 'profile', 'portfolio', 'services',
'jobs', 'applications', 'requirements', 'leads', 'my_responses', 'received_responses',
'shortlisted_candidates', 'shortlisted_responses', 'saved_jobs', 'browse_jobs', 'my_applications') THEN
can_c := true;
can_u := true;
END IF;
IF (SELECT module_key FROM modules WHERE id = mod_id) IN ('portfolio', 'services', 'jobs', 'requirements') THEN
can_d := true;
END IF;
INSERT INTO role_module_permissions (external_role_id, module_id, can_view, can_list, can_create, can_update, can_delete)
VALUES (role_id, mod_id, can_v, can_l, can_c, can_u, can_d)
ON CONFLICT (external_role_id, module_id) DO NOTHING;
END LOOP;
END LOOP;
END $$;
-- ============================================
-- Update external_roles set switch_services_enabled for roles with multiple personas per user
-- (Will be enabled after user_role_applications system is in place)
-- For now, keep it false
UPDATE external_roles SET switch_services_enabled = false WHERE switch_services_enabled IS NULL OR switch_services_enabled = true;

1
start-services.pid Normal file
View file

@ -0,0 +1 @@
71432