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:
parent
00923681ec
commit
2e95c4750b
7 changed files with 229 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
"#,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue