Fix wizard-submitted profile data being dropped and wizard schemas saving with no fields
All checks were successful
build-and-release / build (customers) (push) Successful in 11s
build-and-release / build (catering-services) (push) Successful in 13s
build-and-release / build (companies) (push) Successful in 16s
build-and-release / build (developers) (push) Successful in 16s
build-and-release / build (cron) (push) Successful in 17s
build-and-release / build (employees) (push) Successful in 16s
build-and-release / build (fitness-trainers) (push) Successful in 6s
build-and-release / build (gateway) (push) Successful in 5s
build-and-release / build (graphic-designers) (push) Successful in 7s
build-and-release / build (job-seekers) (push) Successful in 7s
build-and-release / build (jobs) (push) Successful in 5s
build-and-release / build (makeup-artists) (push) Successful in 5s
build-and-release / build (payments) (push) Successful in 7s
build-and-release / build (photographers) (push) Successful in 4s
build-and-release / build (tutors) (push) Successful in 5s
build-and-release / build (social-media-managers) (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 (users) (push) Successful in 2m31s

- 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 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-29 11:46:03 +05:30
parent a24e03fd02
commit fd889efa22
2 changed files with 102 additions and 8 deletions

View file

@ -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<AppState>,
@ -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((

View file

@ -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<String> = r.try_get("company_name").ok();
let email: Option<String> = r.try_get("contact_email").ok();
let phone: Option<String> = r.try_get("contact_phone").ok();
let website: Option<String> = r.try_get("website_url").ok();
let address: Option<String> = r.try_get("address_line1").ok();
let city: Option<String> = r.try_get("city").ok();
let state_val: Option<String> = r.try_get("state").ok();
let postal: Option<String> = r.try_get("postal_code").ok();
let gst: Option<String> = 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
{