From 2e95c4750b93a03cbcec4faab22e6e6ada28ddc2 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Tue, 21 Jul 2026 01:00:47 +0530 Subject: [PATCH] fix: company job posting, job seeker profile submission, and job applications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Company approval wrote profile status to 'ACTIVE' (company_profiles' own pre-verification default) using an id column that never matched any row, so create_job's APPROVED check always rejected newly approved companies. Match on user_id for user_id-keyed tables and write the canonical 'APPROVED' status. - job_seeker_profiles was missing columns the job-seeker app has always queried (full_name, location, summary, skills, active_application_count, status), and the job_applications / job_seeker_documents tables it depends on were never migrated in — job seeker profile save/submit and job applications failed outright with "column/relation does not exist". - Renamed the job_seeker first_name/last_name split to full_name to match what the frontend has always sent. - Special-cased JOB_SEEKER in the generic profile.rs handlers (mirrors the existing COMPANY special-case) so the shared ProfilePage save/ submit flow, which was routed through a user_role_profile_id-based path job_seeker_profiles never had, now persists correctly. - Fixed apply_to_job's company notification query joining a nonexistent "companies" table instead of company_profiles. - Fixed auto-apply cron's company status filter to match the corrected 'APPROVED' status. --- apps/cron/src/tasks/auto_apply.rs | 2 +- apps/job_seekers/src/handlers.rs | 14 +-- apps/users/src/handlers/approvals.rs | 56 ++++++--- apps/users/src/handlers/profile.rs | 117 ++++++++++++++++++ ...b_seeker_profile_and_applications.down.sql | 10 ++ ...job_seeker_profile_and_applications.up.sql | 45 +++++++ crates/db/src/models/job_seeker.rs | 22 ++-- 7 files changed, 229 insertions(+), 37 deletions(-) create mode 100644 crates/db/migrations/20260721000000_fix_job_seeker_profile_and_applications.down.sql create mode 100644 crates/db/migrations/20260721000000_fix_job_seeker_profile_and_applications.up.sql diff --git a/apps/cron/src/tasks/auto_apply.rs b/apps/cron/src/tasks/auto_apply.rs index 1ac9ddf..b3d605e 100644 --- a/apps/cron/src/tasks/auto_apply.rs +++ b/apps/cron/src/tasks/auto_apply.rs @@ -245,7 +245,7 @@ pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box $1 - AND c.status = 'ACTIVE' + AND c.status = 'APPROVED' AND NOT EXISTS ( SELECT 1 FROM job_applications ja WHERE ja.job_id = j.id AND ja.applicant_user_id = $2 diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index 21a31dc..1cb9d24 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -130,12 +130,12 @@ async fn regenerate_resume_for_user( .ok() .flatten(); - let (first, last) = name_row.unwrap_or((seeker.first_name.clone(), seeker.last_name.clone())); + let (first, last) = name_row.unwrap_or((None, None)); let full_name = match (first.as_deref(), last.as_deref()) { (Some(f), Some(l)) if !f.is_empty() || !l.is_empty() => format!("{} {}", f, l).trim().to_string(), - (Some(f), _) => f.to_string(), - (_, Some(l)) => l.to_string(), - _ => "Job Seeker".to_string(), + (Some(f), _) if !f.is_empty() => f.to_string(), + (_, Some(l)) if !l.is_empty() => l.to_string(), + _ => seeker.full_name.clone().filter(|n| !n.is_empty()).unwrap_or_else(|| "Job Seeker".to_string()), }; let portfolio = seeker.custom_data @@ -535,14 +535,14 @@ 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, uuid::Uuid)>( - "SELECT u.email, CONCAT(u.first_name, ' ', u.last_name) AS name, u.id 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, u.id FROM users u INNER JOIN company_profiles 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_id))) = company_user { - let seeker_name = format!("{} {}", seeker.first_name.unwrap_or_default(), seeker.last_name.unwrap_or_default()); + let seeker_name = seeker.full_name.clone().filter(|n| !n.is_empty()).unwrap_or_else(|| "A candidate".to_string()); let _ = state.mail.send_new_application_email( &email, name.as_deref().unwrap_or("Company"), diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index 44842db..1c1c3c2 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -178,23 +178,47 @@ async fn activate_profile_after_final_approval( None => return Ok(()), }; - let user_role_profile_id = match sqlx::query_scalar::<_, Uuid>( - "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2", - ) - .bind(user_id) - .bind(&role_key) - .fetch_optional(&state.pool) - .await? - { - Some(id) => id, - None => return Ok(()), - }; + // 'APPROVED' is the canonical verified-profile status checked everywhere + // downstream (create_job, dashboard counts, company/customer/professional + // models) — writing 'ACTIVE' here left newly-approved companies unable to + // post jobs since 'ACTIVE' is also company_profiles' pre-verification default. + // + // company_profiles/job_seeker_profiles are keyed by user_id directly and + // have no user_role_profile_id column, so they must be matched on + // user_id. Every other role table (photographer_profiles, etc.) is keyed + // by its own id with a separate user_role_profile_id FK column — + // matching those against `id` (as this previously did) never hit any + // row, silently no-opping the approval. + // NOTE: customer_profiles has the same user_id-keyed shape but is left + // out here — its status column doesn't exist in the DB yet (blocked on + // the still-disabled requirements/leads migration), a separate, + // pre-existing issue in the customer/requirements vertical, out of scope + // for this fix. + if role_key == "COMPANY" || role_key == "JOB_SEEKER" { + let query = format!( + "UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1", + table + ); + sqlx::query(&query).bind(user_id).execute(&state.pool).await?; + } else { + let user_role_profile_id = match sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2", + ) + .bind(user_id) + .bind(&role_key) + .fetch_optional(&state.pool) + .await? + { + Some(id) => id, + None => return Ok(()), + }; - let query = format!( - "UPDATE {} SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1", - table - ); - sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?; + let query = format!( + "UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE user_role_profile_id = $1", + table + ); + sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?; + } // Update user's role to match the approved role_key and set status to ACTIVE sqlx::query( diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index d484f63..3bdb710 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -144,6 +144,46 @@ async fn get_profile( }; } + if role_key == "JOB_SEEKER" { + return match sqlx::query( + r#"SELECT custom_data, status FROM job_seeker_profiles WHERE user_id = $1"#, + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(r)) => { + use sqlx::Row; + let custom_data: serde_json::Value = + r.try_get("custom_data").unwrap_or(serde_json::Value::Null); + let status: String = r.try_get("status").unwrap_or_default(); + let profile_data = custom_data + .get("basic_info") + .cloned() + .unwrap_or(serde_json::Value::Null); + ( + StatusCode::OK, + Json(serde_json::json!({ + "role_key": role_key, + "profile_data": profile_data, + "verification_status": status, + })), + ) + .into_response() + } + Ok(None) => ( + StatusCode::OK, + Json(serde_json::json!({ + "role_key": role_key, + "profile_data": null, + "verification_status": "NOT_STARTED", + })), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + } + let table = match role_to_table(&role_key) { Some(t) => t, None => { @@ -320,6 +360,54 @@ async fn save_profile( }; } + if role_key == "JOB_SEEKER" { + // job_seeker_profiles also stores the job_seeker_portfolio blob (written + // by the dedicated /api/jobseeker/profile/me endpoint) inside custom_data, + // so basic-tab fields are merged in under a nested "basic_info" key + // instead of overwriting custom_data wholesale. + let existing_custom_data: serde_json::Value = sqlx::query_scalar( + r#"SELECT custom_data FROM job_seeker_profiles WHERE user_id = $1"#, + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .unwrap_or(serde_json::Value::Object(Default::default())); + + let mut merged = match existing_custom_data { + serde_json::Value::Object(map) => map, + _ => Default::default(), + }; + merged.insert("basic_info".to_string(), input.profile_data.clone()); + let merged_custom_data = serde_json::Value::Object(merged); + + return match sqlx::query( + r#" + INSERT INTO job_seeker_profiles (user_id, custom_data, status, updated_at) + VALUES ($1, $2, 'DRAFT', NOW()) + ON CONFLICT (user_id) DO UPDATE SET + custom_data = EXCLUDED.custom_data, + updated_at = NOW() + "#, + ) + .bind(auth.user_id) + .bind(&merged_custom_data) + .execute(&state.pool) + .await + { + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ "saved": true, "role_key": role_key })), + ) + .into_response(), + Err(e) => { + tracing::error!("save_profile(JOB_SEEKER) failed for user {}: {}", auth.user_id, e); + (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response() + } + }; + } + let table = match role_to_table(&role_key) { Some(t) => t, None => { @@ -591,6 +679,23 @@ async fn fetch_saved_profile( }; } + if role_key == "JOB_SEEKER" { + let custom_data: serde_json::Value = sqlx::query_scalar( + r#"SELECT custom_data FROM job_seeker_profiles WHERE user_id = $1"#, + ) + .bind(user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .unwrap_or(serde_json::Value::Object(Default::default())); + + return custom_data + .get("basic_info") + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())); + } + if let Some(urp_id) = get_user_role_profile_id(&state.pool, user_id, role_key).await.ok().flatten() { return fetch_saved_profile_by_urp_id(state, urp_id, role_key).await; } @@ -611,6 +716,18 @@ async fn set_profile_status(state: &AppState, user_id: Uuid, role_key: &str, sta return; } + if role_key == "JOB_SEEKER" { + sqlx::query( + r#"UPDATE job_seeker_profiles SET status = $1, updated_at = NOW() WHERE user_id = $2"#, + ) + .bind(status) + .bind(user_id) + .execute(&state.pool) + .await + .ok(); + return; + } + let user_role_profile_id = match get_user_role_profile_id(&state.pool, user_id, role_key).await { Ok(Some(id)) => id, Ok(None) => return, diff --git a/crates/db/migrations/20260721000000_fix_job_seeker_profile_and_applications.down.sql b/crates/db/migrations/20260721000000_fix_job_seeker_profile_and_applications.down.sql new file mode 100644 index 0000000..323322c --- /dev/null +++ b/crates/db/migrations/20260721000000_fix_job_seeker_profile_and_applications.down.sql @@ -0,0 +1,10 @@ +DROP TABLE IF EXISTS job_seeker_documents; +DROP TABLE IF EXISTS job_applications; + +ALTER TABLE job_seeker_profiles + DROP COLUMN IF EXISTS full_name, + DROP COLUMN IF EXISTS location, + DROP COLUMN IF EXISTS summary, + DROP COLUMN IF EXISTS skills, + DROP COLUMN IF EXISTS active_application_count, + DROP COLUMN IF EXISTS status; diff --git a/crates/db/migrations/20260721000000_fix_job_seeker_profile_and_applications.up.sql b/crates/db/migrations/20260721000000_fix_job_seeker_profile_and_applications.up.sql new file mode 100644 index 0000000..d8e07df --- /dev/null +++ b/crates/db/migrations/20260721000000_fix_job_seeker_profile_and_applications.up.sql @@ -0,0 +1,45 @@ +-- job_seeker_profiles was missing columns the job-seeker app code has always +-- queried (full_name, location, summary, skills, active_application_count, +-- status), so profile save/submit and job applications failed at the SQL +-- layer with "column does not exist". +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 skills TEXT[] DEFAULT '{}', + ADD COLUMN IF NOT EXISTS active_application_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'DRAFT'; + +-- ApplicationRepository has always queried `job_applications`, but only the +-- differently-shaped `applications` table (job_seeker_id-keyed) was ever +-- created, so every apply-to-job attempt failed with "relation does not +-- exist". +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 NOT NULL REFERENCES users(id) ON DELETE CASCADE, + cover_note TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'APPLIED', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(job_id, applicant_user_id) +); + +CREATE INDEX IF NOT EXISTS idx_job_applications_job_id ON job_applications(job_id); +CREATE INDEX IF NOT EXISTS idx_job_applications_applicant_user_id ON job_applications(applicant_user_id); +CREATE INDEX IF NOT EXISTS idx_job_applications_status ON job_applications(status); + +-- job_seeker_documents backs JobSeekerRepository::create_document/list_documents/ +-- delete_document, referenced from apps/job_seekers but never migrated in. +CREATE TABLE IF NOT EXISTS job_seeker_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_seeker_id UUID NOT NULL REFERENCES job_seeker_profiles(id) ON DELETE CASCADE, + document_type VARCHAR(100) NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_url VARCHAR(500) NOT NULL, + file_size BIGINT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_job_seeker_documents_job_seeker_id ON job_seeker_documents(job_seeker_id); diff --git a/crates/db/src/models/job_seeker.rs b/crates/db/src/models/job_seeker.rs index bc79844..76defc1 100644 --- a/crates/db/src/models/job_seeker.rs +++ b/crates/db/src/models/job_seeker.rs @@ -27,8 +27,7 @@ pub struct CreateJobSeekerDocumentPayload { pub struct JobSeekerProfile { pub id: Uuid, pub user_id: Uuid, - pub first_name: Option, - pub last_name: Option, + pub full_name: Option, pub location: Option, pub summary: Option, pub experience_years: Option, @@ -44,8 +43,7 @@ pub struct JobSeekerProfile { #[derive(Debug, Serialize, Deserialize)] pub struct UpsertJobSeekerProfilePayload { - pub first_name: Option, - pub last_name: Option, + pub full_name: Option, pub location: Option, pub summary: Option, pub experience_years: Option, @@ -65,7 +63,7 @@ impl JobSeekerRepository { let profile = sqlx::query_as::<_, JobSeekerProfile>( r#" SELECT - id, user_id, first_name, last_name, location, summary, experience_years, + id, user_id, full_name, location, summary, experience_years, skills, resume_url, active_application_count, status, bio, custom_data, created_at, updated_at FROM job_seeker_profiles @@ -87,13 +85,12 @@ impl JobSeekerRepository { let profile = sqlx::query_as::<_, JobSeekerProfile>( r#" INSERT INTO job_seeker_profiles ( - user_id, first_name, last_name, location, summary, experience_years, + user_id, full_name, location, summary, experience_years, skills, resume_url, bio, custom_data ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (user_id) DO UPDATE SET - first_name = EXCLUDED.first_name, - last_name = EXCLUDED.last_name, + full_name = EXCLUDED.full_name, location = EXCLUDED.location, summary = EXCLUDED.summary, experience_years = EXCLUDED.experience_years, @@ -103,14 +100,13 @@ impl JobSeekerRepository { custom_data = EXCLUDED.custom_data, updated_at = NOW() RETURNING - id, user_id, first_name, last_name, location, summary, experience_years, + id, user_id, full_name, location, summary, experience_years, skills, resume_url, active_application_count, status, bio, custom_data, created_at, updated_at "#, ) .bind(user_id) - .bind(payload.first_name) - .bind(payload.last_name) + .bind(payload.full_name) .bind(payload.location) .bind(payload.summary) .bind(payload.experience_years.unwrap_or(0)) @@ -149,7 +145,7 @@ impl JobSeekerRepository { SET status = 'PENDING_REVIEW', updated_at = NOW() WHERE user_id = $1 RETURNING - id, user_id, first_name, last_name, location, summary, experience_years, + id, user_id, full_name, location, summary, experience_years, skills, resume_url, active_application_count, status, bio, custom_data, created_at, updated_at "#,