From fd889efa22f737423796d57e125b122f82a8111e Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Wed, 29 Jul 2026 11:46:03 +0530 Subject: [PATCH] Fix wizard-submitted profile data being dropped and wizard schemas saving with no fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/profile for COMPANY only ever returned company_name, silently discarding every other field the wizard (and PATCH) already saved — the profile page looked empty after approval. Select and return all company_profiles columns. - The generic professional-role PATCH path (photographer, tutor, etc.) overwrote custom_data wholesale instead of merging, so RoleWizard's two sequential PATCH calls (portfolio step, then basic+documents step) clobbered each other. Read-merge-write instead, matching the existing JOB_SEEKER/CUSTOMER pattern. - create_onboarding_config now rejects schemas with enableWizardFlow=true but no steps, or a non-review step with no fields — the root cause of a wizard rendering with nothing to fill in. Co-Authored-By: Claude Sonnet 5 --- apps/users/src/handlers/config.rs | 48 +++++++++++++++++++++++ apps/users/src/handlers/profile.rs | 62 ++++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/apps/users/src/handlers/config.rs b/apps/users/src/handlers/config.rs index 6d37606..8638e3a 100644 --- a/apps/users/src/handlers/config.rs +++ b/apps/users/src/handlers/config.rs @@ -485,6 +485,51 @@ mod tests { +/// Rejects schemas that would enable the wizard flow with nothing for the +/// user to fill in: an empty `steps` array, or any non-review step whose +/// `fields` array is empty. Guards against the admin editor's "Enable +/// verification wizard flow" checkbox being saved before any fields are +/// added to a step. +fn validate_onboarding_schema(schema_json: &serde_json::Value) -> Result<(), String> { + let enable_wizard_flow = schema_json + .get("enableWizardFlow") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !enable_wizard_flow { + return Ok(()); + } + + let steps = schema_json + .get("steps") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + if steps.is_empty() { + return Err("Cannot enable the wizard flow with no steps configured.".to_string()); + } + + for step in &steps { + let step_type = step.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if step_type == "review" { + continue; + } + let fields_empty = step + .get("fields") + .and_then(|v| v.as_array()) + .map(|f| f.is_empty()) + .unwrap_or(true); + if fields_empty { + let title = step.get("title").and_then(|v| v.as_str()).unwrap_or("(untitled step)"); + return Err(format!( + "Cannot enable the wizard flow: step \"{}\" has no fields.", + title + )); + } + } + + Ok(()) +} + async fn create_onboarding_config( auth: AuthUser, State(state): State, @@ -493,6 +538,9 @@ async fn create_onboarding_config( if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } + if let Err(msg) = validate_onboarding_schema(&payload.schema_json) { + return Err((StatusCode::BAD_REQUEST, msg)); + } match ConfigRepository::create_onboarding_config(&state.pool, payload).await { Ok(config) => Ok((StatusCode::CREATED, Json(config))), Err(e) => Err(( diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index 4f699ff..609ee18 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -242,7 +242,10 @@ async fn get_profile( if role_key == "COMPANY" { let row = sqlx::query( - r#"SELECT company_name, status, updated_at FROM company_profiles WHERE user_id = $1"#, + r#"SELECT company_name, contact_email, contact_phone, website_url, + address_line1, city, state, postal_code, gst_number, + status, updated_at + FROM company_profiles WHERE user_id = $1"#, ) .bind(auth.user_id) .fetch_optional(&state.pool) @@ -252,12 +255,30 @@ async fn get_profile( Ok(Some(r)) => { use sqlx::Row; let name: Option = r.try_get("company_name").ok(); + let email: Option = r.try_get("contact_email").ok(); + let phone: Option = r.try_get("contact_phone").ok(); + let website: Option = r.try_get("website_url").ok(); + let address: Option = r.try_get("address_line1").ok(); + let city: Option = r.try_get("city").ok(); + let state_val: Option = r.try_get("state").ok(); + let postal: Option = r.try_get("postal_code").ok(); + let gst: Option = r.try_get("gst_number").ok(); let status: String = r.try_get("status").unwrap_or_default(); ( StatusCode::OK, Json(serde_json::json!({ "role_key": role_key, - "profile_data": { "company_name": name }, + "profile_data": { + "company_name": name, + "company_email": email, + "company_phone": phone, + "website": website, + "address": address, + "location": city, + "state": state_val, + "pin_code": postal, + "gst_number": gst, + }, "verification_status": status, })), ) @@ -560,6 +581,36 @@ async fn save_profile( } }; + let user_role_profile_id = match get_or_create_user_role_profile_id(&state.pool, auth.user_id, &role_key).await { + Ok(id) => id, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + // Shallow-merge onto existing custom_data instead of overwriting: RoleWizard + // issues separate PATCH calls for the portfolio step and the basic/documents + // steps, so a wholesale overwrite here would let the second call silently + // wipe out whatever the first one just saved. + let existing_custom_data: serde_json::Value = sqlx::query_scalar( + &format!(r#"SELECT custom_data FROM {} WHERE user_role_profile_id = $1"#, table), + ) + .bind(user_role_profile_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .unwrap_or(serde_json::Value::Null); + + let mut merged = match existing_custom_data { + serde_json::Value::Object(map) => map, + _ => Default::default(), + }; + if let serde_json::Value::Object(incoming) = &input.profile_data { + for (k, v) in incoming { + merged.insert(k.clone(), v.clone()); + } + } + let merged_custom_data = serde_json::Value::Object(merged); + let query = format!( r#" INSERT INTO {table} (user_id, user_role_profile_id, custom_data, status, updated_at) @@ -570,15 +621,10 @@ async fn save_profile( "# ); - let user_role_profile_id = match get_or_create_user_role_profile_id(&state.pool, auth.user_id, &role_key).await { - Ok(id) => id, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - }; - match sqlx::query(&query) .bind(auth.user_id) .bind(user_role_profile_id) - .bind(&input.profile_data) + .bind(&merged_custom_data) .execute(&state.pool) .await {