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
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:
parent
5f1b48d27b
commit
2f47308498
3 changed files with 66 additions and 58 deletions
|
|
@ -869,11 +869,14 @@ async fn submit_with_documents(
|
||||||
|
|
||||||
// storage_backend is always "b2" (set at top of function).
|
// 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() {
|
let document_type = if !original_filename.is_empty() {
|
||||||
std::path::Path::new(&original_filename)
|
original_filename
|
||||||
.file_stem()
|
.splitn(2, '|')
|
||||||
.and_then(|s| s.to_str())
|
.next()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
.unwrap_or("document")
|
.unwrap_or("document")
|
||||||
.to_string()
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1061,6 +1064,29 @@ async fn submit_with_documents(
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 4. Create the verification record. ----
|
// ---- 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)
|
// 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
|
// (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.
|
// populates it with "PROFILE_VERIFICATION" — we do the same.
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,31 @@ pub struct PaginationQuery {
|
||||||
|
|
||||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
// ── 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(
|
async fn get_profile(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
|
|
@ -73,13 +98,12 @@ async fn get_profile(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await {
|
match get_or_create_job_seeker_profile(&state.pool, auth.user_id).await {
|
||||||
Ok(Some(profile)) => {
|
Ok(profile) => {
|
||||||
// Cache for 5 minutes
|
// Cache for 5 minutes
|
||||||
let _: Result<(), _> = redis.set_ex(&cache_key, &serde_json::to_string(&profile).unwrap_or_default(), 300).await;
|
let _: Result<(), _> = redis.set_ex(&cache_key, &serde_json::to_string(&profile).unwrap_or_default(), 300).await;
|
||||||
(StatusCode::OK, Json(profile)).into_response()
|
(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(),
|
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
|
// 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,
|
// job_seeker_profiles row - lazily create a blank one instead of 404ing,
|
||||||
// since uploading a verification document doesn't depend on that data.
|
// 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 {
|
let seeker = match get_or_create_job_seeker_profile(&state.pool, auth.user_id).await {
|
||||||
Ok(Some(s)) => s,
|
Ok(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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -742,17 +742,10 @@ async fn submit_for_verification(
|
||||||
// Mark profile as PENDING in role-specific table
|
// Mark profile as PENDING in role-specific table
|
||||||
set_profile_status(&state, auth.user_id, &role_key, "PENDING").await;
|
set_profile_status(&state, auth.user_id, &role_key, "PENDING").await;
|
||||||
|
|
||||||
// Mark user_role as PENDING
|
// NOTE: we intentionally do NOT change user_role_assignments.status here.
|
||||||
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
|
// Verification status is tracked in the verifications table. Downgrading
|
||||||
sqlx::query(
|
// the role assignment to PENDING would block the user from logging in
|
||||||
"UPDATE user_role_assignments SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2",
|
// while their verification is under review, which is broken UX.
|
||||||
)
|
|
||||||
.bind(auth.user_id)
|
|
||||||
.bind(role.id)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create verification record — appears in admin Verification Management
|
// Create verification record — appears in admin Verification Management
|
||||||
match VerificationRepository::create(
|
match VerificationRepository::create(
|
||||||
|
|
@ -858,30 +851,11 @@ pub async fn verification_status(
|
||||||
.try_get("documents")
|
.try_get("documents")
|
||||||
.unwrap_or(serde_json::Value::Array(vec![]));
|
.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;
|
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")
|
let status = if raw_status.eq_ignore_ascii_case("COMPLETED")
|
||||||
|| role_assignment_approved
|
|
||||||
|| profile_status.eq_ignore_ascii_case("APPROVED")
|
|| profile_status.eq_ignore_ascii_case("APPROVED")
|
||||||
{
|
{
|
||||||
"APPROVED".to_string()
|
"APPROVED".to_string()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue