From 2f473084989afa769a6a69659f8aa1794e1f9d31 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Jul 2026 15:36:12 +0200 Subject: [PATCH] fix: verification submission no longer blocks login or misreports status - submit_for_verification: stop downgrading user_role_assignments.status to PENDING. This was locking users out of login while their verification was under review. Verification progress is tracked in the verifications table; role assignment stays APPROVED throughout. - verification_status: remove role_assignment_approved from the status calculation. Because the assignment now stays APPROVED, using it as a proxy was incorrectly overriding PENDING verification status to APPROVED immediately after submission. - companies submit_with_documents: encode document_type from filename prefix before '|' separator (set by frontend) rather than file stem; add duplicate-verification guard to match users service. - job_seekers: add get_or_create_job_seeker_profile helper to ensure profile row exists before upsert operations. Co-Authored-By: Claude Sonnet 4.6 --- apps/companies/src/handlers/mod.rs | 34 +++++++++++++++++--- apps/job_seekers/src/handlers.rs | 50 +++++++++++++++++------------- apps/users/src/handlers/profile.rs | 40 +++++------------------- 3 files changed, 66 insertions(+), 58 deletions(-) diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index 5d6aa65..ee3bb7c 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -869,11 +869,14 @@ async fn submit_with_documents( // storage_backend is always "b2" (set at top of function). - // Derive a stable document_type from the original filename (stem) so the admin UI can group. + // The frontend encodes the document type as the part before the first '|' + // in the filename (e.g. "registration_doc|scan001.pdf" → "registration_doc"). + // Splitting on '|' is unambiguous because doc-type keys never contain '|'. let document_type = if !original_filename.is_empty() { - std::path::Path::new(&original_filename) - .file_stem() - .and_then(|s| s.to_str()) + original_filename + .splitn(2, '|') + .next() + .filter(|s| !s.is_empty()) .unwrap_or("document") .to_string() } else { @@ -1061,6 +1064,29 @@ async fn submit_with_documents( } // ---- 4. Create the verification record. ---- + // Guard: reject if an active verification already exists (mirrors users/src/handlers/profile.rs). + let existing: Result, sqlx::Error> = sqlx::query_scalar( + r#" + SELECT id FROM verifications + WHERE user_id = $1 AND role_key = 'COMPANY' + AND status IN ('PENDING', 'UNDER_REVIEW', 'DOCUMENTS_REQUESTED', 'REVISION_REQUESTED') + LIMIT 1 + "#, + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await; + + if existing.unwrap_or(None).is_some() { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "A verification is already in progress. Please wait for it to be reviewed." + })), + ) + .into_response(); + } + // Signature: VerificationRepository::create(pool, user_id, role_key, case_type, priority, payload, documents) // (see crates/db/src/models/verification.rs:38). The DB column is `case_type`; the user service // populates it with "PROFILE_VERIFICATION" — we do the same. diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index 9b7bfe6..d53b837 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -58,6 +58,31 @@ pub struct PaginationQuery { // ── Handlers ────────────────────────────────────────────────────────────────── +async fn get_or_create_job_seeker_profile( + pool: &sqlx::PgPool, + user_id: Uuid, +) -> Result { + if let Some(profile) = JobSeekerRepository::get_by_user_id(pool, user_id).await? { + return Ok(profile); + } + + JobSeekerRepository::upsert( + pool, + user_id, + UpsertJobSeekerProfilePayload { + full_name: None, + location: None, + summary: None, + experience_years: None, + skills: None, + resume_url: None, + bio: None, + custom_data: None, + }, + ) + .await +} + async fn get_profile( State(state): State, auth: AuthUser, @@ -73,13 +98,12 @@ async fn get_profile( } } - match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(Some(profile)) => { + match get_or_create_job_seeker_profile(&state.pool, auth.user_id).await { + Ok(profile) => { // Cache for 5 minutes let _: Result<(), _> = redis.set_ex(&cache_key, &serde_json::to_string(&profile).unwrap_or_default(), 300).await; (StatusCode::OK, Json(profile)).into_response() } - Ok(None) => (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } @@ -681,24 +705,8 @@ async fn upload_document( // A job seeker who hasn't saved any basic profile info yet won't have a // job_seeker_profiles row - lazily create a blank one instead of 404ing, // since uploading a verification document doesn't depend on that data. - let seeker = match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(Some(s)) => s, - Ok(None) => { - let empty_payload = UpsertJobSeekerProfilePayload { - full_name: None, - location: None, - summary: None, - experience_years: None, - skills: None, - resume_url: None, - bio: None, - custom_data: None, - }; - match JobSeekerRepository::upsert(&state.pool, auth.user_id, empty_payload).await { - Ok(s) => s, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), - } - } + let seeker = match get_or_create_job_seeker_profile(&state.pool, auth.user_id).await { + Ok(s) => s, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), }; diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index 62b4ed2..6ac74bb 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -742,17 +742,10 @@ async fn submit_for_verification( // Mark profile as PENDING in role-specific table set_profile_status(&state, auth.user_id, &role_key, "PENDING").await; - // Mark user_role as PENDING - if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await { - sqlx::query( - "UPDATE user_role_assignments SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2", - ) - .bind(auth.user_id) - .bind(role.id) - .execute(&state.pool) - .await - .ok(); - } + // NOTE: we intentionally do NOT change user_role_assignments.status here. + // Verification status is tracked in the verifications table. Downgrading + // the role assignment to PENDING would block the user from logging in + // while their verification is under review, which is broken UX. // Create verification record — appears in admin Verification Management match VerificationRepository::create( @@ -858,30 +851,11 @@ pub async fn verification_status( .try_get("documents") .unwrap_or(serde_json::Value::Array(vec![])); - let role_assignment_approved = if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await { - sqlx::query_scalar::<_, String>( - r#" - SELECT status - FROM user_role_assignments - WHERE user_id = $1 AND role_id = $2 - LIMIT 1 - "#, - ) - .bind(auth.user_id) - .bind(role.id) - .fetch_optional(&state.pool) - .await - .ok() - .flatten() - .map(|status| status.eq_ignore_ascii_case("APPROVED")) - .unwrap_or(false) - } else { - false - }; - let profile_status = fetch_current_profile_state(&state.pool, auth.user_id, &role_key).await.0; + // Use the verification record's status as the source of truth. + // role_assignment_approved is NOT used here — keeping the role APPROVED + // so users can log in while under review means it cannot drive this check. let status = if raw_status.eq_ignore_ascii_case("COMPLETED") - || role_assignment_approved || profile_status.eq_ignore_ascii_case("APPROVED") { "APPROVED".to_string()