fix: company job posting, job seeker profile submission, and job applications

- 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.
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-21 01:00:47 +05:30
parent 00923681ec
commit 2e95c4750b
7 changed files with 229 additions and 37 deletions

View file

@ -245,7 +245,7 @@ pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box<dyn std::error::Err
INNER JOIN company_profiles c ON c.id = j.company_id
WHERE j.status = 'LIVE'
AND j.created_at > $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

View file

@ -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<String>, 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"),

View file

@ -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(

View file

@ -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,

View file

@ -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;

View file

@ -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);

View file

@ -27,8 +27,7 @@ pub struct CreateJobSeekerDocumentPayload {
pub struct JobSeekerProfile {
pub id: Uuid,
pub user_id: Uuid,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub full_name: Option<String>,
pub location: Option<String>,
pub summary: Option<String>,
pub experience_years: Option<i32>,
@ -44,8 +43,7 @@ pub struct JobSeekerProfile {
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertJobSeekerProfilePayload {
pub first_name: Option<String>,
pub last_name: Option<String>,
pub full_name: Option<String>,
pub location: Option<String>,
pub summary: Option<String>,
pub experience_years: Option<i32>,
@ -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
"#,