From 5946bfe3a866941d5c75acfed26c89b294daf670 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 26 Apr 2026 23:58:43 +0200 Subject: [PATCH] chore: checkpoint workspace updates --- ...c7b2b88d241b9aac76dd228fd9286c158dd77.json | 40 -- Cargo.lock | 2 + apps/employees/src/handlers/employees.rs | 73 ++- apps/users/src/handlers/admin.rs | 16 +- apps/users/src/handlers/admin_email.rs | 161 +++--- apps/users/src/handlers/approvals.rs | 2 +- apps/users/src/handlers/config.rs | 28 +- apps/users/src/handlers/external_roles.rs | 42 +- apps/users/src/handlers/mod.rs | 1 + apps/users/src/handlers/modules.rs | 263 ++++++++++ apps/users/src/handlers/onboarding.rs | 2 +- apps/users/src/handlers/permissions.rs | 5 + apps/users/src/handlers/profile.rs | 2 +- apps/users/src/handlers/roles.rs | 24 +- apps/users/src/handlers/user_roles.rs | 4 +- apps/users/src/main.rs | 3 + crates/auth/examples/hash_gen.rs | 15 + crates/contracts/src/profession_shared.rs | 34 +- ...001_external_role_management_phase1.up.sql | 127 +++++ ...0260420000002_cleanup_role_tables.down.sql | 21 + .../20260420000002_cleanup_role_tables.up.sql | 45 ++ ...60420000003_external_role_modules.down.sql | 24 + ...60420000003_external_role_modules.seed.sql | 149 ++++++ ...0420000003_external_role_modules.seed2.sql | 120 +++++ ...0260420000003_external_role_modules.up.sql | 157 ++++++ .../20260422000000_seed_widgets.seed.sql | 84 +++ .../20260422000000_seed_widgets.sql | 167 ++++++ crates/db/src/models/config.rs | 20 +- crates/db/src/models/department.rs | 5 +- crates/db/src/models/employee.rs | 56 +- crates/db/src/models/photographer.rs | 4 +- crates/email/Cargo.toml | 2 + crates/email/src/lib.rs | 228 +++++++-- scripts/seed.sql | 18 +- scripts/seed_external_role_management.sql | 484 ++++++++++++++++++ start-services.pid | 1 + 36 files changed, 2195 insertions(+), 234 deletions(-) delete mode 100644 .sqlx/query-f479b3c6088810c02b09611eb2bc7b2b88d241b9aac76dd228fd9286c158dd77.json create mode 100644 apps/users/src/handlers/modules.rs create mode 100644 crates/auth/examples/hash_gen.rs create mode 100644 crates/db/migrations/20260420000001_external_role_management_phase1.up.sql create mode 100644 crates/db/migrations/20260420000002_cleanup_role_tables.down.sql create mode 100644 crates/db/migrations/20260420000002_cleanup_role_tables.up.sql create mode 100644 crates/db/migrations/20260420000003_external_role_modules.down.sql create mode 100644 crates/db/migrations/20260420000003_external_role_modules.seed.sql create mode 100644 crates/db/migrations/20260420000003_external_role_modules.seed2.sql create mode 100644 crates/db/migrations/20260420000003_external_role_modules.up.sql create mode 100644 crates/db/migrations/20260422000000_seed_widgets.seed.sql create mode 100644 crates/db/migrations/20260422000000_seed_widgets.sql create mode 100644 scripts/seed_external_role_management.sql create mode 100644 start-services.pid diff --git a/.sqlx/query-f479b3c6088810c02b09611eb2bc7b2b88d241b9aac76dd228fd9286c158dd77.json b/.sqlx/query-f479b3c6088810c02b09611eb2bc7b2b88d241b9aac76dd228fd9286c158dd77.json deleted file mode 100644 index 639d629..0000000 --- a/.sqlx/query-f479b3c6088810c02b09611eb2bc7b2b88d241b9aac76dd228fd9286c158dd77.json +++ /dev/null @@ -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" -} diff --git a/Cargo.lock b/Cargo.lock index f6d2d86..eb267e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1203,6 +1203,8 @@ dependencies = [ "anyhow", "chrono", "lettre", + "reqwest", + "serde", "tracing", ] diff --git a/apps/employees/src/handlers/employees.rs b/apps/employees/src/handlers/employees.rs index 997800b..0fb7cb2 100644 --- a/apps/employees/src/handlers/employees.rs +++ b/apps/employees/src/handlers/employees.rs @@ -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 { 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, + pub role_code: String, + pub department_id: Option, + pub designation_id: Option, + pub employee_code: Option, + pub password: String, +} + +async fn provision_employee( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> Result { + 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, @@ -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, + Path(id): Path, + Json(payload): Json, +) -> Result { + 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" }))) +} diff --git a/apps/users/src/handlers/admin.rs b/apps/users/src/handlers/admin.rs index 1ac05d2..5d2db03 100644 --- a/apps/users/src/handlers/admin.rs +++ b/apps/users/src/handlers/admin.rs @@ -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 diff --git a/apps/users/src/handlers/admin_email.rs b/apps/users/src/handlers/admin_email.rs index 155ce42..cd64151 100644 --- a/apps/users/src/handlers/admin_email.rs +++ b/apps/users/src/handlers/admin_email.rs @@ -14,8 +14,8 @@ pub fn router() -> Router { .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, + smtp_password: Option, + zeptomail_api_key: String, + zeptomail_from_email: String, + zeptomail_from_name: String, from_email: String, from_name: String, reply_to_email: Option, @@ -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, 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, +struct UpdateEmailConfigRequest { + provider: String, + smtp_host: String, + smtp_port: i32, + smtp_secure: bool, + smtp_username: String, + smtp_password: Option, + zeptomail_api_key: String, + zeptomail_from_email: String, + zeptomail_from_name: String, from_email: String, from_name: String, reply_to_email: Option, enabled: bool, } -async fn update_smtp_config( - Json(req): Json, +async fn update_email_config( + Json(req): Json, ) -> 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, + provider: Option, + config: Option, } #[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, - Json(req): Json, + Json(req): Json, ) -> 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() -} diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index be47db4..06ac634 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -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) diff --git a/apps/users/src/handlers/config.rs b/apps/users/src/handlers/config.rs index baa89db..4e40076 100644 --- a/apps/users/src/handlers/config.rs +++ b/apps/users/src/handlers/config.rs @@ -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, } 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, +} + async fn get_my_runtime_config( auth: contracts::auth_middleware::AuthUser, State(state): State, + Query(q): Query, ) -> Result { - 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 = 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) diff --git a/apps/users/src/handlers/external_roles.rs b/apps/users/src/handlers/external_roles.rs index f87dbc2..4a03970 100644 --- a/apps/users/src/handlers/external_roles.rs +++ b/apps/users/src/handlers/external_roles.rs @@ -32,6 +32,7 @@ struct ExternalRoleRow { id: Uuid, name: String, code: String, + persona_type: Option, vertical: Option, category: Option, onboarding_schema_id: Option, @@ -61,6 +62,7 @@ struct ExternalRoleListRow { id: Uuid, name: String, code: String, + persona_type: Option, is_active: bool, created_date: chrono::DateTime, updated_at: Option>, @@ -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, - runtime: JsonValue, + persona_type: Option, + runtime: Option, } #[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 ) "#, diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index b606d8f..32c9174 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -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; diff --git a/apps/users/src/handlers/modules.rs b/apps/users/src/handlers/modules.rs new file mode 100644 index 0000000..0b6124a --- /dev/null +++ b/apps/users/src/handlers/modules.rs @@ -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 { + Router::new() + .route("/api/admin/persona-types", get(list_persona_types)) +} + +pub fn modules_router() -> Router { + Router::new() + .route("/api/admin/modules", get(list_modules)) +} + +pub fn role_modules_router() -> Router { + 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, + is_active: bool, +} + +async fn list_persona_types( + _auth: AuthUser, + State(state): State, +) -> Result { + 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, + backend_domain: Option, + default_route: Option, + default_sidebar_label: Option, + icon_key: Option, + is_core: bool, + is_active: bool, +} + +async fn list_modules( + _auth: AuthUser, + State(state): State, +) -> Result { + 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, + route_override: Option, +} + +async fn get_role_modules( + _auth: AuthUser, + State(state): State, + Path(role_id): Path, +) -> Result { + 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, + is_sidebar_visible: Option, + sidebar_label_override: Option, + route_override: Option, +} + +async fn add_role_module( + _auth: AuthUser, + State(state): State, + Path(role_id): Path, + Json(payload): Json, +) -> Result { + 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, + Path((role_id, module_id)): Path<(Uuid, Uuid)>, +) -> Result { + 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, + Path(role_id): Path, +) -> Result { + 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, + Path(role_id): Path, + Json(payload): Json, +) -> Result { + 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) +} \ No newline at end of file diff --git a/apps/users/src/handlers/onboarding.rs b/apps/users/src/handlers/onboarding.rs index 0b03307..d86142c 100644 --- a/apps/users/src/handlers/onboarding.rs +++ b/apps/users/src/handlers/onboarding.rs @@ -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 "#, diff --git a/apps/users/src/handlers/permissions.rs b/apps/users/src/handlers/permissions.rs index 54df9c6..d8912b1 100644 --- a/apps/users/src/handlers/permissions.rs +++ b/apps/users/src/handlers/permissions.rs @@ -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"]; diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index 3b400e5..8ed3d28 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -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) diff --git a/apps/users/src/handlers/roles.rs b/apps/users/src/handlers/roles.rs index 78a6801..c5797f5 100644 --- a/apps/users/src/handlers/roles.rs +++ b/apps/users/src/handlers/roles.rs @@ -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 = 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 = 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) diff --git a/apps/users/src/handlers/user_roles.rs b/apps/users/src/handlers/user_roles.rs index b30ee12..bd9254b 100644 --- a/apps/users/src/handlers/user_roles.rs +++ b/apps/users/src/handlers/user_roles.rs @@ -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() diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 6f76b66..b1a976f 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -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 ───────────────────────────────────────────────── diff --git a/crates/auth/examples/hash_gen.rs b/crates/auth/examples/hash_gen.rs new file mode 100644 index 0000000..9f4718d --- /dev/null +++ b/crates/auth/examples/hash_gen.rs @@ -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); +} diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index c7ae631..4ecdab3 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -312,7 +312,7 @@ async fn list_portfolio(State(state): State, 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, 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, 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' "# ) diff --git a/crates/db/migrations/20260420000001_external_role_management_phase1.up.sql b/crates/db/migrations/20260420000001_external_role_management_phase1.up.sql new file mode 100644 index 0000000..8fb00df --- /dev/null +++ b/crates/db/migrations/20260420000001_external_role_management_phase1.up.sql @@ -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); diff --git a/crates/db/migrations/20260420000002_cleanup_role_tables.down.sql b/crates/db/migrations/20260420000002_cleanup_role_tables.down.sql new file mode 100644 index 0000000..cb93261 --- /dev/null +++ b/crates/db/migrations/20260420000002_cleanup_role_tables.down.sql @@ -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; diff --git a/crates/db/migrations/20260420000002_cleanup_role_tables.up.sql b/crates/db/migrations/20260420000002_cleanup_role_tables.up.sql new file mode 100644 index 0000000..361a612 --- /dev/null +++ b/crates/db/migrations/20260420000002_cleanup_role_tables.up.sql @@ -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; diff --git a/crates/db/migrations/20260420000003_external_role_modules.down.sql b/crates/db/migrations/20260420000003_external_role_modules.down.sql new file mode 100644 index 0000000..fe5167a --- /dev/null +++ b/crates/db/migrations/20260420000003_external_role_modules.down.sql @@ -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; diff --git a/crates/db/migrations/20260420000003_external_role_modules.seed.sql b/crates/db/migrations/20260420000003_external_role_modules.seed.sql new file mode 100644 index 0000000..2fdb3bf --- /dev/null +++ b/crates/db/migrations/20260420000003_external_role_modules.seed.sql @@ -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; diff --git a/crates/db/migrations/20260420000003_external_role_modules.seed2.sql b/crates/db/migrations/20260420000003_external_role_modules.seed2.sql new file mode 100644 index 0000000..ebb440a --- /dev/null +++ b/crates/db/migrations/20260420000003_external_role_modules.seed2.sql @@ -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; diff --git a/crates/db/migrations/20260420000003_external_role_modules.up.sql b/crates/db/migrations/20260420000003_external_role_modules.up.sql new file mode 100644 index 0000000..e59c454 --- /dev/null +++ b/crates/db/migrations/20260420000003_external_role_modules.up.sql @@ -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); diff --git a/crates/db/migrations/20260422000000_seed_widgets.seed.sql b/crates/db/migrations/20260422000000_seed_widgets.seed.sql new file mode 100644 index 0000000..871b2e1 --- /dev/null +++ b/crates/db/migrations/20260422000000_seed_widgets.seed.sql @@ -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; diff --git a/crates/db/migrations/20260422000000_seed_widgets.sql b/crates/db/migrations/20260422000000_seed_widgets.sql new file mode 100644 index 0000000..d94bb0f --- /dev/null +++ b/crates/db/migrations/20260422000000_seed_widgets.sql @@ -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; diff --git a/crates/db/src/models/config.rs b/crates/db/src/models/config.rs index 9925a7b..ca54265 100644 --- a/crates/db/src/models/config.rs +++ b/crates/db/src/models/config.rs @@ -175,7 +175,7 @@ impl ConfigRepository { ) -> Result { 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 "#, diff --git a/crates/db/src/models/department.rs b/crates/db/src/models/department.rs index 1c696ad..601e54b 100644 --- a/crates/db/src/models/department.rs +++ b/crates/db/src/models/department.rs @@ -19,7 +19,7 @@ pub struct Department { #[derive(Debug, Serialize, Deserialize)] pub struct CreateDepartmentPayload { pub name: String, - pub code: String, + pub code: Option, pub description: Option, pub department_head: Option, pub department_email: Option, @@ -31,6 +31,7 @@ pub struct DepartmentRepository; impl DepartmentRepository { pub async fn create(pool: &PgPool, payload: CreateDepartmentPayload) -> Result { 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) diff --git a/crates/db/src/models/employee.rs b/crates/db/src/models/employee.rs index 044be94..f831eeb 100644 --- a/crates/db/src/models/employee.rs +++ b/crates/db/src/models/employee.rs @@ -9,6 +9,7 @@ pub struct Employee { pub first_name: String, pub last_name: String, pub email: String, + pub phone: Option, pub password_hash: String, pub employee_code: Option, pub department_id: Option, @@ -35,6 +36,7 @@ pub struct CreateEmployeePayload { pub first_name: String, pub last_name: String, pub email: String, + pub phone: Option, pub password_hash: String, pub department_id: Option, pub designation_id: Option, @@ -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) -> Result { + 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, 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(()) + } } diff --git a/crates/db/src/models/photographer.rs b/crates/db/src/models/photographer.rs index 9eda354..9b0a36b 100644 --- a/crates/db/src/models/photographer.rs +++ b/crates/db/src/models/photographer.rs @@ -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 { 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) diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml index d05bd99..eb94152 100644 --- a/crates/email/Cargo.toml +++ b/crates/email/Cargo.toml @@ -8,3 +8,5 @@ lettre = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } chrono = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index 4cfbb64..40ce0df 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -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), + 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>, + 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, + 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>, - 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::::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::::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::::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(()) } diff --git a/scripts/seed.sql b/scripts/seed.sql index 3e6727e..d540642 100644 --- a/scripts/seed.sql +++ b/scripts/seed.sql @@ -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; diff --git a/scripts/seed_external_role_management.sql b/scripts/seed_external_role_management.sql new file mode 100644 index 0000000..e0a0ff2 --- /dev/null +++ b/scripts/seed_external_role_management.sql @@ -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; diff --git a/start-services.pid b/start-services.pid new file mode 100644 index 0000000..63406ab --- /dev/null +++ b/start-services.pid @@ -0,0 +1 @@ +71432