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",
"chrono",
"lettre",
"reqwest",
"serde",
"tracing",
]

View file

@ -3,18 +3,21 @@ use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
routing::{get, post, patch},
Json, Router,
};
use contracts::auth_middleware::{AuthUser, require_admin};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use db::models::employee::{EmployeeRepository, CreateEmployeePayload};
use auth::crypto::hash_password;
pub fn router() -> Router<AppState> {
Router::new()
.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}/change-password", patch(change_password))
}
#[derive(Deserialize)]
@ -82,6 +85,49 @@ async fn create_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)]
pub struct UpdateEmployeePayload {
pub first_name: Option<String>,
@ -133,3 +179,28 @@ async fn delete_employee(
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() {
// Generic list: users + their approved roles
r#"
SELECT
SELECT
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
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
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
@ -68,14 +68,14 @@ async fn list_users(
"TUTOR" => "tutor_profiles",
"DEVELOPER" => "developer_profiles",
"VIDEO_EDITOR" => "video_editor_profiles",
"GRAPHIC_DESIGNER" => "graphic_designer_profiles",
"GRAPHIC_DESIGNER" => "graphic_designer_profiles",
"SOCIAL_MEDIA_MANAGER" => "social_media_manager_profiles",
"FITNESS_TRAINER" => "fitness_trainer_profiles",
"CATERING_SERVICES" => "catering_service_profiles",
"CUSTOMER" => "customer_profiles",
"COMPANY" => "company_profiles",
"JOB_SEEKER" => "job_seeker_profiles",
_ => "user_roles", // fallback
_ => "user_role_assignments", // fallback
};
format!(
@ -110,11 +110,11 @@ async fn list_customers(
let search = q.q.unwrap_or_default().to_lowercase();
let sql = r#"
SELECT
SELECT
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
ARRAY['CUSTOMER']::text[] as roles
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'
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
@ -138,11 +138,11 @@ async fn list_candidates(
let search = q.q.unwrap_or_default().to_lowercase();
let sql = r#"
SELECT
SELECT
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
ARRAY['JOB_SEEKER']::text[] as roles
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'
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

View file

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

View file

@ -84,7 +84,7 @@ async fn list_runtime_configs(
sqlx::query_as::<_, RcRow>(
r#"
SELECT id, role_id, config_json, version, is_active, updated_at
FROM runtime_configs
FROM role_runtime_configs
WHERE role_id = $1
ORDER BY version DESC
"#,
@ -107,7 +107,7 @@ async fn list_runtime_configs(
sqlx::query_as::<_, RcRow>(
r#"
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
WHERE r.audience = 'INTERNAL'
ORDER BY rc.updated_at DESC
@ -149,7 +149,7 @@ async fn get_runtime_config_by_id(
updated_at: chrono::DateTime<chrono::Utc>,
}
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)
.fetch_optional(&state.pool)
@ -193,20 +193,20 @@ async fn activate_runtime_config(
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
// 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)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "Runtime config not found".to_string()))?;
// 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)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// 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)
.execute(&state.pool)
.await
@ -222,7 +222,7 @@ async fn delete_runtime_config(
if let Err(_e) = require_admin(&auth) {
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)
.execute(&state.pool)
.await
@ -232,11 +232,21 @@ async fn delete_runtime_config(
}
Ok((StatusCode::NO_CONTENT, "".to_string()))
}
#[derive(Deserialize)]
struct RuntimeConfigQuery {
role: Option<String>,
}
async fn get_my_runtime_config(
auth: contracts::auth_middleware::AuthUser,
State(state): State<AppState>,
Query(q): Query<RuntimeConfigQuery>,
) -> 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)]
#[allow(dead_code)]
@ -297,7 +307,7 @@ async fn get_my_runtime_config(
if role.audience == "INTERNAL" {
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)
.fetch_all(&state.pool)

View file

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

View file

@ -8,6 +8,7 @@ pub mod config;
pub mod coupons;
pub mod dashboard;
pub mod kb;
pub mod modules;
pub mod notifications;
pub mod onboarding;
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)
sqlx::query(
r#"
UPDATE user_roles
UPDATE user_role_assignments
SET status = 'PENDING'
WHERE user_id = $1 AND role_id = $2
"#,

View file

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

View file

@ -342,7 +342,7 @@ async fn submit_for_verification(
// Mark user_role as PENDING
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
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(role.id)

View file

@ -164,10 +164,10 @@ async fn list_roles(
COUNT(DISTINCT e.id) AS users_assigned,
COUNT(DISTINCT rp.id) AS permissions_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
LEFT JOIN departments d ON d.id = ir.department_id
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'
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
@ -185,7 +185,7 @@ async fn list_roles(
let total: i64 = sqlx::query_scalar::<_, i64>(
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'
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,
r.created_at
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
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()))?;
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)
.fetch_all(&state.pool)
@ -291,7 +291,7 @@ async fn create_role(
sqlx::query(
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)
"#,
)
@ -307,7 +307,7 @@ async fn create_role(
if let Some(keys) = &payload.permission_keys {
for key in keys {
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(key)
@ -318,7 +318,7 @@ async fn create_role(
}
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)
.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_manage_system_settings, false) AS can_manage_system_settings
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'
"#,
)
@ -385,7 +385,7 @@ async fn update_role(
sqlx::query(
r#"
UPDATE internal_roles SET
UPDATE internal_role_details SET
description = $1,
department_id = $2,
can_approve_requests = $3,
@ -403,7 +403,7 @@ async fn update_role(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
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)
.execute(&state.pool)
.await
@ -411,7 +411,7 @@ async fn update_role(
for key in keys {
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(key)

View file

@ -60,7 +60,7 @@ async fn list_my_roles(
let rows = sqlx::query_as::<_, UserRoleRow>(
r#"
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
WHERE ur.user_id = $1
ORDER BY ur.created_at ASC
@ -100,7 +100,7 @@ async fn register_role(
sqlx::query(
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())
ON CONFLICT (user_id, role_id)
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/permissions", handlers::permissions::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/me/roles", handlers::user_roles::router())
// ── 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(),
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(),
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 {
let _ = ProfessionalRepository::ensure_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(),
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(),
}
}
@ -349,7 +353,13 @@ async fn my_requests(
) -> impl IntoResponse {
let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await {
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);
@ -381,7 +391,7 @@ async fn my_requests(
LEFT JOIN requirements r ON r.id = lr.requirement_id
LEFT JOIN customers c ON c.id = r.customer_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
"#
)
@ -397,7 +407,7 @@ async fn my_requests(
LEFT JOIN requirements r ON r.id = lr.requirement_id
LEFT JOIN customers c ON c.id = r.customer_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
"#
)
@ -405,10 +415,10 @@ async fn my_requests(
};
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)
} 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)
};
@ -478,7 +488,13 @@ async fn accepted_leads(
) -> impl IntoResponse {
let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, "PHOTOGRAPHER").await {
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(),
};
@ -575,7 +591,7 @@ async fn accepted_lead_detail(
INNER JOIN customers c ON c.id = r.customer_id
INNER JOIN users u ON u.id = c.user_id
WHERE lr.id = $1
AND lr.professional_id = $2
AND lr.user_role_profile_id = $2
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> {
sqlx::query(
r#"
UPDATE dashboard_configs
UPDATE role_sidebar_configs
SET is_active = false
WHERE role_id = $1 AND audience = $2::text AND is_active = true
"#,
@ -187,7 +187,7 @@ impl ConfigRepository {
let config = sqlx::query_as::<_, DashboardConfig>(
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 (
$1,
$2::text,
@ -214,7 +214,7 @@ impl ConfigRepository {
let config = sqlx::query_as::<_, DashboardConfig>(
r#"
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
"#,
)
@ -233,7 +233,7 @@ impl ConfigRepository {
r#"
SELECT
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
ORDER BY c.updated_at DESC
"#,
@ -252,7 +252,7 @@ impl ConfigRepository {
let config = sqlx::query_as::<_, DashboardConfig>(
r#"
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
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
sqlx::query(
r#"
UPDATE runtime_configs
UPDATE role_runtime_configs
SET is_active = false
WHERE role_id = $1 AND is_active = true
"#,
@ -284,11 +284,11 @@ impl ConfigRepository {
// Insert new config
let config = sqlx::query_as::<_, RuntimeConfig>(
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,
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
)
RETURNING id, role_id, config_json, version, is_active, updated_at
@ -309,7 +309,7 @@ impl ConfigRepository {
let config = sqlx::query_as::<_, RuntimeConfig>(
r#"
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
"#,
)
@ -327,7 +327,7 @@ impl ConfigRepository {
let config = sqlx::query_as::<_, RuntimeConfig>(
r#"
SELECT rc.id, rc.role_id, rc.config_json, rc.version, rc.is_active, rc.updated_at
FROM runtime_configs rc
FROM role_runtime_configs rc
JOIN roles r ON rc.role_id = r.id
WHERE r.key = $1 AND rc.is_active = true
"#,

View file

@ -19,7 +19,7 @@ pub struct Department {
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateDepartmentPayload {
pub name: String,
pub code: String,
pub code: Option<String>,
pub description: Option<String>,
pub department_head: Option<String>,
pub department_email: Option<String>,
@ -31,6 +31,7 @@ pub struct DepartmentRepository;
impl DepartmentRepository {
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 code = payload.code.filter(|c| !c.is_empty()).map(|c| c.to_uppercase());
sqlx::query_as::<_, Department>(
r#"
@ -43,7 +44,7 @@ impl DepartmentRepository {
"#
)
.bind(payload.name)
.bind(payload.code.to_uppercase())
.bind(code)
.bind(payload.description)
.bind(payload.department_head)
.bind(payload.department_email)

View file

@ -9,6 +9,7 @@ pub struct Employee {
pub first_name: String,
pub last_name: String,
pub email: String,
pub phone: Option<String>,
pub password_hash: String,
pub employee_code: Option<String>,
pub department_id: Option<Uuid>,
@ -35,6 +36,7 @@ pub struct CreateEmployeePayload {
pub first_name: String,
pub last_name: String,
pub email: String,
pub phone: Option<String>,
pub password_hash: String,
pub department_id: Option<Uuid>,
pub designation_id: Option<Uuid>,
@ -48,14 +50,15 @@ impl EmployeeRepository {
let level_code = payload.role_code.clone();
sqlx::query_as::<_, Employee>(
r#"
INSERT INTO employees (first_name, last_name, email, password_hash, department_id, designation_id, role_code)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at
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, $8)
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(payload.department_id)
.bind(payload.designation_id)
@ -64,6 +67,28 @@ impl EmployeeRepository {
.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(
pool: &PgPool,
id: Uuid,
@ -88,7 +113,7 @@ impl EmployeeRepository {
status = COALESCE($7, status),
updated_at = NOW()
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)
@ -113,7 +138,7 @@ impl EmployeeRepository {
pub async fn get_by_email(pool: &PgPool, email: &str) -> Result<Option<Employee>, sqlx::Error> {
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())
.fetch_optional(pool)
@ -124,7 +149,7 @@ impl EmployeeRepository {
let search = q.unwrap_or_default().to_lowercase();
sqlx::query_as::<_, Employee>(
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
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
@ -154,4 +179,23 @@ impl EmployeeRepository {
.fetch_one(pool)
.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
FROM photographer_profiles pp
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)
.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> {
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)
.fetch_optional(pool)

View file

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

View file

@ -4,9 +4,105 @@ use lettre::{
transport::smtp::authentication::Credentials,
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
};
use reqwest::Client;
use serde::Serialize;
use std::collections::HashMap;
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 ───────────────────────────────────────────────────────────
pub struct TemplateEngine;
@ -102,51 +198,100 @@ impl Default for TemplateEngine {
// ── Mailer ────────────────────────────────────────────────────────────────────
pub struct Mailer {
transport: Option<AsyncSmtpTransport<Tokio1Executor>>,
from_email: String,
from_name: String,
template_engine: TemplateEngine,
}
impl Mailer {
pub fn new() -> Self {
let smtp_host = env::var("SMTP_HOST").ok();
let smtp_user = env::var("SMTP_USER").ok();
let smtp_pass = env::var("SMTP_PASS").ok();
let smtp_port: u16 = env::var("SMTP_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(587);
let provider_type = env::var("EMAIL_PROVIDER")
.unwrap_or_else(|_| "SMTP".to_string())
.to_uppercase();
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());
let from_name = env::var("SMTP_FROM_NAME")
.or_else(|_| env::var("ZEPTOMAIL_FROM_NAME".to_string()))
.unwrap_or_else(|_| "NXTGAUGE".to_string());
let transport = 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(t)
let provider = match provider_type.as_str() {
"ZEPTOMAIL_SMTP" | "ZEPTOMAIL" => {
// Use Zeptomail via SMTP
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!("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
}
}
}
"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");
None
// Default to SMTP
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 {
transport,
provider,
from_email,
from_name,
template_engine: TemplateEngine::new(),
@ -154,21 +299,28 @@ impl Mailer {
}
async fn send_html(&self, to: &str, subject: &str, html_body: String) -> Result<()> {
let Some(transport) = &self.transport else {
return Err(anyhow::anyhow!("SMTP transport not configured"));
let Some(provider) = &self.provider else {
return Err(anyhow::anyhow!("No email provider configured"));
};
let from: Mailbox = format!("{} <{}>", self.from_name, self.from_email).parse()?;
let to: Mailbox = to.parse()?;
match provider {
EmailProvider::Smtp(transport) => {
let from: Mailbox = format!("{} <{}>", self.from_name, self.from_email).parse()?;
let to: Mailbox = to.parse()?;
let email = Message::builder()
.from(from)
.to(to)
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(html_body)?;
let email = Message::builder()
.from(from)
.to(to)
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(html_body)?;
transport.send(email).await?;
transport.send(email).await?;
}
EmailProvider::Zeptomail(transport) => {
transport.send(to, subject, &html_body).await?;
}
}
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 ─────────────────────────────────────────────
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,
'EXTERNAL',
jsonb_build_object(
@ -1019,13 +1019,27 @@ SELECT r.id,
WHEN 'JOB_SEEKER' THEN '["browse_jobs", "my_applications", "profile"]'::jsonb
WHEN 'CUSTOMER' THEN '["requirements", "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
),
1,
true
FROM roles r
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 ──────────────────────────────────────────────────────────────────────
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