fix: verification submission no longer blocks login or misreports status
All checks were successful
build-and-release / build (customers) (push) Successful in 12s
build-and-release / build (catering-services) (push) Successful in 15s
build-and-release / build (developers) (push) Successful in 13s
build-and-release / build (cron) (push) Successful in 17s
build-and-release / build (employees) (push) Successful in 5s
build-and-release / build (gateway) (push) Successful in 5s
build-and-release / build (fitness-trainers) (push) Successful in 6s
build-and-release / build (graphic-designers) (push) Successful in 7s
build-and-release / build (makeup-artists) (push) Successful in 5s
build-and-release / build (jobs) (push) Successful in 8s
build-and-release / build (payments) (push) Successful in 7s
build-and-release / build (photographers) (push) Successful in 7s
build-and-release / build (social-media-managers) (push) Successful in 5s
build-and-release / build (tutors) (push) Successful in 6s
build-and-release / build (ugc-content-creators) (push) Successful in 5s
build-and-release / build (video-editors) (push) Successful in 4s
build-and-release / build (companies) (push) Successful in 1m10s
build-and-release / build (job-seekers) (push) Successful in 1m8s
build-and-release / build (users) (push) Successful in 3m7s

- 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 <noreply@anthropic.com>
This commit is contained in:
Tracewebstudio Dev 2026-07-30 15:36:12 +02:00
parent 5f1b48d27b
commit 2f47308498
3 changed files with 66 additions and 58 deletions

View file

@ -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<Option<Uuid>, 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.

View file

@ -58,6 +58,31 @@ pub struct PaginationQuery {
// ── Handlers ──────────────────────────────────────────────────────────────────
async fn get_or_create_job_seeker_profile(
pool: &sqlx::PgPool,
user_id: Uuid,
) -> Result<db::models::job_seeker::JobSeekerProfile, sqlx::Error> {
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<AppState>,
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,25 +705,9 @@ 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 {
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(),
}
}
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
};
let mut file_bytes = bytes::BytesMut::new();

View file

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