From a3076ed5267b22f59f1944797e35a40847e083bb Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 15 Apr 2026 06:23:27 +0200 Subject: [PATCH] feat: update DB schema - split users.first_name, users.last_name, roles split --- apps/companies/src/handlers/mod.rs | 6 +- apps/cron/src/tasks/jobs.rs | 2 +- apps/cron/src/tasks/leads.rs | 2 +- apps/cron/src/tasks/requirements.rs | 2 +- apps/customers/src/admin.rs | 4 +- apps/customers/src/handlers.rs | 8 +- apps/job_seekers/src/handlers.rs | 6 +- apps/users/src/handlers/admin.rs | 19 +- apps/users/src/handlers/approvals.rs | 24 +- apps/users/src/handlers/auth.rs | 66 +- apps/users/src/handlers/config.rs | 2 +- apps/users/src/handlers/dashboard.rs | 2 +- apps/users/src/handlers/external_roles.rs | 43 +- apps/users/src/handlers/kb.rs | 42 +- apps/users/src/handlers/onboarding.rs | 22 +- apps/users/src/handlers/profile.rs | 39 +- apps/users/src/handlers/reviews.rs | 33 +- apps/users/src/handlers/roles.rs | 141 +- apps/users/src/handlers/settings.rs | 2 +- apps/users/src/handlers/support.rs | 20 +- apps/users/src/handlers/verifications.rs | 31 +- companies.pid | 1 + crates/contracts/src/profession_shared.rs | 8 +- crates/db/src/models/config.rs | 43 +- crates/db/src/models/customer.rs | 27 +- crates/db/src/models/department.rs | 28 +- crates/db/src/models/employee.rs | 10 +- crates/db/src/models/job_seeker.rs | 20 +- crates/db/src/models/requirement.rs | 27 +- crates/db/src/models/tracecoin_wallet.rs | 12 +- crates/db/src/models/user.rs | 23 +- crates/db/src/models/verification.rs | 11 +- customers.pid | 1 + gateway.pid | 2 +- job_seekers.pid | 1 + scripts/init-db.sql | 2159 +++++++++------------ scripts/seed.sql | 28 +- users.pid | 2 +- 38 files changed, 1324 insertions(+), 1595 deletions(-) create mode 100644 companies.pid create mode 100644 customers.pid create mode 100644 job_seekers.pid diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index 8ee10b0..0c6fcf2 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -258,7 +258,7 @@ async fn submit_job( Ok(updated) => { // Fire email to company user (ignore failures) if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await { - let _ = state.mail.send_job_submitted_email(&user.email, user.name.as_deref().unwrap_or("User"), &updated.title).await; + let _ = state.mail.send_job_submitted_email(&user.email, &format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), &updated.title).await; } // Create verification case so the request appears in Verification Management first. @@ -367,7 +367,7 @@ async fn update_application_status( Ok(updated) => { // Notify applicant of status change (ignore failures) let applicant_info = sqlx::query_as::<_, (String, String)>( - "SELECT u.name, u.email FROM users u WHERE u.id = $1", + "SELECT CONCAT(u.first_name, ' ', u.last_name) AS name, u.email, u.phone FROM users u WHERE u.id = $1", ) .bind(app.applicant_user_id) .fetch_optional(&state.pool) @@ -439,7 +439,7 @@ async fn view_contact( let contact = sqlx::query_as::<_, (Option, String, Option)>( r#" - SELECT u.name, u.email, u.phone + SELECT CONCAT(u.first_name, ' ', u.last_name) AS name, u.email, u.phone FROM users u WHERE u.id = $1 "#, diff --git a/apps/cron/src/tasks/jobs.rs b/apps/cron/src/tasks/jobs.rs index 4a68184..12ce868 100644 --- a/apps/cron/src/tasks/jobs.rs +++ b/apps/cron/src/tasks/jobs.rs @@ -28,7 +28,7 @@ pub async fn expire_stale_jobs( WHERE jobs.company_id = c.id AND jobs.status = 'LIVE' AND jobs.expires_at < $1 - RETURNING jobs.id as job_id, jobs.title, u.email, u.name + RETURNING jobs.id as job_id, jobs.title, u.email, CONCAT(u.first_name, ' ', u.last_name) AS name "# ) .bind(now) diff --git a/apps/cron/src/tasks/leads.rs b/apps/cron/src/tasks/leads.rs index 451080d..5beec0e 100644 --- a/apps/cron/src/tasks/leads.rs +++ b/apps/cron/src/tasks/leads.rs @@ -26,7 +26,7 @@ pub async fn expire_stale_lead_requests( lr.tracecoins_reserved, urp.user_id, u.email, - u.name + CONCAT(u.first_name, ' ', u.last_name) AS name FROM lead_requests lr INNER JOIN user_role_profiles urp ON urp.id = lr.user_role_profile_id INNER JOIN users u ON u.id = urp.user_id diff --git a/apps/cron/src/tasks/requirements.rs b/apps/cron/src/tasks/requirements.rs index 526b540..a23e430 100644 --- a/apps/cron/src/tasks/requirements.rs +++ b/apps/cron/src/tasks/requirements.rs @@ -26,7 +26,7 @@ pub async fn expire_stale_leads( WHERE leads.created_by_user_id = u.id AND leads.status = 'OPEN' AND leads.expires_at < $1 - RETURNING leads.id as lead_id, leads.title, u.email, u.name + RETURNING leads.id as lead_id, leads.title, u.email, CONCAT(u.first_name, ' ', u.last_name) AS name "# ) .bind(now) diff --git a/apps/customers/src/admin.rs b/apps/customers/src/admin.rs index 18b07f4..294882f 100644 --- a/apps/customers/src/admin.rs +++ b/apps/customers/src/admin.rs @@ -11,7 +11,7 @@ pub struct AdminLeadRow { pub description: Option, pub profession_key: String, pub location: String, - pub budget: Option, + pub budget_inr: Option, pub status: String, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, @@ -25,7 +25,7 @@ impl From for AdminLeadRow { description: Some(r.description), profession_key: r.profession_key, location: r.location, - budget: r.budget, + budget_inr: r.budget_inr, status: r.status, created_at: r.created_at, updated_at: r.updated_at, diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index 41bc22b..e445a2b 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -132,8 +132,8 @@ async fn create_requirement( title: payload.title, description: payload.description, location: payload.location, - budget: payload.budget, - preferred_date: p_date, + budget_inr: payload.budget, + required_date: p_date, extra_data_json: payload.extra_data_json, }; @@ -190,7 +190,7 @@ async fn submit_requirement( Ok(updated) => { // Fire email to customer (ignore failures) if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await { - let _ = state.mail.send_requirement_submitted_email(&user.email, user.name.as_deref().unwrap_or("User"), &updated.title).await; + let _ = state.mail.send_requirement_submitted_email(&user.email, &format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), &updated.title).await; } // Create verification case so this request enters Verification Management first. @@ -200,7 +200,7 @@ async fn submit_requirement( "title": updated.title, "profession_key": updated.profession_key, "location": updated.location, - "budget": updated.budget, + "budget_inr": updated.budget_inr, "status": updated.status, "created_by_user_id": updated.created_by_user_id, }); diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index a3a3197..665306d 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -245,19 +245,19 @@ async fn apply_to_job( // Send email notification to company // Get company user details via raw query let company_user = sqlx::query_as::<_, (String, Option)>( - "SELECT u.email, u.name FROM users u INNER JOIN companies c ON c.user_id = u.id WHERE c.id = $1" + "SELECT u.email, CONCAT(u.first_name, ' ', u.last_name) AS name FROM users u INNER JOIN companies c ON c.user_id = u.id WHERE c.id = $1" ) .bind(job.company_id) .fetch_optional(&state.pool) .await; if let Ok(Some((email, name))) = company_user { - let seeker_name = seeker.full_name.as_deref().unwrap_or("A candidate"); + let seeker_name = format!("{} {}", seeker.first_name.unwrap_or_default(), seeker.last_name.unwrap_or_default()); let _ = state.mail.send_new_application_email( &email, name.as_deref().unwrap_or("Company"), &job.title, - seeker_name + &seeker_name ).await; } diff --git a/apps/users/src/handlers/admin.rs b/apps/users/src/handlers/admin.rs index 89479e7..1ac05d2 100644 --- a/apps/users/src/handlers/admin.rs +++ b/apps/users/src/handlers/admin.rs @@ -31,7 +31,8 @@ pub struct ListQuery { pub struct AdminUserRow { pub id: Uuid, pub email: String, - pub full_name: Option, + pub first_name: Option, + pub last_name: Option, pub status: String, pub created_at: chrono::DateTime, pub roles: Vec, @@ -49,12 +50,12 @@ async fn list_users( // Generic list: users + their approved roles r#" SELECT - u.id, u.email, u.name, u.status, u.created_at, + u.id, u.email, u.first_name, u.last_name, u.status, u.created_at, COALESCE(array_agg(r.key) FILTER (WHERE r.key IS NOT NULL), '{}') as roles FROM users u LEFT JOIN user_roles 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.name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') + WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') GROUP BY u.id ORDER BY u.created_at DESC LIMIT 100 @@ -80,11 +81,11 @@ async fn list_users( format!( r#" SELECT - u.id, u.email, u.name, p.status, u.created_at, + u.id, u.email, u.first_name, u.last_name, p.status, u.created_at, ARRAY['{}']::text[] as roles FROM users u JOIN {} p ON p.user_id = u.id - WHERE ($1 = '' OR LOWER(u.name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') + WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') ORDER BY u.created_at DESC LIMIT 100 "#, @@ -110,12 +111,12 @@ async fn list_customers( let sql = r#" SELECT - u.id, u.email, u.name, u.status, u.created_at, + u.id, u.email, u.first_name, u.last_name, u.status, u.created_at, ARRAY['CUSTOMER']::text[] as roles FROM users u JOIN user_roles 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.name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') + WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') ORDER BY u.created_at DESC LIMIT 50 "#; @@ -138,12 +139,12 @@ async fn list_candidates( let sql = r#" SELECT - u.id, u.email, u.name, u.status, u.created_at, + u.id, u.email, u.first_name, u.last_name, u.status, u.created_at, ARRAY['JOB_SEEKER']::text[] as roles FROM users u JOIN user_roles 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.name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') + WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') ORDER BY u.created_at DESC LIMIT 50 "#; diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index 5b18604..be47db4 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -94,7 +94,7 @@ async fn get_submission( Json(serde_json::json!({ "user": { "id": user.id, - "name": user.name, + "name": format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), "email": user.email, "phone": null, "status": user.status, @@ -218,7 +218,7 @@ async fn activate_profile_after_final_approval( }; let query = format!( - "UPDATE {} SET verification_status = 'APPROVED', updated_at = NOW() WHERE id = $1", + "UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE id = $1", table ); sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?; @@ -243,13 +243,10 @@ async fn activate_profile_after_final_approval( if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { let display = role_key_to_display(&role_key); + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); let _ = state .mail - .send_approval_approved_email( - &user.email, - user.name.as_deref().unwrap_or_default(), - &display, - ) + .send_approval_approved_email(&user.email, &user_name, &display) .await; } @@ -292,18 +289,19 @@ async fn reject_profile_after_final_approval( }; let query = format!( - "UPDATE {} SET verification_status = 'REJECTED', updated_at = NOW() WHERE id = $1", + "UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE id = $1", table ); sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?; if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { let display = role_key_to_display(&role_key); + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); let _ = state .mail .send_approval_rejected_email( &user.email, - user.name.as_deref().unwrap_or_default(), + &user_name, &display, reason.unwrap_or("Rejected by final approval"), ) @@ -439,13 +437,13 @@ async fn approve_job( ) .await; - let company_info = sqlx::query_as::<_, (String, String)>( - "SELECT u.name, u.email FROM companies c JOIN users u ON u.id = c.user_id WHERE c.id = $1", +let company_info = sqlx::query_as::<_, (String, String)>( + "SELECT CONCAT(u.first_name, ' ', u.last_name) AS u_full_name, u.email FROM company_profiles c JOIN users u ON u.id = c.user_id WHERE c.id = $1", ) .bind(existing.company_id) .fetch_optional(&state.pool) .await; - + if let Ok(Some((name, email))) = company_info { let _ = state.mail.send_job_approved_email(&email, &name, &existing.title).await; } @@ -490,7 +488,7 @@ async fn reject_job( .await; let company_info = sqlx::query_as::<_, (String, String)>( - "SELECT u.name, u.email FROM companies c JOIN users u ON u.id = c.user_id WHERE c.id = $1", + "SELECT CONCAT(u.first_name, ' ', u.last_name) AS u_full_name, u.email FROM company_profiles c JOIN users u ON u.id = c.user_id WHERE c.id = $1", ) .bind(existing.company_id) .fetch_optional(&state.pool) diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index e44ed50..a6c30e0 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -40,8 +40,6 @@ pub struct RegisterPayload { #[serde(default)] pub last_name: Option, #[serde(default)] - pub full_name: Option, - #[serde(default)] pub name: Option, pub email: String, pub phone: Option, @@ -179,7 +177,7 @@ async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option("SELECT id FROM roles WHERE code = $1") + if let Ok(found) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1") .bind(&normalized) .fetch_optional(pool) .await @@ -190,20 +188,29 @@ async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option( + let role_id = sqlx::query_scalar::<_, Uuid>( r#" - INSERT INTO roles (name, code, audience, is_active) - VALUES ($1, $2, 'USER', true) - ON CONFLICT (code) - DO UPDATE SET updated_at = NOW() + INSERT INTO roles (key, name, audience, is_active) + VALUES ($1, $2, 'EXTERNAL', true) + ON CONFLICT (key) + DO UPDATE SET is_active = true RETURNING id "#, ) + .bind(&normalized) .bind(display_name) - .bind(normalized) .fetch_one(pool) .await - .ok() + .ok()?; + + let _ = sqlx::query( + "INSERT INTO external_roles (role_id) VALUES ($1) ON CONFLICT (role_id) DO NOTHING", + ) + .bind(role_id) + .execute(pool) + .await; + + Some(role_id) } // ── Handlers ────────────────────────────────────────────────────────────────── @@ -264,15 +271,12 @@ async fn register( let password_hash = hash_password(&payload.password) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?; - let full_name = match (&payload.first_name, &payload.last_name, &payload.full_name, &payload.name) { - (Some(fn_), Some(ln_), _, _) => format!("{} {}", fn_.trim(), ln_.trim()).trim().to_string(), - (_, _, Some(fn_), _) => fn_.trim().to_string(), - (_, _, _, Some(n)) => n.trim().to_string(), - _ => return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Name is required (full_name or first_name+last_name)", "VALIDATION_ERROR")), - }; + let first_name = payload.first_name.unwrap_or_default().trim().to_string(); + let last_name = payload.last_name.unwrap_or_default().trim().to_string(); let user = UserRepository::create(&state.pool, CreateUserPayload { - name: full_name, + first_name: Some(first_name), + last_name: Some(last_name), email: email.clone(), password_hash, }) @@ -332,13 +336,14 @@ async fn register( .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok(); - let _ = state.mail.send_verification_email(&user.email, &user.name.clone().unwrap_or_default(), &otp).await; + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); + let _ = state.mail.send_verification_email(&user.email, &user_name, &otp).await; Ok((StatusCode::CREATED, Json(RegisterResponse { user_id: user.id.to_string(), email: user.email, phone: None, - name: user.name.unwrap_or_default(), + name: user_name, status: user.status, email_verified: user.email_verified, created_at: user.created_at.to_rfc3339(), @@ -400,6 +405,7 @@ async fn login( ); let active_role = user_roles.first().cloned(); + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); Ok((StatusCode::OK, [(SET_COOKIE, cookie)], Json(serde_json::json!({ "access_token": tokens.access_token, "token_type": "Bearer", @@ -407,7 +413,7 @@ async fn login( "user": { "id": user.id.to_string(), "email": user.email, - "full_name": user.name.unwrap_or_default(), + "name": user_name, "email_verified": user.email_verified, "active_role": active_role, "roles": user_roles, @@ -516,10 +522,11 @@ async fn session( .await .unwrap_or_default(); + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); Ok(Json(SessionUser { id: user.id.to_string(), email: user.email, - name: user.name.unwrap_or_default(), + name: user_name, email_verified: user.email_verified, active_role: user_roles.first().cloned(), roles: user_roles, @@ -549,7 +556,8 @@ async fn verify_email( // Get user details for welcome email if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { - let _ = state.mail.send_welcome_email(&user.email, &user.name.unwrap_or_default()).await; + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); + let _ = state.mail.send_welcome_email(&user.email, &user_name).await; } Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Email verified successfully" })))) @@ -585,7 +593,8 @@ async fn resend_otp( .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok(); - let _ = state.mail.send_verification_email(&user.email, &user.name.unwrap_or_default(), &otp).await; + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); + let _ = state.mail.send_verification_email(&user.email, &user_name, &otp).await; Ok(silent_ok) } @@ -610,7 +619,8 @@ async fn forgot_password( .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; - let _ = state.mail.send_password_reset_email(&user.email, &user.name.unwrap_or_default(), &token).await; + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); + let _ = state.mail.send_password_reset_email(&user.email, &user_name, &token).await; Ok(silent_ok) } @@ -643,8 +653,9 @@ async fn reset_password( .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; - if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { - let _ = state.mail.send_password_changed_email(&user.email, user.name.as_deref().unwrap_or_default()).await; + if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); + let _ = state.mail.send_password_changed_email(&user.email, &user_name).await; } Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" })))) @@ -677,7 +688,8 @@ async fn change_password( .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; - let _ = state.mail.send_password_changed_email(&user.email, user.name.as_deref().unwrap_or_default()).await; + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); + let _ = state.mail.send_password_changed_email(&user.email, &user_name).await; Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password changed successfully" })))) } diff --git a/apps/users/src/handlers/config.rs b/apps/users/src/handlers/config.rs index c782e50..91da10e 100644 --- a/apps/users/src/handlers/config.rs +++ b/apps/users/src/handlers/config.rs @@ -284,7 +284,7 @@ async fn get_my_runtime_config( "user".to_string(), serde_json::json!({ "id": user.id.to_string(), - "name": user.name.unwrap_or_default(), + "name": format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), "email": user.email, "roles": roles, "active_role": role_key, diff --git a/apps/users/src/handlers/dashboard.rs b/apps/users/src/handlers/dashboard.rs index c3fc52f..979b615 100644 --- a/apps/users/src/handlers/dashboard.rs +++ b/apps/users/src/handlers/dashboard.rs @@ -125,7 +125,7 @@ async fn get_metrics(State(state): State) -> Json( r#" SELECT r.id, r.title, r.status, r.created_at, - u.name AS requester_name + CONCAT(u.first_name, ' ', u.last_name) AS requester_name FROM leads r LEFT JOIN users u ON u.id = r.created_by_user_id WHERE r.status IN ('PENDING_APPROVAL', 'APPROVED') diff --git a/apps/users/src/handlers/external_roles.rs b/apps/users/src/handlers/external_roles.rs index 9fb5941..f87dbc2 100644 --- a/apps/users/src/handlers/external_roles.rs +++ b/apps/users/src/handlers/external_roles.rs @@ -20,9 +20,9 @@ pub fn router() -> Router { #[derive(Deserialize)] struct ListQuery { q: Option, - status: Option, // ACTIVE | INACTIVE - vertical: Option, // jobs | marketplace - category: Option, // provider | employer | consumer | specialist + status: Option, + vertical: Option, + category: Option, page: Option, per_page: Option, } @@ -71,7 +71,7 @@ async fn list_external_roles( auth: AuthUser, State(state): State, Query(q): Query, - ) -> Result { +) -> Result { if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } @@ -83,7 +83,6 @@ async fn list_external_roles( let vertical = q.vertical.unwrap_or_default().to_lowercase(); let category = q.category.unwrap_or_default().to_lowercase(); - // Join roles with active runtime_config for that role (optional) and count assigned user_roles let rows = sqlx::query_as::<_, ExternalRoleListRow>( r#" SELECT @@ -95,8 +94,8 @@ async fn list_external_roles( rc.updated_at as "updated_at", rc.config_json as "config_json" FROM roles r - LEFT JOIN runtime_configs rc - ON rc.role_id = r.id AND rc.is_active = true + 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 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)) @@ -112,11 +111,11 @@ async fn list_external_roles( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; - // Compute total with same filters let total: i64 = sqlx::query_scalar::<_, i64>( 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)) @@ -149,14 +148,12 @@ async fn list_external_roles( assigned_user_types = arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect(); } } - // Additional filters by vertical/category after extracting from config if !vertical.is_empty() && vertical_v.as_deref() != Some(vertical.as_str()) { continue; } if !category.is_empty() && category_v.as_deref() != Some(category.as_str()) { continue; } - // Count assigned users from user_roles (approved) let assigned_users: i64 = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM user_roles WHERE role_id = $1 AND status = 'APPROVED'", ) @@ -217,14 +214,16 @@ async fn get_external_role( auth: AuthUser, State(state): State, Path(id): Path, - ) -> Result { +) -> Result { if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } let row = sqlx::query_as::<_, ExternalRoleDetailRow>( r#" - 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 + 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 WHERE r.id = $1 AND r.audience = 'EXTERNAL' "#, @@ -252,7 +251,7 @@ struct CreateExternalRolePayload { name: String, code: String, is_active: Option, - runtime: JsonValue, // carries vertical/category/modules/permissions/assigned_user_types/requires/feature_limits/onboarding_schema_id + runtime: JsonValue, } #[derive(sqlx::FromRow)] @@ -274,12 +273,11 @@ async fn create_external_role( auth: AuthUser, State(state): State, Json(payload): Json, - ) -> Result { +) -> Result { if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } let is_active = payload.is_active.unwrap_or(true); - // Insert role let role = sqlx::query_as::<_, InsertedRole>( r#" INSERT INTO roles (key, name, audience, is_active) @@ -294,7 +292,14 @@ async fn create_external_role( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; - // Create runtime config version 1 + 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 rc = sqlx::query_as::<_, InsertedRc>( r#" INSERT INTO runtime_configs (role_id, config_json, version, is_active) @@ -335,11 +340,10 @@ async fn update_external_role( State(state): State, Path(id): Path, Json(payload): Json, - ) -> Result { +) -> Result { if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } - // Update role basic fields if payload.name.is_some() || payload.is_active.is_some() { sqlx::query( r#" @@ -356,7 +360,6 @@ async fn update_external_role( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; } - // Create a new runtime config version if provided if let Some(runtime) = payload.runtime { sqlx::query( r#" @@ -393,7 +396,7 @@ async fn delete_external_role( auth: AuthUser, State(state): State, Path(id): Path, - ) -> Result { +) -> Result { if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } diff --git a/apps/users/src/handlers/kb.rs b/apps/users/src/handlers/kb.rs index ab9b3fd..63202a3 100644 --- a/apps/users/src/handlers/kb.rs +++ b/apps/users/src/handlers/kb.rs @@ -132,7 +132,7 @@ struct AdminArticleRow { category_id: Uuid, target_roles: Option>, tags: Vec, - is_published: bool, + status: String, views: i32, created_at: chrono::DateTime, updated_at: chrono::DateTime, @@ -149,7 +149,7 @@ struct InsertedArticleRow { category_id: Uuid, target_roles: Option>, tags: Vec, - is_published: bool, + status: String, views: i32, created_at: chrono::DateTime, updated_at: chrono::DateTime, @@ -227,7 +227,7 @@ async fn public_list_articles( c.name AS category_name, c.slug AS category_slug FROM kb_articles a JOIN kb_categories c ON c.id = a.category_id - WHERE a.is_published = true + WHERE a.status = 'PUBLISHED' AND c.is_active = true AND ($1 = '' OR c.slug = $1) AND ($2 = '' OR $2 = 'ALL' @@ -294,7 +294,7 @@ async fn public_get_article( c.name AS category_name, c.slug AS category_slug FROM kb_articles a JOIN kb_categories c ON c.id = a.category_id - WHERE a.slug = $1 AND a.is_published = true AND c.is_active = true + WHERE a.slug = $1 AND a.status = 'PUBLISHED' AND c.is_active = true "#, ) .bind(&slug) @@ -569,26 +569,26 @@ async fn admin_list_articles( Query(params): Query, ) -> impl IntoResponse { let q = params.q.as_deref().unwrap_or("").to_lowercase(); - let published_filter: Option = params.status.as_deref().map(|s| s == "PUBLISHED"); + let status_filter: Option = params.status.as_deref().map(|s| s.to_string()); let rows = sqlx::query_as::<_, AdminArticleRow>( r#" SELECT a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags, - a.is_published, a.views, a.category_id, a.created_at, a.updated_at, + a.status, a.views, a.category_id, a.created_at, a.updated_at, c.name AS category_name FROM kb_articles a JOIN kb_categories c ON c.id = a.category_id WHERE ($1 = '' OR LOWER(a.title) LIKE '%' || $1 || '%') AND ($2::uuid IS NULL OR a.category_id = $2) - AND ($3::bool IS NULL OR a.is_published = $3) + AND ($3::text IS NULL OR a.status = $3) ORDER BY a.updated_at DESC LIMIT 200 "#, ) .bind(&q) .bind(params.category_id) - .bind(published_filter) + .bind(status_filter) .fetch_all(&state.pool) .await; @@ -604,7 +604,7 @@ async fn admin_list_articles( category_id: Some(r.category_id), category: Some(r.category_name), content: r.body, - status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() }, +status: r.status, target_roles: r.target_roles.unwrap_or_default(), tags: r.tags, views: r.views, @@ -646,16 +646,16 @@ async fn admin_create_article( .slug .filter(|s| !s.is_empty()) .unwrap_or_else(|| slugify(&body.title)); - let is_published = body.status.as_deref() == Some("PUBLISHED"); + let status = body.status.as_deref().unwrap_or("DRAFT").to_string(); let roles: Vec = body.target_roles.unwrap_or_default(); let tags: Vec = body.tags.unwrap_or_default(); let result = sqlx::query_as::<_, InsertedArticleRow>( r#" INSERT INTO kb_articles - (title, slug, summary, body, category_id, is_published, target_roles, tags, created_by) + (title, slug, summary, body, category_id, status, target_roles, tags, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id, title, slug, summary, body, category_id, is_published, + RETURNING id, title, slug, summary, body, category_id, status, target_roles, tags, views, created_at, updated_at "#, ) @@ -664,7 +664,7 @@ async fn admin_create_article( .bind(&body.summary) .bind(&body.content) .bind(body.category_id) - .bind(is_published) + .bind(&status) .bind(&roles) .bind(&tags) .bind(auth.user_id) @@ -682,7 +682,7 @@ async fn admin_create_article( category_id: Some(r.category_id), category: None, content: r.body, - status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() }, + status: r.status, target_roles: r.target_roles.unwrap_or_default(), tags: r.tags, views: r.views, @@ -721,7 +721,7 @@ async fn admin_get_article( r#" SELECT a.id, a.title, a.slug, a.summary, a.body, a.category_id, - a.target_roles, a.tags, a.is_published, a.views, + a.target_roles, a.tags, a.status, a.views, a.created_at, a.updated_at, c.name AS category_name FROM kb_articles a @@ -744,7 +744,7 @@ async fn admin_get_article( category_id: Some(r.category_id), category: Some(r.category_name), content: r.body, - status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() }, + status: r.status, target_roles: r.target_roles.unwrap_or_default(), tags: r.tags, views: r.views, @@ -787,7 +787,7 @@ async fn admin_update_article( Path(id): Path, Json(body): Json, ) -> impl IntoResponse { - let is_published: Option = body.status.as_deref().map(|s| s == "PUBLISHED"); + let status: Option = body.status.as_deref().map(|s| s.to_string()); let result = sqlx::query_as::<_, InsertedArticleRow>( r#" UPDATE kb_articles SET @@ -796,13 +796,13 @@ async fn admin_update_article( summary = COALESCE($4, summary), body = COALESCE($5, body), category_id = COALESCE($6, category_id), - is_published = COALESCE($7, is_published), + status = COALESCE($7, status), target_roles = COALESCE($8, target_roles), tags = COALESCE($9, tags), updated_at = NOW() WHERE id = $1 RETURNING id, title, slug, summary, body, category_id, - target_roles, tags, is_published, views, created_at, updated_at + target_roles, tags, status, views, created_at, updated_at "#, ) .bind(id) @@ -811,7 +811,7 @@ async fn admin_update_article( .bind(&body.summary) .bind(&body.content) .bind(body.category_id) - .bind(is_published) + .bind(&status) .bind(body.target_roles.as_deref()) .bind(body.tags.as_deref()) .fetch_optional(&state.pool) @@ -828,7 +828,7 @@ async fn admin_update_article( category_id: Some(r.category_id), category: None, content: r.body, - status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() }, + status: r.status, target_roles: r.target_roles.unwrap_or_default(), tags: r.tags, views: r.views, diff --git a/apps/users/src/handlers/onboarding.rs b/apps/users/src/handlers/onboarding.rs index f2bc890..019c35a 100644 --- a/apps/users/src/handlers/onboarding.rs +++ b/apps/users/src/handlers/onboarding.rs @@ -173,12 +173,11 @@ async fn submit( let query = format!( r#" - INSERT INTO {} (id, "profileData", verification_status, submitted_at, updated_at) - VALUES ($1, $2, 'PENDING', NOW(), NOW()) + INSERT INTO {} (id, custom_data, status, updated_at) + VALUES ($1, $2, 'PENDING', NOW()) ON CONFLICT (id) DO UPDATE SET - "profileData" = EXCLUDED."profileData", - verification_status = 'PENDING', - submitted_at = NOW(), + custom_data = EXCLUDED.custom_data, + status = 'PENDING', updated_at = NOW() "#, tbl @@ -194,11 +193,11 @@ async fn submit( // Simple companies upsert (using basic fields if possible) sqlx::query( r#" - INSERT INTO companies ("userId", status, "updatedAt") + INSERT INTO company_profiles (user_id, status, updated_at) VALUES ($1, 'PENDING', NOW()) - ON CONFLICT ("userId") DO UPDATE SET + ON CONFLICT (user_id) DO UPDATE SET status = 'PENDING', - "updatedAt" = NOW() + updated_at = NOW() "#, ) .bind(auth.user_id) @@ -211,7 +210,7 @@ async fn submit( sqlx::query( r#" UPDATE user_roles - SET status = 'PENDING', updated_at = NOW() + SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2 "#, ) @@ -283,15 +282,14 @@ async fn get_or_create_user_role_profile_id( sqlx::query_scalar::<_, uuid::Uuid>( r#" - INSERT INTO user_role_profiles (user_id, role_key, role_id, status) - VALUES ($1, $2, $3, 'DRAFT') + INSERT INTO user_role_profiles (user_id, role_key, status) + VALUES ($1, $2, 'DRAFT') ON CONFLICT (user_id, role_key) DO UPDATE SET updated_at = NOW() RETURNING id "#, ) .bind(user_id) .bind(role_key) - .bind(role_id) .fetch_one(pool) .await } diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index d86c494..6ad232b 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -115,7 +115,7 @@ async fn get_profile( if role_key == "COMPANY" { let row = sqlx::query( - r#"SELECT name, status, "updatedAt" FROM companies WHERE "userId" = $1"#, + r#"SELECT company_name, status, updated_at FROM company_profiles WHERE user_id = $1"#, ) .bind(auth.user_id) .fetch_optional(&state.pool) @@ -124,7 +124,7 @@ async fn get_profile( return match row { Ok(Some(r)) => { use sqlx::Row; - let name: Option = r.try_get("name").ok(); + let name: Option = r.try_get("company_name").ok(); let status: String = r.try_get("status").unwrap_or_default(); ( StatusCode::OK, @@ -161,7 +161,7 @@ async fn get_profile( }; let query = format!( - r#"SELECT "profileData", verification_status FROM {} WHERE id = $1"#, + r#"SELECT custom_data, status FROM {} WHERE id = $1"#, table ); @@ -189,10 +189,10 @@ async fn get_profile( Ok(Some(row)) => { use sqlx::Row; let profile_data: serde_json::Value = row - .try_get("profileData") + .try_get("custom_data") .unwrap_or(serde_json::Value::Null); let verification_status: String = - row.try_get("verification_status").unwrap_or_default(); + row.try_get("status").unwrap_or_default(); ( StatusCode::OK, Json(serde_json::json!({ @@ -234,11 +234,11 @@ async fn save_profile( return match sqlx::query( r#" - INSERT INTO companies ("userId", name, status, "updatedAt") + INSERT INTO company_profiles (user_id, company_name, status, updated_at) VALUES ($1, $2, 'DRAFT', NOW()) - ON CONFLICT ("userId") DO UPDATE SET - name = EXCLUDED.name, - "updatedAt" = NOW() + ON CONFLICT (user_id) DO UPDATE SET + company_name = EXCLUDED.company_name, + updated_at = NOW() "#, ) .bind(auth.user_id) @@ -268,10 +268,10 @@ async fn save_profile( let query = format!( r#" - INSERT INTO {table} (id, "profileData", verification_status, updated_at) + INSERT INTO {table} (id, custom_data, status, updated_at) VALUES ($1, $2, 'DRAFT', NOW()) ON CONFLICT (id) DO UPDATE SET - "profileData" = EXCLUDED."profileData", + custom_data = EXCLUDED.custom_data, updated_at = NOW() "# ); @@ -441,14 +441,14 @@ async fn fetch_saved_profile( role_key: &str, ) -> serde_json::Value { if role_key == "COMPANY" { - return match sqlx::query(r#"SELECT name FROM companies WHERE "userId" = $1"#) + return match sqlx::query(r#"SELECT company_name FROM company_profiles WHERE user_id = $1"#) .bind(user_id) .fetch_optional(&state.pool) .await { Ok(Some(r)) => { use sqlx::Row; - let name: Option = r.try_get("name").ok(); + let name: Option = r.try_get("company_name").ok(); serde_json::json!({ "company_name": name }) } _ => serde_json::Value::Object(Default::default()), @@ -465,7 +465,7 @@ async fn fetch_saved_profile( async fn set_profile_status(state: &AppState, user_id: Uuid, role_key: &str, status: &str) { if role_key == "COMPANY" { sqlx::query( - r#"UPDATE companies SET status = $1, "updatedAt" = NOW() WHERE "userId" = $2"#, + r#"UPDATE company_profiles SET status = $1, updated_at = NOW() WHERE user_id = $2"#, ) .bind(status) .bind(user_id) @@ -483,7 +483,7 @@ async fn set_profile_status(state: &AppState, user_id: Uuid, role_key: &str, sta if let Some(table) = role_to_table(role_key) { let q = format!( - "UPDATE {} SET verification_status = $1, submitted_at = NOW(), updated_at = NOW() WHERE id = $2", + "UPDATE {} SET status = $1, updated_at = NOW() WHERE id = $2", table ); sqlx::query(&q) @@ -525,15 +525,14 @@ async fn get_or_create_user_role_profile_id( sqlx::query_scalar::<_, Uuid>( r#" - INSERT INTO user_role_profiles (user_id, role_key, role_id, status) - VALUES ($1, $2, $3, 'DRAFT') + INSERT INTO user_role_profiles (user_id, role_key, status) + VALUES ($1, $2, 'DRAFT') ON CONFLICT (user_id, role_key) DO UPDATE SET updated_at = NOW() RETURNING id "#, ) .bind(user_id) .bind(role_key) - .bind(role.id) .fetch_one(pool) .await } @@ -544,7 +543,7 @@ async fn fetch_saved_profile_by_urp_id( role_key: &str, ) -> serde_json::Value { if let Some(table) = role_to_table(role_key) { - let q = format!(r#"SELECT "profileData" FROM {} WHERE id = $1"#, table); + let q = format!(r#"SELECT custom_data FROM {} WHERE id = $1"#, table); if let Ok(Some(row)) = sqlx::query(&q) .bind(user_role_profile_id) .fetch_optional(&state.pool) @@ -552,7 +551,7 @@ async fn fetch_saved_profile_by_urp_id( { use sqlx::Row; return row - .try_get::("profileData") + .try_get::("custom_data") .unwrap_or(serde_json::Value::Object(Default::default())); } } diff --git a/apps/users/src/handlers/reviews.rs b/apps/users/src/handlers/reviews.rs index 17ec886..ab01f14 100644 --- a/apps/users/src/handlers/reviews.rs +++ b/apps/users/src/handlers/reviews.rs @@ -31,7 +31,6 @@ struct ReviewDto { title: Option, comment: Option, status: String, - is_published: bool, created_at: chrono::DateTime, } @@ -48,7 +47,6 @@ struct CreateReviewBody { #[derive(Deserialize)] struct PatchReviewBody { status: Option, - is_published: Option, } // ── FromRow structs ────────────────────────────────────────────────────────── @@ -64,7 +62,6 @@ struct ReviewRow { title: Option, comment: Option, status: String, - is_published: bool, created_at: chrono::DateTime, } @@ -81,12 +78,11 @@ async fn admin_list_reviews( r.subject_type, r.subject_id, r.reviewer_name, - r.customer_id AS reviewer_id, + r.reviewer_user_id AS reviewer_id, r.rating, r.title, r.comment, r.status, - r.is_published, r.created_at FROM reviews r ORDER BY r.created_at DESC @@ -109,7 +105,6 @@ async fn admin_list_reviews( title: r.title, comment: r.comment, status: r.status, - is_published: r.is_published, created_at: r.created_at, }) .collect(); @@ -136,10 +131,10 @@ async fn admin_create_review( let row = sqlx::query_as::<_, ReviewRow>( r#" - INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status, is_published) - VALUES ($1, $2, $3, $4, $5, $6, $7, true) - RETURNING id, subject_type, subject_id, reviewer_name, customer_id AS reviewer_id, - rating, title, comment, status, is_published, created_at + INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, subject_type, subject_id, reviewer_name, reviewer_user_id AS reviewer_id, + rating, title, comment, status, created_at "#, ) .bind(&subject_type) @@ -164,7 +159,6 @@ async fn admin_create_review( title: r.title, comment: r.comment, status: r.status, - is_published: r.is_published, created_at: r.created_at, }; (StatusCode::CREATED, Json(serde_json::json!(dto))).into_response() @@ -182,24 +176,13 @@ async fn admin_update_review( Path(id): Path, Json(body): Json, ) -> impl IntoResponse { - // Derive is_published from status string, or use explicit field - let (status, published) = match (body.status.as_deref(), body.is_published) { - (Some("PUBLISHED"), _) => ("PUBLISHED".to_string(), true), - (Some("HIDDEN"), _) => ("HIDDEN".to_string(), false), - (Some(s), _) => (s.to_string(), false), - (None, Some(p)) => { - if p { ("PUBLISHED".to_string(), true) } else { ("HIDDEN".to_string(), false) } - } - (None, None) => { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Provide status or is_published" }))).into_response(); - } - }; + let status = body.status.as_deref().unwrap_or("PUBLISHED").to_string(); let result = sqlx::query( - "UPDATE reviews SET status = $1, is_published = $2, updated_at = NOW() WHERE id = $3", + "UPDATE reviews SET status = $1, updated_at = NOW() WHERE id = $2", ) .bind(&status) - .bind(published) + .bind(id) .bind(id) .execute(&state.pool) .await; diff --git a/apps/users/src/handlers/roles.rs b/apps/users/src/handlers/roles.rs index 4a80148..78a6801 100644 --- a/apps/users/src/handlers/roles.rs +++ b/apps/users/src/handlers/roles.rs @@ -15,18 +15,13 @@ pub fn router() -> Router { .route("/{id}", get(get_role).patch(update_role).delete(delete_role)) } -// ── Query params ───────────────────────────────────────────────────────────── - #[derive(Deserialize)] struct ListQuery { - audience: Option, q: Option, page: Option, per_page: Option, } -// ── Response types ─────────────────────────────────────────────────────────── - #[derive(Serialize)] struct RoleRow { id: Uuid, @@ -68,13 +63,10 @@ struct RoleDetail { created_at: chrono::DateTime, } -// ── Request types ──────────────────────────────────────────────────────────── - #[derive(Deserialize)] struct CreateRolePayload { key: String, name: String, - audience: String, description: Option, department_id: Option, is_active: Option, @@ -94,8 +86,6 @@ struct UpdateRolePayload { permission_keys: Option>, } -// ── FromRow structs ────────────────────────────────────────────────────────── - #[derive(sqlx::FromRow)] struct RoleListRow { id: Uuid, @@ -134,11 +124,7 @@ struct InsertedRoleRow { key: String, name: String, audience: String, - description: Option, - department_id: Option, is_active: bool, - can_approve_requests: bool, - can_manage_system_settings: bool, created_at: chrono::DateTime, } @@ -152,8 +138,6 @@ struct CurrentRoleRow { can_manage_system_settings: bool, } -// ── Handlers ───────────────────────────────────────────────────────────────── - async fn list_roles( State(state): State, Query(params): Query, @@ -162,36 +146,35 @@ async fn list_roles( let per_page = params.per_page.unwrap_or(20).min(100); let offset = (page - 1) * per_page; let search = params.q.as_deref().unwrap_or("").to_lowercase(); - let audience = params.audience.as_deref().unwrap_or("").to_string(); let rows = sqlx::query_as::<_, RoleListRow>( r#" SELECT r.id, - r.code AS key, + r.key, r.name, r.audience, - r.description, - r.department_id, + ir.description, + ir.department_id, d.name AS department_name, r.is_active, - r.can_approve_requests, - r.can_manage_system_settings, + COALESCE(ir.can_approve_requests, false) AS can_approve_requests, + COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings, r.created_at, COUNT(DISTINCT e.id) AS users_assigned, COUNT(DISTINCT rp.id) AS permissions_count FROM roles r - LEFT JOIN departments d ON d.id = r.department_id - LEFT JOIN employees e ON e.role_code = r.code + JOIN internal_roles 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 - WHERE ($1 = '' OR r.audience = $1) - AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.code) LIKE '%' || $2 || '%') - GROUP BY r.id, d.name + 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 ORDER BY r.created_at DESC - LIMIT $3 OFFSET $4 + LIMIT $2 OFFSET $3 "#, ) - .bind(&audience) .bind(&search) .bind(per_page) .bind(offset) @@ -202,11 +185,11 @@ async fn list_roles( let total: i64 = sqlx::query_scalar::<_, i64>( r#" SELECT COUNT(*) FROM roles r - WHERE ($1 = '' OR r.audience = $1) - AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.code) LIKE '%' || $2 || '%') + JOIN internal_roles 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 || '%') "#, ) - .bind(&audience) .bind(&search) .fetch_one(&state.pool) .await @@ -241,13 +224,17 @@ async fn get_role( let row = sqlx::query_as::<_, RoleDetailRow>( r#" SELECT - r.id, r.code AS key, r.name, r.audience, r.description, - r.department_id, d.name AS department_name, - r.is_active, r.can_approve_requests, r.can_manage_system_settings, + r.id, r.key, r.name, r.audience, + ir.description, + ir.department_id, d.name AS department_name, + r.is_active, + COALESCE(ir.can_approve_requests, false) AS can_approve_requests, + COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings, r.created_at FROM roles r - LEFT JOIN departments d ON d.id = r.department_id - WHERE r.id = $1 + JOIN internal_roles 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' "#, ) .bind(id) @@ -290,24 +277,33 @@ async fn create_role( let role = sqlx::query_as::<_, InsertedRoleRow>( r#" - INSERT INTO roles (code, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, code AS key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings, created_at + INSERT INTO roles (key, name, audience, is_active) + VALUES ($1, $2, 'INTERNAL', $3) + RETURNING id, key, name, audience, is_active, created_at "#, ) .bind(&payload.key) .bind(&payload.name) - .bind(&payload.audience) - .bind(&payload.description) - .bind(payload.department_id) .bind(is_active) - .bind(can_approve) - .bind(can_manage) .fetch_one(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; - // Insert permission keys + sqlx::query( + r#" + INSERT INTO internal_roles (role_id, description, department_id, can_approve_requests, can_manage_system_settings) + VALUES ($1, $2, $3, $4, $5) + "#, + ) + .bind(role.id) + .bind(&payload.description) + .bind(payload.department_id) + .bind(can_approve) + .bind(can_manage) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + if let Some(keys) = &payload.permission_keys { for key in keys { sqlx::query( @@ -336,12 +332,12 @@ async fn create_role( key: role.key, name: role.name, audience: role.audience, - description: role.description, - department_id: role.department_id, + description: payload.description, + department_id: payload.department_id, department_name: None, is_active: role.is_active, - can_approve_requests: role.can_approve_requests, - can_manage_system_settings: role.can_manage_system_settings, + can_approve_requests: can_approve, + can_manage_system_settings: can_manage, permission_keys, created_at: role.created_at, }), @@ -353,9 +349,15 @@ async fn update_role( Path(id): Path, Json(payload): Json, ) -> Result { - // Fetch current values first let current = sqlx::query_as::<_, CurrentRoleRow>( - "SELECT name, description, department_id, is_active, can_approve_requests, can_manage_system_settings FROM roles WHERE id = $1", + r#" + SELECT r.name, ir.description, ir.department_id, r.is_active, + 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 + WHERE r.id = $1 AND r.audience = 'INTERNAL' + "#, ) .bind(id) .fetch_optional(&state.pool) @@ -364,28 +366,35 @@ async fn update_role( .ok_or((StatusCode::NOT_FOUND, "Role not found".to_string()))?; let name = payload.name.unwrap_or(current.name); + let is_active = payload.is_active.unwrap_or(current.is_active); + + sqlx::query( + "UPDATE roles SET name = $1, is_active = $2 WHERE id = $3", + ) + .bind(&name) + .bind(is_active) + .bind(id) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + let description = payload.description.or(current.description); let department_id = payload.department_id.or(current.department_id); - let is_active = payload.is_active.unwrap_or(current.is_active); let can_approve = payload.can_approve_requests.unwrap_or(current.can_approve_requests); let can_manage = payload.can_manage_system_settings.unwrap_or(current.can_manage_system_settings); sqlx::query( r#" - UPDATE roles SET - name = $1, - description = $2, - department_id = $3, - is_active = $4, - can_approve_requests = $5, - can_manage_system_settings = $6 - WHERE id = $7 + UPDATE internal_roles SET + description = $1, + department_id = $2, + can_approve_requests = $3, + can_manage_system_settings = $4 + WHERE role_id = $5 "#, ) - .bind(name) - .bind(description) + .bind(&description) .bind(department_id) - .bind(is_active) .bind(can_approve) .bind(can_manage) .bind(id) @@ -393,7 +402,6 @@ async fn update_role( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; - // Replace permissions if provided if let Some(keys) = &payload.permission_keys { sqlx::query("DELETE FROM role_permissions WHERE role_id = $1") .bind(id) @@ -413,7 +421,6 @@ async fn update_role( } } - // Return updated role get_role(State(state), Path(id)).await } @@ -421,7 +428,7 @@ async fn delete_role( State(state): State, Path(id): Path, ) -> Result { - let result = sqlx::query("DELETE FROM roles WHERE id = $1") + let result = sqlx::query("DELETE FROM roles WHERE id = $1 AND audience = 'INTERNAL'") .bind(id) .execute(&state.pool) .await diff --git a/apps/users/src/handlers/settings.rs b/apps/users/src/handlers/settings.rs index c991e9b..7fefe45 100644 --- a/apps/users/src/handlers/settings.rs +++ b/apps/users/src/handlers/settings.rs @@ -225,7 +225,7 @@ async fn create_delete_account_request( .mail .send_account_deleted_email( &user.email, - user.name.as_deref().unwrap_or_default(), + &format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), ) .await; let _ = sqlx::query( diff --git a/apps/users/src/handlers/support.rs b/apps/users/src/handlers/support.rs index 4e75ff1..32d48c0 100644 --- a/apps/users/src/handlers/support.rs +++ b/apps/users/src/handlers/support.rs @@ -137,7 +137,7 @@ async fn user_create_ticket( }; let _ = state.mail.send_support_ticket_created_email( &user.email, - user.name.as_deref().unwrap_or_default(), + &format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), &r.id.to_string(), &body.subject, &category, @@ -444,14 +444,10 @@ async fn admin_list_cases( t.id, t.subject, t.description, t.category, t.priority, t.status, t.requester_name, t.requester_email, t.assigned_to, t.created_at, t.updated_at, - u.name AS user_name, u.email AS user_email + CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email FROM support_tickets t LEFT JOIN users u ON u.id = t.user_id - WHERE ($1 = '' OR t.status = $1) - AND ($2 = '' OR t.priority = $2) - AND ($3 = '' OR t.category = $3) - ORDER BY t.updated_at DESC - LIMIT $4 OFFSET $5 + WHERE t.id = $1 "#, ) .bind(&status_filter) @@ -586,10 +582,14 @@ async fn admin_get_case( t.id, t.subject, t.description, t.category, t.priority, t.status, t.requester_name, t.requester_email, t.assigned_to, t.created_at, t.updated_at, - u.name AS user_name, u.email AS user_email + CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email FROM support_tickets t LEFT JOIN users u ON u.id = t.user_id - WHERE t.id = $1 + WHERE ($1 = '' OR t.status = $1) + AND ($2 = '' OR t.priority = $2) + AND ($3 = '' OR t.category = $3) + ORDER BY t.updated_at DESC + LIMIT $4 OFFSET $5 "#, ) .bind(id) @@ -832,7 +832,7 @@ async fn admin_add_message( if let Some(user_email) = ticket.requester_email { // Try to get user name from user table let user_name = if let Ok(user) = db::models::user::UserRepository::get_by_email(&state.pool, &user_email).await { - user.name.unwrap_or_default() + format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()) } else { ticket.requester_name.unwrap_or_default() }; diff --git a/apps/users/src/handlers/verifications.rs b/apps/users/src/handlers/verifications.rs index c861803..4522787 100644 --- a/apps/users/src/handlers/verifications.rs +++ b/apps/users/src/handlers/verifications.rs @@ -136,21 +136,17 @@ async fn trigger_rejection( }; let query = format!( - "UPDATE {} SET verification_status = 'REJECTED', updated_at = NOW() WHERE id = $1", + "UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE id = $1", table ); sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?; // Send Email - if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await { - let display = role_key_to_display(&role_key); - let _ = state.mail.send_approval_rejected_email( - &user.email, - user.name.as_deref().unwrap_or_default(), - &display, - reason_str - ).await; - } + if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await { + let display = role_key_to_display(&role_key); + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); + let _ = state.mail.send_approval_rejected_email(&user.email, &user_name, &display, reason_str).await; + } } Ok(()) @@ -180,11 +176,8 @@ async fn approve_verification( // Send approval email if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, v.user_id).await { let display = role_key_to_display(&v.role_key); - let _ = state.mail.send_approval_approved_email( - &user.email, - user.name.as_deref().unwrap_or_default(), - &display - ).await; + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); + let _ = state.mail.send_approval_approved_email(&user.email, &user_name, &display).await; } (StatusCode::OK, Json(v)).into_response() } @@ -294,12 +287,8 @@ async fn request_documents( // Send email notification if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, v.user_id).await { let display = role_key_to_display(&v.role_key); - let _ = state.mail.send_documents_requested_email( - &user.email, - user.name.as_deref().unwrap_or_default(), - &display, - &payload.message - ).await; + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); + let _ = state.mail.send_documents_requested_email(&user.email, &user_name, &display, &payload.message).await; } (StatusCode::OK, Json(v)).into_response() diff --git a/companies.pid b/companies.pid new file mode 100644 index 0000000..465e8a5 --- /dev/null +++ b/companies.pid @@ -0,0 +1 @@ +9692 diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 13d9e48..c7ae631 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -183,7 +183,7 @@ async fn send_lead_request( Err(_) => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(), }; - if wallet.current_balance < 25 { + if wallet.balance < 25 { return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response(); } @@ -374,7 +374,7 @@ async fn my_requests( sqlx::query_as::<_, RichLeadReq>( r#" SELECT lr.*, r.title as req_title, r.profession_key as req_profession_key, r.location as req_location, r.budget as req_budget, - CASE WHEN lr.status = 'ACCEPTED' THEN u.name ELSE NULL END as customer_name, + CASE WHEN lr.status = 'ACCEPTED' THEN CONCAT(u.first_name, ' ', u.last_name) AS name ELSE NULL END as customer_name, CASE WHEN lr.status = 'ACCEPTED' THEN u.email ELSE NULL END as customer_email, CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone FROM lead_requests lr @@ -390,7 +390,7 @@ async fn my_requests( sqlx::query_as::<_, RichLeadReq>( r#" SELECT lr.*, r.title as req_title, r.profession_key as req_profession_key, r.location as req_location, r.budget as req_budget, - CASE WHEN lr.status = 'ACCEPTED' THEN u.name ELSE NULL END as customer_name, + CASE WHEN lr.status = 'ACCEPTED' THEN CONCAT(u.first_name, ' ', u.last_name) AS name ELSE NULL END as customer_name, CASE WHEN lr.status = 'ACCEPTED' THEN u.email ELSE NULL END as customer_email, CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone FROM lead_requests lr @@ -567,7 +567,7 @@ async fn accepted_lead_detail( r.location AS requirement_location, r.profession_key, r.custom_fields, - u.name AS customer_name, + CONCAT(u.first_name, ' ', u.last_name) AS name AS customer_name, u.email AS customer_email, u.phone AS customer_phone FROM lead_requests lr diff --git a/crates/db/src/models/config.rs b/crates/db/src/models/config.rs index 9abc25b..9925a7b 100644 --- a/crates/db/src/models/config.rs +++ b/crates/db/src/models/config.rs @@ -32,6 +32,7 @@ pub struct CreateOnboardingConfigPayload { #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct DashboardConfigListItem { pub id: Uuid, + pub role_id: Uuid, pub role_key: String, pub audience: String, pub config_json: serde_json::Value, @@ -42,7 +43,7 @@ pub struct DashboardConfigListItem { #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct DashboardConfig { pub id: Uuid, - pub role_key: String, + pub role_id: Uuid, pub audience: String, pub config_json: serde_json::Value, pub is_active: bool, @@ -172,40 +173,31 @@ impl ConfigRepository { pool: &PgPool, payload: CreateDashboardConfigPayload, ) -> Result { - let role_key = sqlx::query_scalar::<_, String>( - "SELECT code FROM roles WHERE id = $1", - ) - .bind(payload.role_id) - .fetch_one(pool) - .await?; - - // Soft-disable previous active configs for this role sqlx::query( r#" UPDATE dashboard_configs SET is_active = false - WHERE UPPER(role_key) = UPPER($1) AND audience = $2::text AND is_active = true + WHERE role_id = $1 AND audience = $2::text AND is_active = true "#, ) - .bind(&role_key) + .bind(payload.role_id) .bind(&payload.audience) .execute(pool) .await?; - // Insert new config let config = sqlx::query_as::<_, DashboardConfig>( r#" - INSERT INTO dashboard_configs (role_key, audience, widgets, is_active) + INSERT INTO dashboard_configs (role_id, audience, config_json, is_active) VALUES ( $1, $2::text, $3, true ) - RETURNING id, role_key, audience, widgets as config_json, is_active, updated_at + RETURNING id, role_id, audience, config_json, is_active, updated_at "#, ) - .bind(&role_key) + .bind(payload.role_id) .bind(&payload.audience) .bind(payload.config_json) .fetch_one(pool) @@ -219,21 +211,14 @@ impl ConfigRepository { role_id: Uuid, audience: &str, ) -> Result { - let role_key = sqlx::query_scalar::<_, String>( - "SELECT code FROM roles WHERE id = $1", - ) - .bind(role_id) - .fetch_one(pool) - .await?; - let config = sqlx::query_as::<_, DashboardConfig>( r#" - SELECT id, role_key, audience, widgets as config_json, is_active, updated_at + SELECT id, role_id, audience, config_json, is_active, updated_at FROM dashboard_configs - WHERE UPPER(role_key) = UPPER($1) AND audience = $2 AND is_active = true + WHERE role_id = $1 AND audience = $2 AND is_active = true "#, ) - .bind(role_key) + .bind(role_id) .bind(audience) .fetch_one(pool) .await?; @@ -247,8 +232,9 @@ impl ConfigRepository { let configs = sqlx::query_as::<_, DashboardConfigListItem>( r#" SELECT - c.id, c.role_key, c.audience, c.widgets as config_json, c.is_active, c.updated_at + c.id, c.role_id, r.key as role_key, c.audience, c.config_json, c.is_active, c.updated_at FROM dashboard_configs c + JOIN roles r ON c.role_id = r.id ORDER BY c.updated_at DESC "#, ) @@ -265,9 +251,10 @@ impl ConfigRepository { ) -> Result { let config = sqlx::query_as::<_, DashboardConfig>( r#" - SELECT c.id, c.role_key, c.audience, c.widgets as config_json, c.is_active, c.updated_at + SELECT c.id, c.role_id, c.audience, c.config_json, c.is_active, c.updated_at FROM dashboard_configs c - WHERE UPPER(c.role_key) = UPPER($1) AND c.audience = $2 AND c.is_active = true + JOIN roles r ON c.role_id = r.id + WHERE r.key = $1 AND c.audience = $2 AND c.is_active = true "#, ) .bind(role_key) diff --git a/crates/db/src/models/customer.rs b/crates/db/src/models/customer.rs index 0f3a5c4..8d44fde 100644 --- a/crates/db/src/models/customer.rs +++ b/crates/db/src/models/customer.rs @@ -7,7 +7,8 @@ use uuid::Uuid; pub struct CustomerProfile { pub id: Uuid, pub user_id: Uuid, - pub full_name: Option, + pub first_name: Option, + pub last_name: Option, pub phone: Option, pub city: Option, pub area: Option, @@ -15,7 +16,6 @@ pub struct CustomerProfile { pub active_requirement_count: i32, pub status: String, pub bio: Option, - pub experience_years: Option, pub custom_data: Option, pub created_at: DateTime, pub updated_at: DateTime, @@ -23,7 +23,8 @@ pub struct CustomerProfile { #[derive(Debug, Serialize, Deserialize)] pub struct UpsertCustomerProfilePayload { - pub full_name: Option, + pub first_name: Option, + pub last_name: Option, pub phone: Option, pub city: Option, pub area: Option, @@ -42,8 +43,8 @@ impl CustomerRepository { let profile = sqlx::query_as::<_, CustomerProfile>( r#" SELECT - id, user_id, full_name, phone, city, area, preferred_professions, - active_requirement_count, status, bio, experience_years, custom_data, + id, user_id, first_name, last_name, phone, city, area, preferred_professions, + active_requirement_count, status, bio, custom_data, created_at, updated_at FROM customer_profiles WHERE user_id = $1 @@ -64,11 +65,12 @@ impl CustomerRepository { let profile = sqlx::query_as::<_, CustomerProfile>( r#" INSERT INTO customer_profiles ( - user_id, full_name, phone, city, area, preferred_professions, bio, custom_data, status + user_id, first_name, last_name, phone, city, area, preferred_professions, bio, custom_data, status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'PENDING') ON CONFLICT (user_id) DO UPDATE SET - full_name = EXCLUDED.full_name, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, phone = EXCLUDED.phone, city = EXCLUDED.city, area = EXCLUDED.area, @@ -81,13 +83,14 @@ impl CustomerRepository { END, updated_at = NOW() RETURNING - id, user_id, full_name, phone, city, area, preferred_professions, - active_requirement_count, status, bio, experience_years, custom_data, + id, user_id, first_name, last_name, phone, city, area, preferred_professions, + active_requirement_count, status, bio, custom_data, created_at, updated_at "#, ) .bind(user_id) - .bind(payload.full_name) + .bind(payload.first_name) + .bind(payload.last_name) .bind(payload.phone) .bind(payload.city) .bind(payload.area) @@ -125,8 +128,8 @@ impl CustomerRepository { SET status = 'PENDING_REVIEW', updated_at = NOW() WHERE user_id = $1 RETURNING - id, user_id, full_name, phone, city, area, preferred_professions, - active_requirement_count, status, bio, experience_years, custom_data, + id, user_id, first_name, last_name, phone, city, area, preferred_professions, + active_requirement_count, status, bio, custom_data, created_at, updated_at "#, ) diff --git a/crates/db/src/models/department.rs b/crates/db/src/models/department.rs index ff459d5..1c696ad 100644 --- a/crates/db/src/models/department.rs +++ b/crates/db/src/models/department.rs @@ -12,8 +12,6 @@ pub struct Department { pub department_head: Option, pub department_email: Option, pub is_active: bool, - pub visibility: String, - pub transfers_enabled: bool, pub created_at: DateTime, pub updated_at: DateTime, } @@ -25,9 +23,7 @@ pub struct CreateDepartmentPayload { pub description: Option, pub department_head: Option, pub department_email: Option, - pub status: Option, // ACTIVE | INACTIVE - pub visibility: Option, // INTERNAL | EXTERNAL - pub transfers_enabled: Option, + pub status: Option, } pub struct DepartmentRepository; @@ -35,17 +31,15 @@ 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 visibility = payload.visibility.unwrap_or_else(|| "INTERNAL".to_string()); - let transfers_enabled = payload.transfers_enabled.unwrap_or(false); sqlx::query_as::<_, Department>( r#" INSERT INTO departments ( name, code, description, department_head, department_email, - is_active, visibility, transfers_enabled + is_active ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, name, code, description, department_head, department_email, is_active, visibility, transfers_enabled, created_at, updated_at + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, name, code, description, department_head, department_email, is_active, created_at, updated_at "# ) .bind(payload.name) @@ -54,8 +48,6 @@ impl DepartmentRepository { .bind(payload.department_head) .bind(payload.department_email) .bind(is_active) - .bind(visibility) - .bind(transfers_enabled) .fetch_one(pool) .await } @@ -68,8 +60,6 @@ impl DepartmentRepository { let department_email = payload.get("department_email").map(|v| v.as_str().unwrap_or_default()); let status = payload.get("status").and_then(|v| v.as_str()); let is_active = status.map(|s| s.to_uppercase() == "ACTIVE"); - let visibility = payload.get("visibility").and_then(|v| v.as_str()); - let transfers_enabled = payload.get("transfers_enabled").and_then(|v| v.as_bool()); sqlx::query_as::<_, Department>( r#" @@ -80,11 +70,9 @@ impl DepartmentRepository { department_head = COALESCE($5, department_head), department_email = COALESCE($6, department_email), is_active = COALESCE($7, is_active), - visibility = COALESCE($8, visibility), - transfers_enabled = COALESCE($9, transfers_enabled), updated_at = NOW() WHERE id = $1 - RETURNING id, name, code, description, department_head, department_email, is_active, visibility, transfers_enabled, created_at, updated_at + RETURNING id, name, code, description, department_head, department_email, is_active, created_at, updated_at "# ) .bind(id) @@ -94,8 +82,6 @@ impl DepartmentRepository { .bind(department_head) .bind(department_email) .bind(is_active) - .bind(visibility) - .bind(transfers_enabled) .fetch_one(pool) .await } @@ -110,7 +96,7 @@ impl DepartmentRepository { pub async fn list(pool: &PgPool) -> Result, sqlx::Error> { sqlx::query_as::<_, Department>( - "SELECT id, name, code, description, department_head, department_email, is_active, visibility, transfers_enabled, created_at, updated_at FROM departments ORDER BY name ASC" + "SELECT id, name, code, description, department_head, department_email, is_active, created_at, updated_at FROM departments ORDER BY name ASC" ) .fetch_all(pool) .await @@ -118,7 +104,7 @@ impl DepartmentRepository { pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, Department>( - "SELECT id, name, code, description, department_head, department_email, is_active, visibility, transfers_enabled, created_at, updated_at FROM departments WHERE id = $1" + "SELECT id, name, code, description, department_head, department_email, is_active, created_at, updated_at FROM departments WHERE id = $1" ) .bind(id) .fetch_optional(pool) diff --git a/crates/db/src/models/employee.rs b/crates/db/src/models/employee.rs index 1076b4e..044be94 100644 --- a/crates/db/src/models/employee.rs +++ b/crates/db/src/models/employee.rs @@ -15,7 +15,7 @@ pub struct Employee { pub designation_id: Option, pub role_code: String, pub status: String, - pub joined_at: NaiveDate, + pub joining_date: NaiveDate, pub created_at: DateTime, pub updated_at: DateTime, } @@ -50,7 +50,7 @@ impl EmployeeRepository { 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, joined_at, created_at, updated_at + RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at "# ) .bind(payload.first_name) @@ -88,7 +88,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, joined_at, created_at, updated_at + RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at "# ) .bind(first_name) @@ -113,7 +113,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, joined_at, created_at, updated_at FROM employees WHERE email = $1" + "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" ) .bind(email.to_lowercase()) .fetch_optional(pool) @@ -124,7 +124,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, joined_at, created_at, updated_at + 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 ($1 = '' OR LOWER(first_name) LIKE '%' || $1 || '%' OR LOWER(last_name) LIKE '%' || $1 || '%' OR LOWER(email) LIKE '%' || $1 || '%') ORDER BY last_name, first_name diff --git a/crates/db/src/models/job_seeker.rs b/crates/db/src/models/job_seeker.rs index 90bc991..4080368 100644 --- a/crates/db/src/models/job_seeker.rs +++ b/crates/db/src/models/job_seeker.rs @@ -7,7 +7,8 @@ use uuid::Uuid; pub struct JobSeekerProfile { pub id: Uuid, pub user_id: Uuid, - pub full_name: Option, + pub first_name: Option, + pub last_name: Option, pub location: Option, pub summary: Option, pub experience_years: Option, @@ -23,7 +24,8 @@ pub struct JobSeekerProfile { #[derive(Debug, Serialize, Deserialize)] pub struct UpsertJobSeekerProfilePayload { - pub full_name: Option, + pub first_name: Option, + pub last_name: Option, pub location: Option, pub summary: Option, pub experience_years: Option, @@ -43,7 +45,7 @@ impl JobSeekerRepository { let profile = sqlx::query_as::<_, JobSeekerProfile>( r#" SELECT - id, user_id, full_name, location, summary, experience_years, + id, user_id, first_name, last_name, location, summary, experience_years, skills, resume_url, active_application_count, status, bio, custom_data, created_at, updated_at FROM job_seeker_profiles @@ -65,12 +67,13 @@ impl JobSeekerRepository { let profile = sqlx::query_as::<_, JobSeekerProfile>( r#" INSERT INTO job_seeker_profiles ( - user_id, full_name, location, summary, experience_years, + user_id, first_name, last_name, location, summary, experience_years, skills, resume_url, bio, custom_data ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (user_id) DO UPDATE SET - full_name = EXCLUDED.full_name, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, location = EXCLUDED.location, summary = EXCLUDED.summary, experience_years = EXCLUDED.experience_years, @@ -80,13 +83,14 @@ impl JobSeekerRepository { custom_data = EXCLUDED.custom_data, updated_at = NOW() RETURNING - id, user_id, full_name, location, summary, experience_years, + id, user_id, first_name, last_name, location, summary, experience_years, skills, resume_url, active_application_count, status, bio, custom_data, created_at, updated_at "#, ) .bind(user_id) - .bind(payload.full_name) + .bind(payload.first_name) + .bind(payload.last_name) .bind(payload.location) .bind(payload.summary) .bind(payload.experience_years.unwrap_or(0)) @@ -125,7 +129,7 @@ impl JobSeekerRepository { SET status = 'PENDING_REVIEW', updated_at = NOW() WHERE user_id = $1 RETURNING - id, user_id, full_name, location, summary, experience_years, + id, user_id, first_name, last_name, location, summary, experience_years, skills, resume_url, active_application_count, status, bio, custom_data, created_at, updated_at "#, diff --git a/crates/db/src/models/requirement.rs b/crates/db/src/models/requirement.rs index b0912d5..5fe335f 100644 --- a/crates/db/src/models/requirement.rs +++ b/crates/db/src/models/requirement.rs @@ -10,8 +10,8 @@ pub struct Requirement { pub title: String, pub description: String, pub location: String, - pub budget: Option, - pub preferred_date: Option, + pub budget_inr: Option, + pub required_date: Option, pub extra_data_json: Option, pub status: String, pub rejection_reason: Option, @@ -22,7 +22,6 @@ pub struct Requirement { pub approved_by: Option, pub created_at: DateTime, pub created_by_user_id: Option, - pub required_date: Option, pub updated_at: DateTime, } @@ -32,8 +31,8 @@ pub struct CreateRequirementPayload { pub title: String, pub description: String, pub location: String, - pub budget: Option, - pub preferred_date: Option, + pub budget_inr: Option, + pub required_date: Option, pub extra_data_json: Option, } @@ -42,8 +41,8 @@ pub struct UpdateRequirementPayload { pub title: Option, pub description: Option, pub location: Option, - pub budget: Option, - pub preferred_date: Option, + pub budget_inr: Option, + pub required_date: Option, pub extra_data_json: Option, } @@ -58,7 +57,7 @@ impl RequirementRepository { r#" INSERT INTO leads ( profession_key, title, description, location, - budget, preferred_date, extra_data_json + budget_inr, required_date, extra_data_json ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * @@ -68,8 +67,8 @@ impl RequirementRepository { .bind(payload.title) .bind(payload.description) .bind(payload.location) - .bind(payload.budget) - .bind(payload.preferred_date) + .bind(payload.budget_inr) + .bind(payload.required_date) .bind(payload.extra_data_json) .fetch_one(pool) .await?; @@ -119,8 +118,8 @@ impl RequirementRepository { title = COALESCE($1, title), description = COALESCE($2, description), location = COALESCE($3, location), - budget = COALESCE($4, budget), - preferred_date = COALESCE($5, preferred_date), + budget_inr = COALESCE($4, budget_inr), + required_date = COALESCE($5, required_date), extra_data_json = COALESCE($6, extra_data_json), updated_at = NOW() WHERE id = $7 @@ -130,8 +129,8 @@ impl RequirementRepository { .bind(payload.title) .bind(payload.description) .bind(payload.location) - .bind(payload.budget) - .bind(payload.preferred_date) + .bind(payload.budget_inr) + .bind(payload.required_date) .bind(payload.extra_data_json) .bind(id) .fetch_one(pool) diff --git a/crates/db/src/models/tracecoin_wallet.rs b/crates/db/src/models/tracecoin_wallet.rs index fc4c816..3fe81b9 100644 --- a/crates/db/src/models/tracecoin_wallet.rs +++ b/crates/db/src/models/tracecoin_wallet.rs @@ -7,7 +7,7 @@ use uuid::Uuid; pub struct Wallet { pub id: Uuid, pub user_id: Uuid, - pub current_balance: i32, + pub balance: i32, pub reserved: i32, pub updated_at: DateTime, } @@ -40,7 +40,7 @@ impl TracecoinWalletRepository { pub async fn ensure_wallet(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> { sqlx::query( r#" - INSERT INTO tracecoin_wallets (user_id, current_balance, reserved) + INSERT INTO tracecoin_wallets (user_id, balance, reserved) VALUES ($1, 0, 0) ON CONFLICT (user_id) DO NOTHING "#, @@ -61,7 +61,7 @@ impl TracecoinWalletRepository { sqlx::query( r#" - INSERT INTO tracecoin_wallets (user_id, current_balance, reserved) + INSERT INTO tracecoin_wallets (user_id, balance, reserved) VALUES ($1, 0, 0) ON CONFLICT (user_id) DO NOTHING "#, @@ -77,7 +77,7 @@ impl TracecoinWalletRepository { .fetch_one(&mut *tx) .await?; - if wallet.current_balance < amount { + if wallet.balance < amount { tx.rollback().await?; return Ok(false); } @@ -85,7 +85,7 @@ impl TracecoinWalletRepository { sqlx::query( r#" UPDATE tracecoin_wallets - SET current_balance = current_balance - $1, reserved = reserved + $1, updated_at = NOW() + SET balance = balance - $1, reserved = reserved + $1, updated_at = NOW() WHERE id = $2 "#, ) @@ -192,7 +192,7 @@ impl TracecoinWalletRepository { sqlx::query( r#" UPDATE tracecoin_wallets - SET reserved = reserved - $1, current_balance = current_balance + $1, updated_at = NOW() + SET reserved = reserved - $1, balance = balance + $1, updated_at = NOW() WHERE id = $2 "#, ) diff --git a/crates/db/src/models/user.rs b/crates/db/src/models/user.rs index c3d96a9..24fb9c9 100644 --- a/crates/db/src/models/user.rs +++ b/crates/db/src/models/user.rs @@ -10,7 +10,8 @@ pub struct User { pub id: Uuid, pub email: String, pub password_hash: String, - pub name: Option, + pub first_name: Option, + pub last_name: Option, pub email_verified: bool, pub phone_verified: bool, pub status: String, // ACTIVE, SUSPENDED, BANNED @@ -26,7 +27,8 @@ pub struct User { #[derive(Debug, Serialize, Deserialize)] pub struct CreateUserPayload { - pub name: String, + pub first_name: Option, + pub last_name: Option, pub email: String, pub password_hash: String, } @@ -49,17 +51,18 @@ impl UserRepository { pub async fn create(pool: &PgPool, payload: CreateUserPayload) -> Result { let user = sqlx::query_as::<_, User>( r#" - INSERT INTO users (name, email, password_hash, email_verified, phone_verified) + INSERT INTO users (first_name, last_name, email, password_hash, email_verified, phone_verified) VALUES ($1, $2, $3, false, false) RETURNING - id, email, password_hash, name, + id, email, password_hash, first_name, last_name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, created_at, updated_at, deleted_at "#, ) - .bind(&payload.name) + .bind(&payload.first_name) + .bind(&payload.last_name) .bind(payload.email.to_lowercase()) .bind(payload.password_hash) .fetch_one(pool) @@ -71,7 +74,7 @@ impl UserRepository { pub async fn get_by_email(pool: &PgPool, email: &str) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, name, + SELECT id, email, password_hash, first_name, last_name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -88,7 +91,7 @@ impl UserRepository { pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, name, + SELECT id, email, password_hash, first_name, last_name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -106,7 +109,7 @@ impl UserRepository { pub async fn get_user_role_keys(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { let rows = sqlx::query_scalar::<_, String>( r#" - SELECT r.code + SELECT r.key FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = $1 AND ur.status = 'APPROVED' @@ -145,7 +148,7 @@ impl UserRepository { pub async fn get_by_verification_token(pool: &PgPool, token: &str) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, name, + SELECT id, email, password_hash, first_name, last_name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -193,7 +196,7 @@ impl UserRepository { pub async fn get_by_reset_token(pool: &PgPool, token: &str) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, name, + SELECT id, email, password_hash, first_name, last_name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, diff --git a/crates/db/src/models/verification.rs b/crates/db/src/models/verification.rs index 757ce56..c05dfeb 100644 --- a/crates/db/src/models/verification.rs +++ b/crates/db/src/models/verification.rs @@ -23,12 +23,12 @@ pub struct Verification { #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct VerificationLog { pub id: Uuid, - pub verification_id: Uuid, + pub verification_request_id: Uuid, pub action: String, - pub actor_id: Option, + pub acted_by_user_id: Option, pub old_status: Option, pub new_status: Option, - pub message: Option, + pub remarks: Option, pub created_at: DateTime, } @@ -81,9 +81,8 @@ impl VerificationRepository { if status.is_some() { query.push_str(" AND status = $1"); } if case_type.is_some() { query.push_str(if status.is_some() { " AND case_type = $2" } else { " AND case_type = $1" }); } - query.push_str(" ORDER BY created_at DESC LIMIT $3 OFFSET $4"); // This simplified query string concatenation is for readability, handle properly in prod. + query.push_str(" ORDER BY created_at DESC LIMIT $3 OFFSET $4"); - // Actually implementing with sqlx properly: sqlx::query_as::<_, Verification>( r#" SELECT * FROM verifications @@ -135,7 +134,7 @@ impl VerificationRepository { sqlx::query( r#" - INSERT INTO verification_logs (verification_id, action, actor_id, old_status, new_status, message) + INSERT INTO verification_logs (verification_request_id, action, acted_by_user_id, old_status, new_status, remarks) VALUES ($1, 'STATUS_CHANGE', $2, $3, $4, $5) "# ) diff --git a/customers.pid b/customers.pid new file mode 100644 index 0000000..b693d20 --- /dev/null +++ b/customers.pid @@ -0,0 +1 @@ +9694 diff --git a/gateway.pid b/gateway.pid index bf2b153..30f4094 100644 --- a/gateway.pid +++ b/gateway.pid @@ -1 +1 @@ -97314 +9690 diff --git a/job_seekers.pid b/job_seekers.pid new file mode 100644 index 0000000..a1d3082 --- /dev/null +++ b/job_seekers.pid @@ -0,0 +1 @@ +9693 diff --git a/scripts/init-db.sql b/scripts/init-db.sql index d795c51..fa8ae30 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -1,275 +1,46 @@ --- 1. ROLES +-- ============================================================================ +-- Nxtgauge Database — Complete Schema +-- Source: nxtgauge_database_source_of_truth.md +-- No duplicates. Every domain table from the document included. +-- ============================================================================ + +BEGIN; + +-- ============================================================================ +-- 1. IDENTITY & ACCESS CONTROL (users, roles, permissions, sessions, tokens) +-- ============================================================================ + CREATE TABLE IF NOT EXISTS roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), key VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, - audience VARCHAR(50) NOT NULL, -- INTERNAL or EXTERNAL + audience VARCHAR(50) NOT NULL, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- 2. ONBOARDING CONFIGS -CREATE TABLE IF NOT EXISTS onboarding_configs ( +CREATE TABLE IF NOT EXISTS internal_roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - schema_json JSONB NOT NULL, - version INTEGER NOT NULL DEFAULT 1, - is_active BOOLEAN NOT NULL DEFAULT true, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + role_id UUID NOT NULL UNIQUE REFERENCES roles(id) ON DELETE CASCADE, + description TEXT, + department_id UUID, + can_approve_requests BOOLEAN NOT NULL DEFAULT false, + can_manage_system_settings BOOLEAN NOT NULL DEFAULT false ); --- Only one active onboarding config per role at a time -CREATE UNIQUE INDEX IF NOT EXISTS idx_active_onboarding_per_role - ON onboarding_configs(role_id) WHERE is_active = true; - --- 3. DASHBOARD CONFIGS -CREATE TABLE IF NOT EXISTS dashboard_configs ( +CREATE TABLE IF NOT EXISTS external_roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - audience VARCHAR(50) NOT NULL, -- INTERNAL or EXTERNAL - config_json JSONB NOT NULL, - version INTEGER NOT NULL DEFAULT 1, - is_active BOOLEAN NOT NULL DEFAULT true, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + role_id UUID NOT NULL UNIQUE REFERENCES roles(id) ON DELETE CASCADE ); --- Only one active dashboard config per role+audience combination -CREATE UNIQUE INDEX IF NOT EXISTS idx_active_dashboard_per_role_audience - ON dashboard_configs(role_id, audience) WHERE is_active = true; - --- 4. RUNTIME CONFIGS -CREATE TABLE IF NOT EXISTS runtime_configs ( +CREATE TABLE IF NOT EXISTS permissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - config_json JSONB NOT NULL, - version INTEGER NOT NULL DEFAULT 1, - is_active BOOLEAN NOT NULL DEFAULT true, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Only one active runtime config per role at a time -CREATE UNIQUE INDEX IF NOT EXISTS idx_active_runtime_per_role - ON runtime_configs(role_id) WHERE is_active = true; --- 1. USERS -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email VARCHAR(255) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE, PENDING, SUSPENDED - role_id UUID REFERENCES roles(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 2. REFRESH TOKENS -CREATE TABLE IF NOT EXISTS refresh_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash VARCHAR(255) UNIQUE NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - revoked BOOLEAN NOT NULL DEFAULT false, + key VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + module VARCHAR(100), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- Index for fast token lookups -CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash); -CREATE TABLE IF NOT EXISTS photographer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Photographer Specific Fields - portfolio_url VARCHAR(255), - equipment_list TEXT, - years_of_experience INT, - hourly_rate INTEGER, -- in paise (INR × 100) - specialties TEXT[], -- e.g., ["wedding", "portrait", "commercial"] - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Ensure a user can only have one photographer profile - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS tutor_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Tutor Specific Fields - subjects_taught TEXT[], -- e.g., ["math", "physics", "computer science"] - education_level VARCHAR(255), - certifications TEXT, - years_of_experience INT, - hourly_rate INTEGER, -- in paise (INR × 100) - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Ensure a user can only have one tutor profile - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS company_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Company Specific Fields - company_name VARCHAR(255) NOT NULL, - registration_number VARCHAR(100), - industry VARCHAR(150), - website_url VARCHAR(255), - employee_count INT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Ensure a user can only have one company profile - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS job_seeker_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Job Seeker - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS customer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Customer - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS makeup_artist_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Makeup Artist - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS developer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Developer - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS video_editor_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Video Editor - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS graphic_designer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Graphic Designer - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS social_media_manager_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Social Media Manager - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS fitness_trainer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Fitness Trainer - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS catering_service_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Catering Service - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); --- Add missing columns to users table -ALTER TABLE users - ADD COLUMN IF NOT EXISTS full_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS phone VARCHAR(20) UNIQUE, - ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS phone_verified BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; - --- user_roles: many-to-many, a user can hold multiple external roles -CREATE TABLE IF NOT EXISTS user_roles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, SUSPENDED - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(user_id, role_id) -); - --- role_permissions CREATE TABLE IF NOT EXISTS role_permissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, @@ -278,32 +49,860 @@ CREATE TABLE IF NOT EXISTS role_permissions ( UNIQUE(role_id, permission_key) ); --- departments for internal staff -CREATE TABLE IF NOT EXISTS departments ( +CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(100) NOT NULL UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + email VARCHAR(255) UNIQUE NOT NULL, + phone VARCHAR(20) UNIQUE, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(100), + last_name VARCHAR(100), + account_type TEXT DEFAULT 'INDIVIDUAL', + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + email_verified BOOLEAN NOT NULL DEFAULT false, + phone_verified BOOLEAN NOT NULL DEFAULT false, + role_id UUID REFERENCES roles(id) ON DELETE SET NULL, + last_login_at TIMESTAMPTZ, + email_verification_token VARCHAR(255), + email_verification_expires_at TIMESTAMPTZ, + reset_password_token VARCHAR(255), + reset_password_expires_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- designations for internal staff -CREATE TABLE IF NOT EXISTS designations ( +CREATE TABLE IF NOT EXISTS refresh_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(100) NOT NULL UNIQUE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash); --- employees (internal staff records) -CREATE TABLE IF NOT EXISTS employees ( +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT UNIQUE NOT NULL, + ip_address TEXT, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token); + +CREATE TABLE IF NOT EXISTS user_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_id) +); +CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); + +CREATE TABLE IF NOT EXISTS user_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - role_id UUID NOT NULL REFERENCES roles(id), - department_id UUID REFERENCES departments(id), - designation_id UUID REFERENCES designations(id), - employee_code VARCHAR(50), + settings JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 2. USER ROLE PROFILES (users can have multiple role profiles) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS user_role_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + display_name TEXT, + bio TEXT, + location TEXT, + avatar_url TEXT, + phone TEXT, + email TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + verification_status VARCHAR(50) DEFAULT 'PENDING', + approval_status VARCHAR(50) DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + is_profile_public BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_key) +); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_user_id ON user_role_profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); + +-- ============================================================================ +-- 3. ROLE EXTENSION TABLES (10 profession profiles) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS photographer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + specialties TEXT[] DEFAULT '{}', camera_brands TEXT[] DEFAULT '{}', + studio_available BOOLEAN NOT NULL DEFAULT false, outdoor_shoots BOOLEAN NOT NULL DEFAULT true, + travel_radius_km INTEGER DEFAULT 50, starting_price_inr INTEGER DEFAULT 0, + custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tutor_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + subjects TEXT[] DEFAULT '{}', board_types TEXT[] DEFAULT '{}', + qualification VARCHAR(255), teaches_online BOOLEAN NOT NULL DEFAULT true, + teaches_offline BOOLEAN NOT NULL DEFAULT true, experience_years INTEGER DEFAULT 0, + hourly_rate_inr INTEGER DEFAULT 0, custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS makeup_artist_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + specializations TEXT[] DEFAULT '{}', kit_brands TEXT[] DEFAULT '{}', + home_service BOOLEAN NOT NULL DEFAULT true, studio_available BOOLEAN NOT NULL DEFAULT false, + starting_price_inr INTEGER DEFAULT 0, custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS developer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + tech_stack TEXT[] DEFAULT '{}', experience_years INTEGER DEFAULT 0, + availability VARCHAR(50) DEFAULT 'FULL_TIME', hourly_rate_inr INTEGER DEFAULT 0, + remote_ok BOOLEAN NOT NULL DEFAULT true, custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS video_editor_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + software_skills TEXT[] DEFAULT '{}', style_tags TEXT[] DEFAULT '{}', + turnaround_days INTEGER DEFAULT 7, starting_price_inr INTEGER DEFAULT 0, + custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS graphic_designer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + design_tools TEXT[] DEFAULT '{}', style_tags TEXT[] DEFAULT '{}', + brand_experience BOOLEAN NOT NULL DEFAULT false, starting_price_inr INTEGER DEFAULT 0, + custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS social_media_manager_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + platforms TEXT[] DEFAULT '{}', industries TEXT[] DEFAULT '{}', + content_types TEXT[] DEFAULT '{}', avg_follower_growth_pct INTEGER DEFAULT 0, + starting_price_inr INTEGER DEFAULT 0, custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS fitness_trainer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + disciplines TEXT[] DEFAULT '{}', certifications TEXT[] DEFAULT '{}', + online_sessions BOOLEAN NOT NULL DEFAULT true, home_visits BOOLEAN NOT NULL DEFAULT false, + gym_based BOOLEAN NOT NULL DEFAULT false, per_session_rate_inr INTEGER DEFAULT 0, + custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS catering_service_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + business_name VARCHAR(255), bio TEXT, location VARCHAR(255), + cuisine_types TEXT[] DEFAULT '{}', event_types TEXT[] DEFAULT '{}', + min_guests INTEGER DEFAULT 10, max_guests INTEGER DEFAULT 500, + has_setup_team BOOLEAN NOT NULL DEFAULT true, has_serving_staff BOOLEAN NOT NULL DEFAULT true, + price_per_head_inr INTEGER DEFAULT 0, custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS ugc_content_creator_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + platforms TEXT[] DEFAULT '{}', content_niches TEXT[] DEFAULT '{}', + content_formats TEXT[] DEFAULT '{}', follower_count INTEGER DEFAULT 0, + avg_views_per_post INTEGER DEFAULT 0, has_media_kit BOOLEAN NOT NULL DEFAULT false, + instagram_handle VARCHAR(100), youtube_channel_url VARCHAR(500), + starting_price_inr INTEGER DEFAULT 0, custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 4. COMPANY PROFILES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS company_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + company_name VARCHAR(255) NOT NULL, + business_type VARCHAR(100), registration_number VARCHAR(100), + industry VARCHAR(150), website_url VARCHAR(255), employee_count INT, + gst_number VARCHAR(50), contact_name VARCHAR(255), + contact_email VARCHAR(255), contact_phone VARCHAR(20), + address_line1 VARCHAR(500), city VARCHAR(100), state VARCHAR(100), + country VARCHAR(100) NOT NULL DEFAULT 'India', postal_code VARCHAR(20), + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + free_job_slots INTEGER NOT NULL DEFAULT 1, + purchased_job_slots INTEGER NOT NULL DEFAULT 0, + free_contact_views INTEGER NOT NULL DEFAULT 30, + purchased_contact_views INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 5. CUSTOMER PROFILES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS customer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + full_name VARCHAR(255), bio TEXT, phone VARCHAR(20), + city VARCHAR(100), area VARCHAR(100), location VARCHAR(255), + preferred_professions TEXT[] DEFAULT '{}', + active_requirement_count INTEGER NOT NULL DEFAULT 0, + custom_data JSONB DEFAULT '{}'::jsonb, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 6. JOB SEEKER PROFILES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS job_seeker_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + full_name VARCHAR(255), bio TEXT, location VARCHAR(255), summary TEXT, + experience_years INTEGER DEFAULT 0, skills TEXT[] DEFAULT '{}', + resume_url VARCHAR(500), active_application_count INTEGER NOT NULL DEFAULT 0, + custom_data JSONB DEFAULT '{}'::jsonb, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 7. PORTFOLIO DOMAIN (native content only, no external links) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS portfolio_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, description TEXT, + tags TEXT[] DEFAULT '{}', display_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_portfolio_items_user_id ON portfolio_items(user_id); + +CREATE TABLE IF NOT EXISTS portfolio_images ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + portfolio_item_id UUID NOT NULL REFERENCES portfolio_items(id) ON DELETE CASCADE, + file_url VARCHAR(500) NOT NULL, display_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- onboarding_submissions: tracks verification submissions +CREATE TABLE IF NOT EXISTS services ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, description TEXT, + price INTEGER NOT NULL DEFAULT 0, duration_minutes INTEGER, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 8. VERIFICATION DOMAIN +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS verification_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE CASCADE, + verification_type VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_at TIMESTAMPTZ, + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_verification_requests_status ON verification_requests(status); + +CREATE TABLE IF NOT EXISTS verification_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id) ON DELETE CASCADE, + document_type VARCHAR(100) NOT NULL, + file_url VARCHAR(500) NOT NULL, + file_name VARCHAR(255), + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verification_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id) ON DELETE CASCADE, + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- verifications table used by Rust code +CREATE TABLE IF NOT EXISTS verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + priority VARCHAR(10) NOT NULL DEFAULT 'LOW', + case_type VARCHAR(50) NOT NULL, + payload JSONB NOT NULL DEFAULT '{}', + documents JSONB NOT NULL DEFAULT '[]', + notes TEXT, + rejection_reason TEXT, + assigned_to UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_verifications_user_id ON verifications(user_id); +CREATE INDEX IF NOT EXISTS idx_verifications_status ON verifications(status); +CREATE INDEX IF NOT EXISTS idx_verifications_case_type ON verifications(case_type); + +-- ============================================================================ +-- 9. APPROVAL DOMAIN +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS approval_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + approval_type VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + reviewed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_approval_requests_status ON approval_requests(status); +CREATE INDEX IF NOT EXISTS idx_approval_requests_entity ON approval_requests(entity_type, entity_id); + +CREATE TABLE IF NOT EXISTS approval_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + approval_request_id UUID NOT NULL REFERENCES approval_requests(id) ON DELETE CASCADE, + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 10. MARKETPLACE DOMAIN (jobs, leads, reviews) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES company_profiles(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, category VARCHAR(100), + description TEXT NOT NULL, location VARCHAR(255) NOT NULL, + job_type VARCHAR(50) NOT NULL DEFAULT 'FULL_TIME', + mode_of_work VARCHAR(50), + salary_min INTEGER, salary_max INTEGER, budget_inr INTEGER, + experience_years INTEGER, skills TEXT[] DEFAULT '{}', + posted_by_user_id UUID REFERENCES users(id), + status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', + rejection_reason TEXT, expires_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, approved_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_jobs_company_id ON jobs(company_id); +CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); + +CREATE TABLE IF NOT EXISTS job_applications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + applicant_user_id UUID REFERENCES users(id) ON DELETE CASCADE, + cover_note TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'APPLIED', + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_job_applications_job_id ON job_applications(job_id); + +CREATE TABLE IF NOT EXISTS leads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + profession_key VARCHAR(50) NOT NULL, + title VARCHAR(200) NOT NULL, description TEXT NOT NULL, + location VARCHAR(255) NOT NULL, budget_inr INTEGER, + required_date DATE, extra_data_json JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', + rejection_reason TEXT, + request_count INTEGER NOT NULL DEFAULT 0, + accepted_count INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, approved_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status); +CREATE INDEX IF NOT EXISTS idx_leads_profession_key ON leads(profession_key); + +CREATE TABLE IF NOT EXISTS lead_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + lead_id UUID REFERENCES leads(id) ON DELETE CASCADE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE CASCADE, + professional_user_id UUID REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + tracecoins_reserved INTEGER NOT NULL DEFAULT 25, + remarks TEXT, + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '1 day'), + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_lead_requests_lead_id ON lead_requests(lead_id); +CREATE INDEX IF NOT EXISTS idx_lead_requests_status ON lead_requests(status); + +CREATE TABLE IF NOT EXISTS reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + reviewer_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + entity_type VARCHAR(50) NOT NULL DEFAULT 'PLATFORM', + entity_id VARCHAR(255), + title VARCHAR(255), + rating SMALLINT CHECK (rating >= 1 AND rating <= 5), + review_text TEXT, + reviewer_name VARCHAR(255), + subject_type VARCHAR(50) NOT NULL DEFAULT 'PLATFORM', + subject_id VARCHAR(255), + status VARCHAR(20) NOT NULL DEFAULT 'PUBLISHED', + is_published BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 11. FINANCE DOMAIN (wallets, ledger, pricing, payments, invoices, orders) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS tracecoin_wallets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + balance INTEGER NOT NULL DEFAULT 0, + reserved INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tracecoin_ledger ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID NOT NULL REFERENCES tracecoin_wallets(id), + amount INTEGER NOT NULL, + transaction_type VARCHAR(20) NOT NULL, + reference_type VARCHAR(100), + reference_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_wallet_id ON tracecoin_ledger(wallet_id); + +CREATE OR REPLACE FUNCTION prevent_tracecoin_ledger_mutation() +RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'tracecoin_ledger is immutable; % is not allowed', TG_OP; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_prevent_tracecoin_ledger_update ON tracecoin_ledger; +CREATE TRIGGER trg_prevent_tracecoin_ledger_update + BEFORE UPDATE ON tracecoin_ledger FOR EACH ROW + EXECUTE FUNCTION prevent_tracecoin_ledger_mutation(); + +DROP TRIGGER IF EXISTS trg_prevent_tracecoin_ledger_delete ON tracecoin_ledger; +CREATE TRIGGER trg_prevent_tracecoin_ledger_delete + BEFORE DELETE ON tracecoin_ledger FOR EACH ROW + EXECUTE FUNCTION prevent_tracecoin_ledger_mutation(); + +CREATE TABLE IF NOT EXISTS pricing_packages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + role_key VARCHAR(50) NOT NULL, + package_type VARCHAR(50) NOT NULL, + tracecoins_amount INTEGER NOT NULL DEFAULT 0, + price_inr INTEGER NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + package_id UUID NOT NULL REFERENCES pricing_packages(id), + razorpay_order_id VARCHAR(100), + razorpay_payment_id VARCHAR(100), + amount_inr INTEGER NOT NULL, + tracecoins_credited INTEGER NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id); + +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES payments(id), + user_id UUID NOT NULL REFERENCES users(id), + invoice_number VARCHAR(50) NOT NULL UNIQUE, + subtotal INTEGER NOT NULL, + gst_amount INTEGER NOT NULL, + total INTEGER NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ISSUED', + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + file_url VARCHAR(500) +); +CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id); + +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + total_amount INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + package_id UUID REFERENCES pricing_packages(id), + item_type VARCHAR(50) NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + unit_price INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tax_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + tax_type VARCHAR(50) NOT NULL, + percentage DECIMAL(5,2) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + applicable_to TEXT[] DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS discounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + scope VARCHAR(20) NOT NULL DEFAULT 'ROLE', + role_key VARCHAR(50), + package_id UUID REFERENCES pricing_packages(id) ON DELETE SET NULL, + discount_type VARCHAR(20) NOT NULL, + discount_value INTEGER NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS coupons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) NOT NULL UNIQUE, + title VARCHAR(255), + description TEXT, + discount_type VARCHAR(20) NOT NULL, + discount_value INTEGER NOT NULL, + applies_to VARCHAR(50) NOT NULL DEFAULT 'ALL', + min_order_amount INTEGER NOT NULL DEFAULT 0, + max_uses INTEGER, + uses_count INTEGER NOT NULL DEFAULT 0, + per_user_limit INTEGER NOT NULL DEFAULT 1, + role_keys TEXT[] NOT NULL DEFAULT '{}', + valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), + valid_until TIMESTAMPTZ, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS coupon_uses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + payment_id UUID REFERENCES payments(id), + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(coupon_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_coupon_uses_user_id ON coupon_uses(user_id); + +-- ============================================================================ +-- 12. KNOWLEDGE BASE +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS kb_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL UNIQUE, + slug VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + display_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS kb_sections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID NOT NULL REFERENCES kb_categories(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + display_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS kb_articles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID NOT NULL REFERENCES kb_categories(id) ON DELETE CASCADE, + section_id UUID REFERENCES kb_sections(id), + title VARCHAR(500) NOT NULL, + slug VARCHAR(500) NOT NULL UNIQUE, + summary TEXT, + body TEXT NOT NULL, + content_markdown TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + target_roles TEXT[] DEFAULT '{}', + tags TEXT[] DEFAULT '{}', + views INTEGER NOT NULL DEFAULT 0, + created_by UUID REFERENCES users(id), + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_kb_articles_category_id ON kb_articles(category_id); + +CREATE TABLE IF NOT EXISTS kb_article_feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL REFERENCES kb_articles(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + helpful BOOLEAN NOT NULL, + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 13. SUPPORT SYSTEM +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS support_tickets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + subject VARCHAR(500) NOT NULL, + description TEXT, + category VARCHAR(50) NOT NULL DEFAULT 'GENERAL', + status VARCHAR(20) NOT NULL DEFAULT 'OPEN', + priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL', + assigned_to UUID REFERENCES users(id), + requester_name VARCHAR(255), + requester_email VARCHAR(255), + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_support_tickets_user_id ON support_tickets(user_id); +CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status); + +CREATE TABLE IF NOT EXISTS support_ticket_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES users(id), + body TEXT NOT NULL, + is_internal BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_support_ticket_messages_ticket_id ON support_ticket_messages(ticket_id); + +-- ============================================================================ +-- 14. NOTIFICATION SYSTEM +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + body TEXT, + type VARCHAR(50), + reference_id UUID, + is_read BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); + +CREATE TABLE IF NOT EXISTS notification_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + preferences JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS notification_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + trigger_event VARCHAR(100) NOT NULL UNIQUE, + title_template VARCHAR(500), + body_template TEXT, + channel VARCHAR(20) NOT NULL DEFAULT 'IN_APP', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS email_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + trigger VARCHAR(100) NOT NULL, + to_email VARCHAR(255) NOT NULL, + subject VARCHAR(500), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS smtp_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL DEFAULT 587, + username VARCHAR(255) NOT NULL, + password_encrypted TEXT NOT NULL, + from_email VARCHAR(255) NOT NULL, + from_name VARCHAR(255), + use_tls BOOLEAN NOT NULL DEFAULT true, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 15. DASHBOARD SYSTEM +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS dashboard_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + audience VARCHAR(50) NOT NULL, + config_json JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_dashboard_per_role_audience + ON dashboard_configs(role_id, audience) WHERE is_active = true; + +CREATE TABLE IF NOT EXISTS dashboard_widgets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dashboard_config_id UUID NOT NULL REFERENCES dashboard_configs(id) ON DELETE CASCADE, + widget_key VARCHAR(100) NOT NULL, + widget_type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + config_json JSONB NOT NULL DEFAULT '{}', + display_order INTEGER NOT NULL DEFAULT 0, + is_visible BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS runtime_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + config_json JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_runtime_per_role + ON runtime_configs(role_id) WHERE is_active = true; + +-- ============================================================================ +-- 16. ONBOARDING (deprecated but tables kept for backward compat) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS onboarding_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + schema_json JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_onboarding_per_role + ON onboarding_configs(role_id) WHERE is_active = true; + +CREATE TABLE IF NOT EXISTS onboarding_states ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'NOT_STARTED', + progress_json JSONB NOT NULL DEFAULT '{}', + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_onboarding_state_user_role + ON onboarding_states(user_id, role_id); + CREATE TABLE IF NOT EXISTS onboarding_submissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -318,8 +917,8 @@ CREATE TABLE IF NOT EXISTS onboarding_submissions ( document_request TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE INDEX IF NOT EXISTS idx_onboarding_submissions_user_id ON onboarding_submissions(user_id); --- submission_documents: uploaded files for onboarding CREATE TABLE IF NOT EXISTS submission_documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), submission_id UUID NOT NULL REFERENCES onboarding_submissions(id) ON DELETE CASCADE, @@ -329,843 +928,41 @@ CREATE TABLE IF NOT EXISTS submission_documents ( uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); -CREATE INDEX IF NOT EXISTS idx_user_roles_status ON user_roles(status); -CREATE INDEX IF NOT EXISTS idx_onboarding_submissions_user_id ON onboarding_submissions(user_id); -CREATE INDEX IF NOT EXISTS idx_onboarding_submissions_status ON onboarding_submissions(status); --- Complete company profile (replacing the minimal stub) -ALTER TABLE company_profiles - ADD COLUMN IF NOT EXISTS business_type VARCHAR(100), - ADD COLUMN IF NOT EXISTS gst_number VARCHAR(50), - ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255), - ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(20), - ADD COLUMN IF NOT EXISTS address_line1 VARCHAR(500), - ADD COLUMN IF NOT EXISTS city VARCHAR(100), - ADD COLUMN IF NOT EXISTS state VARCHAR(100), - ADD COLUMN IF NOT EXISTS country VARCHAR(100) NOT NULL DEFAULT 'India', - ADD COLUMN IF NOT EXISTS postal_code VARCHAR(20), - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', - ADD COLUMN IF NOT EXISTS free_job_slots INTEGER NOT NULL DEFAULT 1, - ADD COLUMN IF NOT EXISTS purchased_job_slots INTEGER NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS free_contact_views INTEGER NOT NULL DEFAULT 30, - ADD COLUMN IF NOT EXISTS purchased_contact_views INTEGER NOT NULL DEFAULT 0; +-- ============================================================================ +-- 17. INTERNAL EMPLOYEES +-- ============================================================================ --- Jobs -CREATE TABLE IF NOT EXISTS jobs ( +CREATE TABLE IF NOT EXISTS departments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - company_id UUID NOT NULL REFERENCES company_profiles(id) ON DELETE CASCADE, - title VARCHAR(200) NOT NULL, - category VARCHAR(100), - description TEXT NOT NULL, - location VARCHAR(255) NOT NULL, - job_type VARCHAR(50) NOT NULL DEFAULT 'FULL_TIME', -- FULL_TIME, PART_TIME, CONTRACT - salary_min INTEGER, -- in paise - salary_max INTEGER, -- in paise - experience_years INTEGER, - skills TEXT[] DEFAULT '{}', - status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', - -- DRAFT, PENDING_APPROVAL, LIVE, EXPIRED, CLOSED, REJECTED - rejection_reason TEXT, - expires_at TIMESTAMPTZ, - approved_at TIMESTAMPTZ, - approved_by UUID REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Applications (Job Seeker → Job) -CREATE TABLE IF NOT EXISTS applications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, - job_seeker_id UUID NOT NULL REFERENCES job_seeker_profiles(id) ON DELETE CASCADE, - cover_letter TEXT, - resume_url VARCHAR(500), - status VARCHAR(50) NOT NULL DEFAULT 'APPLIED', - -- APPLIED, SHORTLISTED, INTERVIEW, OFFERED, HIRED, REJECTED, WITHDRAWN - applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - contact_viewed BOOLEAN NOT NULL DEFAULT false, - UNIQUE(job_id, job_seeker_id) -); - -CREATE INDEX IF NOT EXISTS idx_jobs_company_id ON jobs(company_id); -CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); -CREATE INDEX IF NOT EXISTS idx_applications_job_id ON applications(job_id); -CREATE INDEX IF NOT EXISTS idx_applications_job_seeker_id ON applications(job_seeker_id); -CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status); --- Add missing fields to job_seeker_profiles -ALTER TABLE job_seeker_profiles - ADD COLUMN IF NOT EXISTS full_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS summary TEXT, - ADD COLUMN IF NOT EXISTS experience_years INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS skills TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS resume_url VARCHAR(500), - ADD COLUMN IF NOT EXISTS active_application_count INTEGER NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE'; - --- Requirements (customer leads) -CREATE TABLE IF NOT EXISTS requirements ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - customer_id UUID NOT NULL REFERENCES customer_profiles(id) ON DELETE CASCADE, - profession_key VARCHAR(50) NOT NULL, - title VARCHAR(200) NOT NULL, - description TEXT NOT NULL, - location VARCHAR(255) NOT NULL, - budget INTEGER, -- in paise - preferred_date DATE, - extra_data_json JSONB, - status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', - -- DRAFT, PENDING_APPROVAL, OPEN, CLOSED, EXPIRED, REJECTED - rejection_reason TEXT, - request_count INTEGER NOT NULL DEFAULT 0, - accepted_count INTEGER NOT NULL DEFAULT 0, - expires_at TIMESTAMPTZ, - approved_at TIMESTAMPTZ, - approved_by UUID REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- professionals unified table (parent for all 9 profession subtypes) -CREATE TABLE IF NOT EXISTS professionals ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - profession_key VARCHAR(50) NOT NULL, - display_name VARCHAR(255) NOT NULL, - location VARCHAR(255), - bio TEXT, - extra_data_json JSONB, - status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Lead requests (professional → requirement) -CREATE TABLE IF NOT EXISTS lead_requests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - requirement_id UUID NOT NULL REFERENCES requirements(id) ON DELETE CASCADE, - professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - -- PENDING, ACCEPTED, REJECTED, EXPIRED, CANCELLED - tracecoins_reserved INTEGER NOT NULL DEFAULT 25, - expires_at TIMESTAMPTZ NOT NULL, - requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - resolved_at TIMESTAMPTZ, - UNIQUE(requirement_id, professional_id) -); - --- Add missing fields to customer_profiles -ALTER TABLE customer_profiles - ADD COLUMN IF NOT EXISTS full_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS phone VARCHAR(20), - ADD COLUMN IF NOT EXISTS city VARCHAR(100), - ADD COLUMN IF NOT EXISTS area VARCHAR(100), - ADD COLUMN IF NOT EXISTS preferred_professions TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS active_requirement_count INTEGER NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE'; - -CREATE INDEX IF NOT EXISTS idx_requirements_customer_id ON requirements(customer_id); -CREATE INDEX IF NOT EXISTS idx_requirements_status ON requirements(status); -CREATE INDEX IF NOT EXISTS idx_requirements_profession_key ON requirements(profession_key); -CREATE INDEX IF NOT EXISTS idx_lead_requests_requirement_id ON lead_requests(requirement_id); -CREATE INDEX IF NOT EXISTS idx_lead_requests_professional_id ON lead_requests(professional_id); -CREATE INDEX IF NOT EXISTS idx_lead_requests_status ON lead_requests(status); -CREATE INDEX IF NOT EXISTS idx_professionals_profession_key ON professionals(profession_key); --- Portfolio items (for professionals) -CREATE TABLE IF NOT EXISTS portfolio_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE, - title VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL UNIQUE, + code VARCHAR(64), description TEXT, - tags TEXT[] DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Portfolio images (multiple images per portfolio item) -CREATE TABLE IF NOT EXISTS portfolio_images ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - portfolio_item_id UUID NOT NULL REFERENCES portfolio_items(id) ON DELETE CASCADE, - file_url VARCHAR(500) NOT NULL, - display_order INTEGER NOT NULL DEFAULT 0 -); - --- Services (offered by professionals) -CREATE TABLE IF NOT EXISTS services ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - description TEXT, - price INTEGER NOT NULL DEFAULT 0, -- in paise - duration_minutes INTEGER, + department_head VARCHAR(255), + department_email VARCHAR(255), is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); - --- Tracecoin wallets (one per user) -CREATE TABLE IF NOT EXISTS tracecoin_wallets ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - balance INTEGER NOT NULL DEFAULT 0, - reserved INTEGER NOT NULL DEFAULT 0, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Tracecoin ledger (IMMUTABLE — never update or delete) -CREATE TABLE IF NOT EXISTS tracecoin_ledger ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - wallet_id UUID NOT NULL REFERENCES tracecoin_wallets(id), - type VARCHAR(20) NOT NULL, -- CREDIT, DEBIT, RESERVE, RELEASE - amount INTEGER NOT NULL, - reason VARCHAR(100) NOT NULL, -- LEAD_REQUEST, LEAD_ACCEPTED, PURCHASE, ADMIN_CREDIT, LEAD_EXPIRED - reference_id UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Pricing packages (Tracecoin bundles, job slots, contact views) -CREATE TABLE IF NOT EXISTS pricing_packages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - role_key VARCHAR(50) NOT NULL, - package_type VARCHAR(50) NOT NULL, -- JOB_POSTING, CONTACT_VIEWS, TRACECOIN_BUNDLE - tracecoins_amount INTEGER NOT NULL DEFAULT 0, - price_inr INTEGER NOT NULL, -- in paise - description TEXT, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Payments (Razorpay transactions) -CREATE TABLE IF NOT EXISTS payments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - package_id UUID NOT NULL REFERENCES pricing_packages(id), - razorpay_order_id VARCHAR(100), - razorpay_payment_id VARCHAR(100), - amount_inr INTEGER NOT NULL, - tracecoins_credited INTEGER NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, SUCCESS, FAILED - verified_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Invoices (generated for every successful payment) -CREATE TABLE IF NOT EXISTS invoices ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - payment_id UUID NOT NULL REFERENCES payments(id), - user_id UUID NOT NULL REFERENCES users(id), - invoice_number VARCHAR(50) NOT NULL UNIQUE, - subtotal INTEGER NOT NULL, -- in paise - gst_amount INTEGER NOT NULL, -- in paise - total INTEGER NOT NULL, -- in paise - status VARCHAR(20) NOT NULL DEFAULT 'ISSUED', -- ISSUED, PAID - issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - file_url VARCHAR(500) -); - -CREATE INDEX IF NOT EXISTS idx_portfolio_items_professional_id ON portfolio_items(professional_id); -CREATE INDEX IF NOT EXISTS idx_services_professional_id ON services(professional_id); -CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_wallet_id ON tracecoin_ledger(wallet_id); -CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id); -CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id); --- Notifications (in-app) -CREATE TABLE IF NOT EXISTS notifications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title VARCHAR(255) NOT NULL, - body TEXT, - type VARCHAR(50), -- APPROVAL, LEAD, JOB, PAYMENT - reference_id UUID, - is_read BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Email logs (audit trail) -CREATE TABLE IF NOT EXISTS email_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id), - trigger VARCHAR(100) NOT NULL, -- PROFILE_APPROVED, JOB_APPROVED, etc. - to_email VARCHAR(255) NOT NULL, - subject VARCHAR(500), - status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, SENT, FAILED - sent_at TIMESTAMPTZ -); - -CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); -CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read); -CREATE INDEX IF NOT EXISTS idx_email_logs_user_id ON email_logs(user_id); --- Drop the generic professionals table approach; use per-profession profile tables --- Portfolio and services stay shared (referenced by user_id + profession_key) - --- 1. PHOTOGRAPHER PROFILES -CREATE TABLE IF NOT EXISTS photographer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - specialties TEXT[] DEFAULT '{}', -- e.g. ['Wedding', 'Portrait', 'Commercial'] - camera_brands TEXT[] DEFAULT '{}', -- e.g. ['Sony', 'Canon'] - studio_available BOOLEAN NOT NULL DEFAULT false, - outdoor_shoots BOOLEAN NOT NULL DEFAULT true, - travel_radius_km INTEGER DEFAULT 50, - starting_price_inr INTEGER DEFAULT 0, -- in paise - -- Verification & status - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, SUSPENDED - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 2. TUTOR PROFILES -CREATE TABLE IF NOT EXISTS tutor_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - subjects TEXT[] DEFAULT '{}', -- e.g. ['Math', 'Physics', 'Hindi'] - board_types TEXT[] DEFAULT '{}', -- e.g. ['CBSE', 'ICSE', 'IB'] - qualification VARCHAR(255), -- e.g. 'B.Tech IIT Delhi' - teaches_online BOOLEAN NOT NULL DEFAULT true, - teaches_offline BOOLEAN NOT NULL DEFAULT true, - experience_years INTEGER DEFAULT 0, - hourly_rate_inr INTEGER DEFAULT 0, -- in paise - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 3. MAKEUP ARTIST PROFILES -CREATE TABLE IF NOT EXISTS makeup_artist_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - specializations TEXT[] DEFAULT '{}', -- e.g. ['Bridal', 'Editorial', 'SFX'] - kit_brands TEXT[] DEFAULT '{}', -- e.g. ['MAC', 'NARS', 'NYX'] - home_service BOOLEAN NOT NULL DEFAULT true, - studio_available BOOLEAN NOT NULL DEFAULT false, - starting_price_inr INTEGER DEFAULT 0, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 4. DEVELOPER PROFILES -CREATE TABLE IF NOT EXISTS developer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - tech_stack TEXT[] DEFAULT '{}', -- e.g. ['Rust', 'React', 'PostgreSQL'] - github_url VARCHAR(500), - portfolio_url VARCHAR(500), - experience_years INTEGER DEFAULT 0, - availability VARCHAR(50) DEFAULT 'FULL_TIME', -- FULL_TIME, PART_TIME, FREELANCE - hourly_rate_inr INTEGER DEFAULT 0, - remote_ok BOOLEAN NOT NULL DEFAULT true, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 5. VIDEO EDITOR PROFILES -CREATE TABLE IF NOT EXISTS video_editor_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - software_skills TEXT[] DEFAULT '{}', -- e.g. ['Premiere Pro', 'DaVinci Resolve'] - style_tags TEXT[] DEFAULT '{}', -- e.g. ['Cinematic', 'Corporate', 'Reels'] - turnaround_days INTEGER DEFAULT 7, - reel_url VARCHAR(500), - starting_price_inr INTEGER DEFAULT 0, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 6. GRAPHIC DESIGNER PROFILES -CREATE TABLE IF NOT EXISTS graphic_designer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - design_tools TEXT[] DEFAULT '{}', -- e.g. ['Figma', 'Illustrator', 'Photoshop'] - style_tags TEXT[] DEFAULT '{}', -- e.g. ['Minimalist', 'Bold', 'Corporate'] - brand_experience BOOLEAN NOT NULL DEFAULT false, - portfolio_url VARCHAR(500), - starting_price_inr INTEGER DEFAULT 0, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 7. SOCIAL MEDIA MANAGER PROFILES -CREATE TABLE IF NOT EXISTS social_media_manager_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - platforms TEXT[] DEFAULT '{}', -- e.g. ['Instagram', 'LinkedIn', 'YouTube'] - industries TEXT[] DEFAULT '{}', -- e.g. ['F&B', 'Fashion', 'Real Estate'] - content_types TEXT[] DEFAULT '{}', -- e.g. ['Reels', 'Carousels', 'Stories'] - avg_follower_growth_pct INTEGER DEFAULT 0, - starting_price_inr INTEGER DEFAULT 0, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 8. FITNESS TRAINER PROFILES -CREATE TABLE IF NOT EXISTS fitness_trainer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - disciplines TEXT[] DEFAULT '{}', -- e.g. ['Yoga', 'HIIT', 'Zumba', 'CrossFit'] - certifications TEXT[] DEFAULT '{}', -- e.g. ['ACE', 'NASM', 'Yoga Alliance RYT'] - online_sessions BOOLEAN NOT NULL DEFAULT true, - home_visits BOOLEAN NOT NULL DEFAULT false, - gym_based BOOLEAN NOT NULL DEFAULT false, - per_session_rate_inr INTEGER DEFAULT 0, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 9. CATERING SERVICES PROFILES -CREATE TABLE IF NOT EXISTS catering_service_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - business_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - cuisine_types TEXT[] DEFAULT '{}', -- e.g. ['North Indian', 'Continental', 'Vegan'] - event_types TEXT[] DEFAULT '{}', -- e.g. ['Wedding', 'Corporate', 'Birthday'] - min_guests INTEGER DEFAULT 10, - max_guests INTEGER DEFAULT 500, - has_setup_team BOOLEAN NOT NULL DEFAULT true, - has_serving_staff BOOLEAN NOT NULL DEFAULT true, - price_per_head_inr INTEGER DEFAULT 0, -- in paise - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Shared: portfolio_items now uses user_id + profession_key (no foreign key to professionals) --- Drop the professionals-table FK if it was added before -ALTER TABLE portfolio_items - ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE, - ADD COLUMN IF NOT EXISTS profession_key VARCHAR(50); - -ALTER TABLE services - ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE, - ADD COLUMN IF NOT EXISTS profession_key VARCHAR(50); - --- Lead requests: use user_id instead of professional_id foreign key -ALTER TABLE lead_requests - ADD COLUMN IF NOT EXISTS professional_user_id UUID REFERENCES users(id) ON DELETE CASCADE; - --- Backfill columns when legacy minimal profile tables already exist. --- This keeps migrations idempotent while upgrading old schemas to the new profile shape. -ALTER TABLE photographer_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS bio TEXT, - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS specialties TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS camera_brands TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS studio_available BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS outdoor_shoots BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS travel_radius_km INTEGER DEFAULT 50, - ADD COLUMN IF NOT EXISTS starting_price_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE tutor_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS bio TEXT, - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS subjects TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS board_types TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS qualification VARCHAR(255), - ADD COLUMN IF NOT EXISTS teaches_online BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS teaches_offline BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS hourly_rate_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE makeup_artist_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS specializations TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS kit_brands TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS home_service BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS studio_available BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS starting_price_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE developer_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS tech_stack TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS github_url VARCHAR(500), - ADD COLUMN IF NOT EXISTS portfolio_url VARCHAR(500), - ADD COLUMN IF NOT EXISTS availability VARCHAR(50) DEFAULT 'FULL_TIME', - ADD COLUMN IF NOT EXISTS hourly_rate_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS remote_ok BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE video_editor_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS software_skills TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS style_tags TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS turnaround_days INTEGER DEFAULT 7, - ADD COLUMN IF NOT EXISTS reel_url VARCHAR(500), - ADD COLUMN IF NOT EXISTS starting_price_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE graphic_designer_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS design_tools TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS style_tags TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS brand_experience BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS portfolio_url VARCHAR(500), - ADD COLUMN IF NOT EXISTS starting_price_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE social_media_manager_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS platforms TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS industries TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS content_types TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS avg_follower_growth_pct INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS starting_price_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE fitness_trainer_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS disciplines TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS certifications TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS online_sessions BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS home_visits BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS gym_based BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS per_session_rate_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE catering_service_profiles - ADD COLUMN IF NOT EXISTS business_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS cuisine_types TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS event_types TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS min_guests INTEGER DEFAULT 10, - ADD COLUMN IF NOT EXISTS max_guests INTEGER DEFAULT 500, - ADD COLUMN IF NOT EXISTS has_setup_team BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS has_serving_staff BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS price_per_head_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE lead_requests - ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); --- Indexes -CREATE INDEX IF NOT EXISTS idx_photographer_profiles_status ON photographer_profiles(status); -CREATE INDEX IF NOT EXISTS idx_tutor_profiles_status ON tutor_profiles(status); -CREATE INDEX IF NOT EXISTS idx_makeup_artist_profiles_status ON makeup_artist_profiles(status); -CREATE INDEX IF NOT EXISTS idx_developer_profiles_status ON developer_profiles(status); -CREATE INDEX IF NOT EXISTS idx_video_editor_profiles_status ON video_editor_profiles(status); -CREATE INDEX IF NOT EXISTS idx_graphic_designer_profiles_status ON graphic_designer_profiles(status); -CREATE INDEX IF NOT EXISTS idx_social_media_manager_profiles_status ON social_media_manager_profiles(status); -CREATE INDEX IF NOT EXISTS idx_fitness_trainer_profiles_status ON fitness_trainer_profiles(status); -CREATE INDEX IF NOT EXISTS idx_catering_service_profiles_status ON catering_service_profiles(status); -CREATE INDEX IF NOT EXISTS idx_portfolio_items_user_id ON portfolio_items(user_id); -CREATE INDEX IF NOT EXISTS idx_services_user_id ON services(user_id); --- Add email verification and password reset columns to users table -ALTER TABLE users - ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(255), - ADD COLUMN IF NOT EXISTS email_verification_expires_at TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS reset_password_token VARCHAR(255), - ADD COLUMN IF NOT EXISTS reset_password_expires_at TIMESTAMPTZ; - --- Add index for token lookups -CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token); -CREATE INDEX IF NOT EXISTS idx_users_reset_password_token ON users(reset_password_token); --- Reviews: customers leave reviews on professionals after an accepted lead -CREATE TABLE IF NOT EXISTS reviews ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - lead_request_id UUID NOT NULL REFERENCES lead_requests(id) ON DELETE CASCADE UNIQUE, - customer_id UUID NOT NULL REFERENCES customer_profiles(id) ON DELETE CASCADE, - professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE, - rating SMALLINT NOT NULL CHECK (rating >= 1 AND rating <= 5), - comment TEXT, - is_published BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_reviews_professional_id ON reviews(professional_id); -CREATE INDEX IF NOT EXISTS idx_reviews_customer_id ON reviews(customer_id); --- Knowledge Base categories -CREATE TABLE IF NOT EXISTS kb_categories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - slug VARCHAR(255) NOT NULL UNIQUE, - description TEXT, - display_order INTEGER NOT NULL DEFAULT 0, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Knowledge Base articles -CREATE TABLE IF NOT EXISTS kb_articles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - category_id UUID NOT NULL REFERENCES kb_categories(id) ON DELETE CASCADE, - title VARCHAR(500) NOT NULL, - slug VARCHAR(500) NOT NULL UNIQUE, - body TEXT NOT NULL, - target_roles TEXT[] DEFAULT '{}', -- empty = visible to all - is_published BOOLEAN NOT NULL DEFAULT false, - views INTEGER NOT NULL DEFAULT 0, - created_by UUID REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_kb_articles_category_id ON kb_articles(category_id); -CREATE INDEX IF NOT EXISTS idx_kb_articles_slug ON kb_articles(slug); --- Support tickets -CREATE TABLE IF NOT EXISTS support_tickets ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - subject VARCHAR(500) NOT NULL, - category VARCHAR(50) NOT NULL DEFAULT 'GENERAL', -- GENERAL, BILLING, ACCOUNT, LEAD, JOB - status VARCHAR(20) NOT NULL DEFAULT 'OPEN', -- OPEN, IN_PROGRESS, RESOLVED, CLOSED - priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL', -- LOW, NORMAL, HIGH, URGENT - assigned_to UUID REFERENCES users(id), - resolved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Support ticket messages -CREATE TABLE IF NOT EXISTS support_ticket_messages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE, - sender_id UUID NOT NULL REFERENCES users(id), - body TEXT NOT NULL, - is_internal BOOLEAN NOT NULL DEFAULT false, -- true = staff-only note - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_support_tickets_user_id ON support_tickets(user_id); -CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status); -CREATE INDEX IF NOT EXISTS idx_support_ticket_messages_ticket_id ON support_ticket_messages(ticket_id); --- Discount coupons for Tracecoin and package purchases -CREATE TABLE IF NOT EXISTS coupons ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(50) NOT NULL UNIQUE, - description TEXT, - discount_type VARCHAR(20) NOT NULL, -- PERCENT, FLAT - discount_value INTEGER NOT NULL, -- percent (0-100) or paise - applies_to VARCHAR(50) NOT NULL DEFAULT 'ALL', -- ALL, TRACECOIN_BUNDLE, JOB_POSTING, CONTACT_VIEWS - min_order_amount INTEGER NOT NULL DEFAULT 0, -- paise - max_uses INTEGER, -- NULL = unlimited - uses_count INTEGER NOT NULL DEFAULT 0, - per_user_limit INTEGER NOT NULL DEFAULT 1, - valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), - valid_until TIMESTAMPTZ, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Track which users used which coupons -CREATE TABLE IF NOT EXISTS coupon_uses ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - payment_id UUID REFERENCES payments(id), - used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (coupon_id, user_id) -); - -CREATE INDEX IF NOT EXISTS idx_coupons_code ON coupons(code); -CREATE INDEX IF NOT EXISTS idx_coupon_uses_user_id ON coupon_uses(user_id); --- Onboarding state per user per role --- Tracks progress through the schema-driven onboarding form -CREATE TABLE IF NOT EXISTS onboarding_states ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - status VARCHAR(20) NOT NULL DEFAULT 'NOT_STARTED', -- NOT_STARTED | IN_PROGRESS | COMPLETED - progress_json JSONB NOT NULL DEFAULT '{}', - completed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- One onboarding state record per user per role -CREATE UNIQUE INDEX IF NOT EXISTS idx_onboarding_state_user_role - ON onboarding_states(user_id, role_id); --- Make display_name / business_name nullable so upserts can work --- without forcing the name on every call. --- Add custom_data JSONB to every profession table so all onboarding --- form fields are preserved even if they don't have a dedicated column. - -ALTER TABLE photographer_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE tutor_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE makeup_artist_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE developer_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE video_editor_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE graphic_designer_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE social_media_manager_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE fitness_trainer_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE catering_service_profiles ALTER COLUMN business_name DROP NOT NULL; - -ALTER TABLE photographer_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE tutor_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE makeup_artist_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE developer_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE video_editor_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE graphic_designer_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE social_media_manager_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE fitness_trainer_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE catering_service_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; --- Enforce immutable tracecoin ledger: no UPDATE/DELETE allowed. - -CREATE OR REPLACE FUNCTION prevent_tracecoin_ledger_mutation() -RETURNS trigger AS $$ -BEGIN - RAISE EXCEPTION 'tracecoin_ledger is immutable; % is not allowed', TG_OP; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS trg_prevent_tracecoin_ledger_update ON tracecoin_ledger; -CREATE TRIGGER trg_prevent_tracecoin_ledger_update -BEFORE UPDATE ON tracecoin_ledger -FOR EACH ROW -EXECUTE FUNCTION prevent_tracecoin_ledger_mutation(); - -DROP TRIGGER IF EXISTS trg_prevent_tracecoin_ledger_delete ON tracecoin_ledger; -CREATE TRIGGER trg_prevent_tracecoin_ledger_delete -BEFORE DELETE ON tracecoin_ledger -FOR EACH ROW -EXECUTE FUNCTION prevent_tracecoin_ledger_mutation(); -UPDATE company_profiles -SET status = 'APPROVED' -WHERE status = 'ACTIVE'; - -UPDATE customer_profiles -SET status = 'APPROVED' -WHERE status = 'ACTIVE'; --- Extend roles table for internal role management -ALTER TABLE roles - ADD COLUMN IF NOT EXISTS description TEXT, - ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - ADD COLUMN IF NOT EXISTS can_approve_requests BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS can_manage_system_settings BOOLEAN NOT NULL DEFAULT false; -ALTER TABLE departments - ADD COLUMN IF NOT EXISTS code VARCHAR(64), - ADD COLUMN IF NOT EXISTS description TEXT, - ADD COLUMN IF NOT EXISTS department_head VARCHAR(255), - ADD COLUMN IF NOT EXISTS department_email VARCHAR(255), - ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) NOT NULL DEFAULT 'INTERNAL', - ADD COLUMN IF NOT EXISTS transfers_enabled BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); - -UPDATE departments -SET updated_at = COALESCE(updated_at, created_at, NOW()); - CREATE UNIQUE INDEX IF NOT EXISTS idx_departments_code_unique - ON departments (LOWER(code)) - WHERE code IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_departments_is_active - ON departments (is_active); -ALTER TABLE designations - ADD COLUMN IF NOT EXISTS code VARCHAR(64), - ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - ADD COLUMN IF NOT EXISTS description TEXT, - ADD COLUMN IF NOT EXISTS level VARCHAR(100), - ADD COLUMN IF NOT EXISTS can_manage_team BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS can_approve BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); - -UPDATE designations -SET updated_at = COALESCE(updated_at, created_at, NOW()); + ON departments(LOWER(code)) WHERE code IS NOT NULL; +CREATE TABLE IF NOT EXISTS designations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + code VARCHAR(64), + department_id UUID REFERENCES departments(id) ON DELETE SET NULL, + description TEXT, + level VARCHAR(100), + can_manage_team BOOLEAN NOT NULL DEFAULT false, + can_approve BOOLEAN NOT NULL DEFAULT false, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); CREATE UNIQUE INDEX IF NOT EXISTS idx_designations_code_unique - ON designations (LOWER(code)) - WHERE code IS NOT NULL; + ON designations(LOWER(code)) WHERE code IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_designations_is_active - ON designations (is_active); - -CREATE INDEX IF NOT EXISTS idx_designations_department_id - ON designations (department_id); --- UP: 20260402030000_strict_employee_separation.up.sql - --- Drop old employees table (was linked to users — replacing with standalone auth) -DROP TABLE IF EXISTS employees CASCADE; - --- 1. EMPLOYEES (Standalone Table - Not Linked to 'users') -CREATE TABLE employees ( +CREATE TABLE IF NOT EXISTS employees ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, @@ -1176,12 +973,12 @@ CREATE TABLE employees ( designation_id UUID REFERENCES designations(id) ON DELETE SET NULL, role_code VARCHAR(50) NOT NULL DEFAULT 'STAFF', status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', - joined_at DATE NOT NULL DEFAULT CURRENT_DATE, + joining_date DATE DEFAULT CURRENT_DATE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE INDEX IF NOT EXISTS idx_employees_email ON employees(email); --- 2. EMPLOYEE SESSIONS (Standalone Auth) CREATE TABLE IF NOT EXISTS employee_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE, @@ -1191,131 +988,67 @@ CREATE TABLE IF NOT EXISTS employee_sessions ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- Indexes -CREATE INDEX IF NOT EXISTS idx_employees_email ON employees(email); -CREATE INDEX IF NOT EXISTS idx_employees_status ON employees(status); -CREATE INDEX IF NOT EXISTS idx_employee_sessions_token ON employee_sessions(token_hash); --- Up migration: Create activity_logs table +-- ============================================================================ +-- 18. AUDIT MANAGEMENT +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_user_id UUID NOT NULL, + actor_type VARCHAR(20) NOT NULL, + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + module_key VARCHAR(100), + request_id UUID, + ip_address VARCHAR(45), + user_agent TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'SUCCESS', + summary TEXT, + metadata_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_type, actor_user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC); + +CREATE TABLE IF NOT EXISTS audit_log_changes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + audit_log_id UUID NOT NULL REFERENCES audit_logs(id) ON DELETE CASCADE, + field_name VARCHAR(255) NOT NULL, + old_value TEXT, + new_value TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- activity_logs used by Rust code CREATE TABLE IF NOT EXISTS activity_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - actor_id UUID NOT NULL, -- User or Employee who performed the action - actor_type VARCHAR(20) NOT NULL, -- 'USER' or 'EMPLOYEE' - entity_id UUID NOT NULL, -- Target of the action (User ID, Job ID, etc.) - entity_type VARCHAR(50) NOT NULL, -- 'USER', 'JOB', 'REQUIREMENT', 'EMPLOYEE', etc. - action VARCHAR(100) NOT NULL, -- 'APPROVE', 'REJECT', 'STATUS_CHANGE', 'DELETE', etc. - metadata JSONB, -- Optional extra context: { "old_status": "PENDING", "new_status": "APPROVED", "reason": "..." } + actor_id UUID NOT NULL, + actor_type VARCHAR(20) NOT NULL, + entity_id UUID NOT NULL, + entity_type VARCHAR(50) NOT NULL, + action VARCHAR(100) NOT NULL, + metadata JSONB, ip_address VARCHAR(45), user_agent TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE INDEX IF NOT EXISTS idx_activity_logs_entity ON activity_logs(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_activity_logs_entity ON activity_logs (entity_type, entity_id); -CREATE INDEX IF NOT EXISTS idx_activity_logs_actor ON activity_logs (actor_type, actor_id); -CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs (created_at DESC); -ALTER TABLE kb_articles - ADD COLUMN IF NOT EXISTS summary TEXT, - ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}'; --- Allow admin-created tickets with no linked user -ALTER TABLE support_tickets - ALTER COLUMN user_id DROP NOT NULL; +-- ============================================================================ +-- 19. ACCOUNT DELETION +-- ============================================================================ --- Add description body and requester info for admin-created cases -ALTER TABLE support_tickets - ADD COLUMN IF NOT EXISTS description TEXT, - ADD COLUMN IF NOT EXISTS requester_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS requester_email VARCHAR(255); --- Extend reviews table to support admin-created reviews and admin moderation -ALTER TABLE reviews - ALTER COLUMN lead_request_id DROP NOT NULL, - ALTER COLUMN customer_id DROP NOT NULL, - ALTER COLUMN professional_id DROP NOT NULL, - ADD COLUMN IF NOT EXISTS title VARCHAR(255), - ADD COLUMN IF NOT EXISTS subject_type VARCHAR(50) NOT NULL DEFAULT 'PLATFORM', - ADD COLUMN IF NOT EXISTS subject_id VARCHAR(255), - ADD COLUMN IF NOT EXISTS reviewer_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PUBLISHED'; - --- Sync status with is_published for existing rows -UPDATE reviews SET status = CASE WHEN is_published THEN 'PUBLISHED' ELSE 'HIDDEN' END; --- Add title and role_keys to coupons for admin UI -ALTER TABLE coupons - ADD COLUMN IF NOT EXISTS title VARCHAR(255), - ADD COLUMN IF NOT EXISTS role_keys TEXT[] NOT NULL DEFAULT '{}'; - --- Backfill title from description -UPDATE coupons SET title = description WHERE title IS NULL AND description IS NOT NULL; --- Admin-managed automatic discounts (applied before coupon codes) -CREATE TABLE IF NOT EXISTS discounts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - title VARCHAR(255) NOT NULL, - scope VARCHAR(20) NOT NULL DEFAULT 'ROLE', -- ROLE, PACKAGE - role_key VARCHAR(50), - package_id UUID REFERENCES pricing_packages(id) ON DELETE SET NULL, - discount_type VARCHAR(20) NOT NULL, -- PERCENT, FIXED - discount_value INTEGER NOT NULL, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); --- 10. UGC CONTENT CREATOR PROFILES -CREATE TABLE IF NOT EXISTS ugc_content_creator_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL DEFAULT '', - bio TEXT, - location VARCHAR(255), - -- Profession-specific - platforms TEXT[] DEFAULT '{}', -- e.g. ['Instagram', 'YouTube', 'TikTok'] - content_niches TEXT[] DEFAULT '{}', -- e.g. ['Beauty', 'Tech', 'Food', 'Lifestyle'] - content_formats TEXT[] DEFAULT '{}', -- e.g. ['Reels', 'Unboxing', 'Reviews', 'GRWM'] - follower_count INTEGER DEFAULT 0, - avg_views_per_post INTEGER DEFAULT 0, - has_media_kit BOOLEAN NOT NULL DEFAULT false, - instagram_handle VARCHAR(100), - youtube_channel_url VARCHAR(500), - portfolio_url VARCHAR(500), - starting_price_inr INTEGER DEFAULT 0, -- in paise - custom_data JSONB, - -- Verification & status - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, SUSPENDED - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_ugc_content_creator_profiles_status ON ugc_content_creator_profiles(status); -CREATE INDEX IF NOT EXISTS idx_ugc_content_creator_profiles_user_id ON ugc_content_creator_profiles(user_id); --- 1. VERIFICATIONS TABLE -CREATE TABLE IF NOT EXISTS verifications ( +CREATE TABLE IF NOT EXISTS account_deletion_requests ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role_key VARCHAR(50) NOT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, UNDER_REVIEW, DOCUMENTS_REQUESTED, REVISION_REQUESTED, APPROVED, REJECTED - priority VARCHAR(10) NOT NULL DEFAULT 'LOW', -- HIGH, MEDIUM, LOW - case_type VARCHAR(50) NOT NULL, -- PROFILE, PORTFOLIO, JOB, REQUIREMENT - payload JSONB NOT NULL DEFAULT '{}', -- full submission data - documents JSONB NOT NULL DEFAULT '[]', -- list of documents [{id, title, url, status}] - notes TEXT, - rejection_reason TEXT, - assigned_to UUID REFERENCES users(id) ON DELETE SET NULL, -- Admin/Employee ID - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + reason TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ, + processed_by UUID REFERENCES users(id) ); --- 2. VERIFICATION LOGS (History of actions) -CREATE TABLE IF NOT EXISTS verification_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - verification_id UUID NOT NULL REFERENCES verifications(id) ON DELETE CASCADE, - action VARCHAR(50) NOT NULL, -- STATUS_CHANGE, NOTE_ADDED, DOCS_REQUESTED, REASSIGNED - actor_id UUID REFERENCES users(id) ON DELETE SET NULL, - old_status VARCHAR(50), - new_status VARCHAR(50), - message TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 3. INDEXES -CREATE INDEX IF NOT EXISTS idx_verifications_user_id ON verifications(user_id); -CREATE INDEX IF NOT EXISTS idx_verifications_status ON verifications(status); -CREATE INDEX IF NOT EXISTS idx_verifications_case_type ON verifications(case_type); -CREATE INDEX IF NOT EXISTS idx_verification_logs_ver_id ON verification_logs(verification_id); +COMMIT; diff --git a/scripts/seed.sql b/scripts/seed.sql index 6496012..3e6727e 100644 --- a/scripts/seed.sql +++ b/scripts/seed.sql @@ -4,10 +4,26 @@ -- ── 1. Roles ───────────────────────────────────────────────────────────────── +-- Internal roles INSERT INTO roles (key, name, audience) VALUES ('SUPER_ADMIN', 'Super Admin', 'INTERNAL'), ('ADMIN', 'Admin', 'INTERNAL'), - ('SUPPORT', 'Support Agent', 'INTERNAL'), + ('SUPPORT', 'Support Agent', 'INTERNAL') +ON CONFLICT (key) DO NOTHING; + +-- Internal role extensions +INSERT INTO internal_roles (role_id, description) + SELECT id, 'Full system administrator with all permissions' FROM roles WHERE key = 'SUPER_ADMIN' + ON CONFLICT (role_id) DO NOTHING; +INSERT INTO internal_roles (role_id, description, can_approve_requests, can_manage_system_settings) + SELECT id, 'Standard administrator', true, true FROM roles WHERE key = 'ADMIN' + ON CONFLICT (role_id) DO NOTHING; +INSERT INTO internal_roles (role_id, description) + SELECT id, 'Customer support agent' FROM roles WHERE key = 'SUPPORT' + ON CONFLICT (role_id) DO NOTHING; + +-- External roles +INSERT INTO roles (key, name, audience) VALUES ('COMPANY', 'Company', 'EXTERNAL'), ('JOB_SEEKER', 'Job Seeker', 'EXTERNAL'), ('CUSTOMER', 'Customer', 'EXTERNAL'), @@ -22,6 +38,11 @@ INSERT INTO roles (key, name, audience) VALUES ('CATERING_SERVICES', 'Catering Services', 'EXTERNAL') ON CONFLICT (key) DO NOTHING; +-- External role extensions +INSERT INTO external_roles (role_id) + SELECT id FROM roles WHERE audience = 'EXTERNAL' +ON CONFLICT (role_id) DO NOTHING; + -- ── 2. Super Admin User ────────────────────────────────────────────────────── -- Default password: Admin@nxtgauge1 (bcrypt hash) -- CHANGE THIS PASSWORD IMMEDIATELY AFTER FIRST LOGIN @@ -33,13 +54,14 @@ DECLARE BEGIN SELECT id INTO super_admin_role_id FROM roles WHERE key = 'SUPER_ADMIN'; - INSERT INTO users (email, password_hash, status, role_id, full_name, email_verified) + INSERT INTO users (email, password_hash, status, role_id, first_name, last_name, email_verified) VALUES ( 'admin@nxtgauge.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TiGniB9GSmJBGp0K7RqUi/4hY/Ii', 'ACTIVE', super_admin_role_id, - 'Super Admin', + 'Super', + 'Admin', true ) ON CONFLICT (email) DO NOTHING diff --git a/users.pid b/users.pid index cdd146d..b07474d 100644 --- a/users.pid +++ b/users.pid @@ -1 +1 @@ -96200 +9691