From 3935277fb7621279654e01bfbdd6730988bc5e19 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Fri, 3 Apr 2026 02:25:47 +0200 Subject: [PATCH] Fix backend compile errors after schema migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - employees.rs: rewrite for new standalone schema (email/password_hash, no user_id/role_id FK — matches 20260402030000 migration) - migration: DROP old employees table before CREATE (old schema incompatible) - pricing.rs: merge if-else sqlx::query! branches into single nullable param query - kb.rs: fix target_roles Option> unwrap, category_id Some() wrapping - support.rs: fix .or() call with non-optional user_email (use Some()) - roles.rs: fix employees JOIN from role_id (deleted) to role_code Co-Authored-By: Claude Sonnet 4.6 --- apps/users/src/handlers/employees.rs | 327 +++++++----------- apps/users/src/handlers/kb.rs | 20 +- apps/users/src/handlers/pricing.rs | 36 +- apps/users/src/handlers/roles.rs | 2 +- apps/users/src/handlers/support.rs | 4 +- ...02030000_strict_employee_separation.up.sql | 11 +- 6 files changed, 163 insertions(+), 237 deletions(-) diff --git a/apps/users/src/handlers/employees.rs b/apps/users/src/handlers/employees.rs index 27c5eeb..563abf0 100644 --- a/apps/users/src/handlers/employees.rs +++ b/apps/users/src/handlers/employees.rs @@ -9,11 +9,10 @@ use axum::{ use contracts::auth_middleware::{AuthUser, require_admin}; use serde::{Deserialize, Serialize}; use sqlx::types::Uuid; -use sqlx::Row; +use auth::crypto::hash_password; pub fn router() -> Router { Router::new() - .route("/provision", axum::routing::post(provision_employee)) .route("/", get(list_employees).post(create_employee)) .route("/{id}", get(get_employee).patch(update_employee).delete(delete_employee)) } @@ -21,27 +20,24 @@ pub fn router() -> Router { #[derive(Deserialize)] struct ListQuery { q: Option, - status: Option, // ACTIVE | INACTIVE | SUSPENDED + status: Option, page: Option, per_page: Option, - sort: Option, // name_asc | name_desc | joined_asc | joined_desc } #[derive(Serialize)] struct EmployeeRow { id: Uuid, - user_id: Uuid, - name: String, + first_name: String, + last_name: String, email: String, - phone: Option, - employee_id: Option, + employee_code: Option, department_name: Option, designation_name: Option, - role_name: Option, - role_key: Option, - joining_date: String, + role_code: String, status: String, - updated_at: Option>, + joined_at: String, + created_at: String, } #[derive(Serialize)] @@ -65,82 +61,65 @@ async fn list_employees( let offset = (page - 1) * per_page; let search = q.q.unwrap_or_default().to_lowercase(); let status = q.status.unwrap_or_default().to_uppercase(); - let sort_sql = match q.sort.as_deref() { - Some("name_asc") => "u.full_name ASC", - Some("name_desc") => "u.full_name DESC", - Some("joined_asc") => "e.created_at ASC", - _ => "e.created_at DESC", - }; - let sql = format!(r#" + + let rows = sqlx::query!( + r#" SELECT e.id, - e.user_id, + e.first_name, + e.last_name, + e.email, e.employee_code, - e.created_at AS joining_date, - u.full_name, - u.email, - u.phone, - u.status AS user_status, - r.name AS role_name, - r.key AS role_key, - d.name AS department_name, - des.name AS designation_name, - e.created_at AS updated_at + e.role_code, + e.status, + e.joined_at, + e.created_at, + d.name AS "department_name?", + des.name AS "designation_name?" FROM employees e - JOIN users u ON u.id = e.user_id - LEFT JOIN roles r ON r.id = e.role_id LEFT JOIN departments d ON d.id = e.department_id LEFT JOIN designations des ON des.id = e.designation_id - WHERE ($1 = '' OR LOWER(u.full_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%' OR LOWER(COALESCE(e.employee_code,'')) LIKE '%' || $1 || '%') - AND ( - $2 = '' - OR ($2 = 'ACTIVE' AND u.status = 'ACTIVE') - OR ($2 = 'INACTIVE' AND u.status = 'INACTIVE') - OR ($2 = 'SUSPENDED' AND u.status = 'SUSPENDED') - ) - ORDER BY {sort} + WHERE ($1 = '' OR LOWER(e.first_name || ' ' || e.last_name) LIKE '%' || $1 || '%' + OR LOWER(e.email) LIKE '%' || $1 || '%' + OR LOWER(COALESCE(e.employee_code,'')) LIKE '%' || $1 || '%') + AND ($2 = '' OR e.status = $2) + ORDER BY e.created_at DESC LIMIT $3 OFFSET $4 - "#, sort = sort_sql); - let rows = sqlx::query(&sql) - .bind(&search) - .bind(&status) - .bind(per_page) - .bind(offset) - .fetch_all(&state.pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + "#, + search, + status, + per_page, + offset + ) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; - let employees = rows.into_iter().map(|row| { - let joining: chrono::DateTime = row.get::, _>("joining_date"); - EmployeeRow { - id: row.get("id"), - user_id: row.get("user_id"), - name: row.get::, _>("full_name").unwrap_or_default(), - email: row.get("email"), - phone: row.get::, _>("phone"), - employee_id: row.get::, _>("employee_code"), - department_name: row.get::, _>("department_name"), - designation_name: row.get::, _>("designation_name"), - role_name: row.get::, _>("role_name"), - role_key: row.get::, _>("role_key"), - joining_date: joining.to_rfc3339(), - status: row.get::, _>("user_status").unwrap_or_else(|| "ACTIVE".to_string()), - updated_at: Some(row.get::, _>("updated_at")), - } - }).collect::>(); + let employees = rows + .into_iter() + .map(|r| EmployeeRow { + id: r.id, + first_name: r.first_name, + last_name: r.last_name, + email: r.email, + employee_code: r.employee_code, + department_name: r.department_name, + designation_name: r.designation_name, + role_code: r.role_code, + status: r.status, + joined_at: r.joined_at.to_string(), + created_at: r.created_at.to_rfc3339(), + }) + .collect::>(); let total: i64 = sqlx::query_scalar!( r#" SELECT COUNT(*) FROM employees e - JOIN users u ON u.id = e.user_id - WHERE ($1 = '' OR LOWER(u.full_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%' OR LOWER(COALESCE(e.employee_code,'')) LIKE '%' || $1 || '%') - AND ( - $2 = '' - OR ($2 = 'ACTIVE' AND u.status = 'ACTIVE') - OR ($2 = 'INACTIVE' AND u.status = 'INACTIVE') - OR ($2 = 'SUSPENDED' AND u.status = 'SUSPENDED') - ) + WHERE ($1 = '' OR LOWER(e.first_name || ' ' || e.last_name) LIKE '%' || $1 || '%' + OR LOWER(e.email) LIKE '%' || $1 || '%' + OR LOWER(COALESCE(e.employee_code,'')) LIKE '%' || $1 || '%') + AND ($2 = '' OR e.status = $2) "#, search, status @@ -161,52 +140,50 @@ async fn get_employee( if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } - let sql = r#" + let r = sqlx::query!( + r#" SELECT - e.id, e.user_id, e.employee_code, - e.created_at AS joining_date, e.created_at AS updated_at, - u.full_name, u.email, u.phone, u.status AS user_status, - r.name AS role_name, r.key AS role_key, - d.name AS department_name, - des.name AS designation_name + e.id, e.first_name, e.last_name, e.email, e.employee_code, + e.role_code, e.status, e.joined_at, e.created_at, + d.name AS "department_name?", + des.name AS "designation_name?" FROM employees e - JOIN users u ON u.id = e.user_id - LEFT JOIN roles r ON r.id = e.role_id LEFT JOIN departments d ON d.id = e.department_id LEFT JOIN designations des ON des.id = e.designation_id WHERE e.id = $1 - "#; - let row = sqlx::query(sql) - .bind(id) - .fetch_optional(&state.pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? - .ok_or((StatusCode::NOT_FOUND, "Employee not found".to_string()))?; - let joining: chrono::DateTime = row.get::, _>("joining_date"); + "#, + id + ) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + .ok_or((StatusCode::NOT_FOUND, "Employee not found".to_string()))?; + Ok(Json(EmployeeRow { - id: row.get("id"), - user_id: row.get("user_id"), - name: row.get::, _>("full_name").unwrap_or_default(), - email: row.get("email"), - phone: row.get::, _>("phone"), - employee_id: row.get::, _>("employee_code"), - department_name: row.get::, _>("department_name"), - designation_name: row.get::, _>("designation_name"), - role_name: row.get::, _>("role_name"), - role_key: row.get::, _>("role_key"), - joining_date: joining.to_rfc3339(), - status: row.get::, _>("user_status").unwrap_or_else(|| "ACTIVE".to_string()), - updated_at: Some(row.get::, _>("updated_at")), + id: r.id, + first_name: r.first_name, + last_name: r.last_name, + email: r.email, + employee_code: r.employee_code, + department_name: r.department_name, + designation_name: r.designation_name, + role_code: r.role_code, + status: r.status, + joined_at: r.joined_at.to_string(), + created_at: r.created_at.to_rfc3339(), })) } #[derive(Deserialize)] struct CreateEmployeePayload { - user_id: Uuid, - role_id: Uuid, + first_name: String, + last_name: String, + email: String, + password: String, + employee_code: Option, department_id: Option, designation_id: Option, - employee_code: Option, + role_code: Option, } async fn create_employee( @@ -217,103 +194,49 @@ async fn create_employee( if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } - let row = sqlx::query!( - r#" - INSERT INTO employees (user_id, role_id, department_id, designation_id, employee_code) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - role_id = EXCLUDED.role_id, - department_id = EXCLUDED.department_id, - designation_id = EXCLUDED.designation_id, - employee_code = EXCLUDED.employee_code - RETURNING id - "#, - p.user_id, p.role_id, p.department_id, p.designation_id, p.employee_code - ) - .fetch_one(&state.pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; - get_employee(auth, State(state), Path(row.id)).await -} - -#[derive(Deserialize)] -struct ProvisionEmployeePayload { - email: String, - full_name: String, - role_id: Uuid, - department_id: Option, - designation_id: Option, - employee_code: Option, - #[serde(default)] - generate_login: bool, - password: Option, -} - -async fn provision_employee( - auth: AuthUser, - State(state): State, - Json(p): Json, -) -> Result { - if let Err(_e) = require_admin(&auth) { - return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); - } - use db::models::user::{CreateUserPayload, UserRepository}; - use auth::crypto::hash_password; - + let password_hash = hash_password(&p.password) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Crypto error: {e}")))?; + let role_code = p.role_code.unwrap_or_else(|| "STAFF".to_string()); let email = p.email.trim().to_lowercase(); - - // 1. Resolve User - let user_id = match UserRepository::get_by_email(&state.pool, &email).await { - Ok(user) => user.id, - Err(_) => { - if !p.generate_login { - return Err((StatusCode::BAD_REQUEST, "User not found for this email".to_string())); - } - let plain_password = p.password.unwrap_or_else(|| format!("{:08}", rand::random::() % 100_000_000)); - if plain_password.len() < 8 { - return Err((StatusCode::BAD_REQUEST, "Password must be at least 8 characters".to_string())); - } - let password_hash = hash_password(&plain_password) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Crypto error: {e}")))?; - - let new_user = UserRepository::create(&state.pool, CreateUserPayload { - full_name: p.full_name.clone(), - email: email.clone(), - phone: None, - password_hash, - }).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; - - new_user.id - } - }; - // 2. Link Employee - let row = sqlx::query!( + let r = sqlx::query!( r#" - INSERT INTO employees (user_id, role_id, department_id, designation_id, employee_code) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - role_id = EXCLUDED.role_id, - department_id = EXCLUDED.department_id, - designation_id = EXCLUDED.designation_id, - employee_code = EXCLUDED.employee_code + INSERT INTO employees (first_name, last_name, email, password_hash, employee_code, + department_id, designation_id, role_code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id "#, - user_id, p.role_id, p.department_id, p.designation_id, p.employee_code + p.first_name, + p.last_name, + email, + password_hash, + p.employee_code, + p.department_id, + p.designation_id, + role_code, ) .fetch_one(&state.pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + .map_err(|e| { + if e.to_string().contains("unique") { + (StatusCode::CONFLICT, "Email already exists".to_string()) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + } + })?; - get_employee(auth, State(state), Path(row.id)).await + get_employee(auth, State(state), Path(r.id)).await } #[derive(Deserialize)] struct UpdateEmployeePayload { - role_id: Option, + first_name: Option, + last_name: Option, + employee_code: Option, department_id: Option, designation_id: Option, - employee_code: Option, + role_code: Option, + status: Option, } async fn update_employee( @@ -329,17 +252,29 @@ async fn update_employee( r#" UPDATE employees SET - role_id = COALESCE($1, role_id), - department_id = COALESCE($2, department_id), - designation_id = COALESCE($3, designation_id), - employee_code = COALESCE($4, employee_code) - WHERE id = $5 + first_name = COALESCE($1, first_name), + last_name = COALESCE($2, last_name), + employee_code = COALESCE($3, employee_code), + department_id = COALESCE($4, department_id), + designation_id = COALESCE($5, designation_id), + role_code = COALESCE($6, role_code), + status = COALESCE($7, status), + updated_at = NOW() + WHERE id = $8 "#, - p.role_id, p.department_id, p.designation_id, p.employee_code, id + p.first_name, + p.last_name, + p.employee_code, + p.department_id, + p.designation_id, + p.role_code, + p.status, + id, ) .execute(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + get_employee(auth, State(state), Path(id)).await } diff --git a/apps/users/src/handlers/kb.rs b/apps/users/src/handlers/kb.rs index 3b3d2db..196bb86 100644 --- a/apps/users/src/handlers/kb.rs +++ b/apps/users/src/handlers/kb.rs @@ -182,7 +182,7 @@ async fn public_list_articles( let articles: Vec<_> = rows .into_iter() .map(|r| { - let role = derive_role(&r.target_roles); + let role = derive_role(r.target_roles.as_deref().unwrap_or(&[])); PublicArticleDto { id: r.id, slug: r.slug, @@ -245,7 +245,7 @@ async fn public_get_article( match row { Ok(Some(r)) => { - let role = derive_role(&r.target_roles); + let role = derive_role(r.target_roles.as_deref().unwrap_or(&[])); let dto = PublicArticleDto { id: r.id, slug: r.slug, @@ -528,11 +528,11 @@ async fn admin_list_articles( title: r.title, slug: r.slug, summary: r.summary, - category_id: r.category_id, + category_id: Some(r.category_id), category: Some(r.category_name), content: r.body, status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() }, - target_roles: r.target_roles, + target_roles: r.target_roles.unwrap_or_default(), tags: r.tags, views: r.views, created_at: r.created_at.to_rfc3339(), @@ -606,11 +606,11 @@ async fn admin_create_article( title: r.title, slug: r.slug, summary: r.summary, - category_id: r.category_id, + category_id: Some(r.category_id), category: None, content: r.body, status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() }, - target_roles: r.target_roles, + target_roles: r.target_roles.unwrap_or_default(), tags: r.tags, views: r.views, created_at: r.created_at.to_rfc3339(), @@ -668,11 +668,11 @@ async fn admin_get_article( title: r.title, slug: r.slug, summary: r.summary, - category_id: r.category_id, + category_id: Some(r.category_id), category: Some(r.category_name), content: r.body, status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() }, - target_roles: r.target_roles, + target_roles: r.target_roles.unwrap_or_default(), tags: r.tags, views: r.views, created_at: r.created_at.to_rfc3339(), @@ -752,11 +752,11 @@ async fn admin_update_article( title: r.title, slug: r.slug, summary: r.summary, - category_id: r.category_id, + category_id: Some(r.category_id), category: None, content: r.body, status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() }, - target_roles: r.target_roles, + target_roles: r.target_roles.unwrap_or_default(), tags: r.tags, views: r.views, created_at: r.created_at.to_rfc3339(), diff --git a/apps/users/src/handlers/pricing.rs b/apps/users/src/handlers/pricing.rs index 5de8e7c..418c830 100644 --- a/apps/users/src/handlers/pricing.rs +++ b/apps/users/src/handlers/pricing.rs @@ -94,30 +94,18 @@ async fn public_list_packages( State(state): State, Query(params): Query, ) -> impl IntoResponse { - let rows = if let Some(role) = params.role { - sqlx::query!( - r#" - SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active - FROM pricing_packages - WHERE is_active = true AND (role_key = $1 OR role_key = 'ALL') - ORDER BY price_inr - "#, - role - ) - .fetch_all(&state.pool) - .await - } else { - sqlx::query!( - r#" - SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active - FROM pricing_packages - WHERE is_active = true - ORDER BY role_key, price_inr - "# - ) - .fetch_all(&state.pool) - .await - }; + let rows = sqlx::query!( + r#" + SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active + FROM pricing_packages + WHERE is_active = true + AND ($1::text IS NULL OR role_key = $1 OR role_key = 'ALL') + ORDER BY role_key, price_inr + "#, + params.role as Option + ) + .fetch_all(&state.pool) + .await; match rows { Ok(rows) => { diff --git a/apps/users/src/handlers/roles.rs b/apps/users/src/handlers/roles.rs index c15d156..d5b8c86 100644 --- a/apps/users/src/handlers/roles.rs +++ b/apps/users/src/handlers/roles.rs @@ -124,7 +124,7 @@ async fn list_roles( 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_id = r.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.key) LIKE '%' || $2 || '%') diff --git a/apps/users/src/handlers/support.rs b/apps/users/src/handlers/support.rs index 7efbdd8..3b6203d 100644 --- a/apps/users/src/handlers/support.rs +++ b/apps/users/src/handlers/support.rs @@ -412,7 +412,7 @@ async fn admin_list_cases( .map(|r| { // Use user info if available, fall back to requester fields let requester_name = r.requester_name.or(r.user_name); - let requester_email = r.requester_email.or(r.user_email); + let requester_email = r.requester_email.or(Some(r.user_email)); serde_json::json!({ "id": r.id, "title": r.subject, @@ -562,7 +562,7 @@ async fn admin_get_case( .collect(); let requester_name = t.requester_name.or(t.user_name); - let requester_email = t.requester_email.or(t.user_email); + let requester_email = t.requester_email.or(Some(t.user_email)); (StatusCode::OK, Json(serde_json::json!({ "ticket": { diff --git a/crates/db/migrations/20260402030000_strict_employee_separation.up.sql b/crates/db/migrations/20260402030000_strict_employee_separation.up.sql index 5b4374e..44da563 100644 --- a/crates/db/migrations/20260402030000_strict_employee_separation.up.sql +++ b/crates/db/migrations/20260402030000_strict_employee_separation.up.sql @@ -1,7 +1,10 @@ -- 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 IF NOT EXISTS employees ( +CREATE TABLE employees ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, @@ -10,14 +13,14 @@ CREATE TABLE IF NOT EXISTS employees ( employee_code VARCHAR(50) UNIQUE, department_id UUID REFERENCES departments(id) ON DELETE SET NULL, designation_id UUID REFERENCES designations(id) ON DELETE SET NULL, - role_code VARCHAR(50) NOT NULL DEFAULT 'STAFF', -- ADMIN, MANAGER, STAFF - status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE, INACTIVE, SUSPENDED + role_code VARCHAR(50) NOT NULL DEFAULT 'STAFF', + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', joined_at DATE NOT NULL DEFAULT CURRENT_DATE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- 4. EMPLOYEE SESSIONS (Standalone Auth) +-- 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,