Add runtime-config foundation for verification wizard + field locking
All checks were successful
build-and-release / build (cron) (push) Successful in 1m1s
build-and-release / build (catering-services) (push) Successful in 1m40s
build-and-release / build (companies) (push) Successful in 1m50s
build-and-release / build (employees) (push) Successful in 2m19s
build-and-release / build (developers) (push) Successful in 2m23s
build-and-release / build (customers) (push) Successful in 2m27s
build-and-release / build (gateway) (push) Successful in 1m4s
build-and-release / build (graphic-designers) (push) Successful in 57s
build-and-release / build (jobs) (push) Successful in 47s
build-and-release / build (makeup-artists) (push) Successful in 50s
build-and-release / build (fitness-trainers) (push) Successful in 2m37s
build-and-release / build (photographers) (push) Successful in 1m39s
build-and-release / build (payments) (push) Successful in 2m4s
build-and-release / build (job-seekers) (push) Successful in 2m56s
build-and-release / build (social-media-managers) (push) Successful in 2m25s
build-and-release / build (tutors) (push) Successful in 2m52s
build-and-release / build (ugc-content-creators) (push) Successful in 3m0s
build-and-release / build (video-editors) (push) Successful in 2m3s
build-and-release / build (users) (push) Successful in 4m20s

Reconciles onboarding_configs.schema_json (previously orphaned seed data
using a field vocabulary that didn't match production) with the live
profile-fields-config.ts field keys, and extends the schema with
lockAfterApproval flags, step types, and a per-role portfolioModel so
the frontend wizard is entirely schema-driven rather than hardcoded per
role.

Also: closes an unauthenticated write on the onboarding/dashboard
config create endpoints (require_admin was missing), and adds
server-side enforcement in save_profile rejecting changes to any field
marked lockAfterApproval once a profile is APPROVED — the UI already
disables these inputs, this stops a direct API call from bypassing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-28 20:10:15 +05:30
parent 80acfdd64f
commit a24e03fd02
3 changed files with 242 additions and 812 deletions

View file

@ -486,9 +486,13 @@ mod tests {
async fn create_onboarding_config(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateOnboardingConfigPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
match ConfigRepository::create_onboarding_config(&state.pool, payload).await {
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
Err(e) => Err((
@ -528,9 +532,13 @@ async fn list_onboarding_configs(
}
async fn create_dashboard_config(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateDashboardConfigPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
match ConfigRepository::create_dashboard_config(&state.pool, payload).await {
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
Err(e) => Err((

View file

@ -7,6 +7,7 @@ use axum::{
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use db::models::config::ConfigRepository;
use db::models::{role::RoleRepository, user::UserRepository, verification::VerificationRepository};
use serde::Deserialize;
use uuid::Uuid;
@ -99,6 +100,136 @@ fn resolve_role_key(auth_role: &str, query_role: Option<String>) -> String {
.to_uppercase()
}
/// Field ids marked `lockAfterApproval: true` anywhere in a role's active
/// onboarding schema — these become permanently read-only once the profile
/// is APPROVED, per the "name/GST/Aadhar stay locked, phone/address stay
/// editable" verification design.
fn locked_field_ids(schema_json: &serde_json::Value) -> Vec<String> {
schema_json
.get("steps")
.and_then(|s| s.as_array())
.into_iter()
.flatten()
.filter_map(|step| step.get("fields")?.as_array())
.flatten()
.filter(|field| field.get("lockAfterApproval").and_then(|v| v.as_bool()).unwrap_or(false))
.filter_map(|field| field.get("id")?.as_str().map(String::from))
.collect()
}
/// Current verification status + a flat view of currently-stored profile
/// fields for this user/role, used only to check locked-field values before
/// a save is persisted. Best-effort: any lookup failure yields an
/// "unapproved" default, since there is nothing to protect if no profile row
/// exists yet.
async fn fetch_current_profile_state(
pool: &sqlx::PgPool,
user_id: Uuid,
role_key: &str,
) -> (String, serde_json::Value) {
if role_key == "COMPANY" {
let row = sqlx::query(
r#"SELECT company_name, gst_number, status FROM company_profiles WHERE user_id = $1"#,
)
.bind(user_id)
.fetch_optional(pool)
.await;
return match row {
Ok(Some(r)) => {
use sqlx::Row;
let company_name: Option<String> = r.try_get("company_name").ok();
let gst_number: Option<String> = r.try_get("gst_number").ok();
let status: String = r.try_get("status").unwrap_or_default();
(status, serde_json::json!({ "company_name": company_name, "gst_number": gst_number }))
}
_ => ("NOT_STARTED".to_string(), serde_json::Value::Null),
};
}
if role_key == "JOB_SEEKER" || role_key == "CUSTOMER" {
let table = if role_key == "JOB_SEEKER" { "job_seeker_profiles" } else { "customer_profiles" };
let query = format!(r#"SELECT custom_data, status FROM {} WHERE user_id = $1"#, table);
let row = sqlx::query(&query).bind(user_id).fetch_optional(pool).await;
return match row {
Ok(Some(r)) => {
use sqlx::Row;
let custom_data: serde_json::Value =
r.try_get("custom_data").unwrap_or(serde_json::Value::Null);
let status: String = r.try_get("status").unwrap_or_default();
let profile_data = custom_data.get("basic_info").cloned().unwrap_or(serde_json::Value::Null);
(status, profile_data)
}
_ => ("NOT_STARTED".to_string(), serde_json::Value::Null),
};
}
let Some(table) = role_to_table(role_key) else {
return ("NOT_STARTED".to_string(), serde_json::Value::Null);
};
let Ok(Some(user_role_profile_id)) = get_user_role_profile_id(pool, user_id, role_key).await else {
return ("NOT_STARTED".to_string(), serde_json::Value::Null);
};
let query = format!(r#"SELECT custom_data, status FROM {} WHERE user_role_profile_id = $1"#, table);
match sqlx::query(&query).bind(user_role_profile_id).fetch_optional(pool).await {
Ok(Some(row)) => {
use sqlx::Row;
let profile_data: serde_json::Value =
row.try_get("custom_data").unwrap_or(serde_json::Value::Null);
let status: String = row.try_get("status").unwrap_or_default();
(status, profile_data)
}
_ => ("NOT_STARTED".to_string(), serde_json::Value::Null),
}
}
/// Rejects a save that changes any field marked `lockAfterApproval` once the
/// profile has already been APPROVED — the server-side backstop for the
/// "verified fields stay locked" guarantee (the UI already disables these
/// inputs, but this stops a direct API call from bypassing that).
async fn reject_locked_field_changes(
pool: &sqlx::PgPool,
user_id: Uuid,
role_key: &str,
incoming: &serde_json::Value,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
let schema = match ConfigRepository::get_active_onboarding_by_role_key(pool, role_key).await {
Ok(config) => config.schema_json,
Err(_) => return Ok(()), // no schema configured for this role - nothing to enforce
};
let locked = locked_field_ids(&schema);
if locked.is_empty() {
return Ok(());
}
let (status, current) = fetch_current_profile_state(pool, user_id, role_key).await;
if status != "APPROVED" {
return Ok(());
}
let mut violations = vec![];
for field_id in &locked {
if let Some(new_val) = incoming.get(field_id) {
let current_val = current.get(field_id).cloned().unwrap_or(serde_json::Value::Null);
if !current_val.is_null() && new_val != &current_val {
violations.push(field_id.clone());
}
}
}
if violations.is_empty() {
Ok(())
} else {
Err((
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": format!("These verified fields can no longer be changed: {}", violations.join(", ")),
"code": "FIELD_LOCKED",
"locked_fields": violations,
})),
))
}
}
// ── Handlers ──────────────────────────────────────────────────────────────────
/// GET /api/profile?roleKey=PHOTOGRAPHER
@ -260,6 +391,12 @@ async fn save_profile(
) -> impl IntoResponse {
let role_key = input.role_key.to_uppercase();
if let Err((status, body)) =
reject_locked_field_changes(&state.pool, auth.user_id, &role_key, &input.profile_data).await
{
return (status, body).into_response();
}
if role_key == "COMPANY" {
let name = input
.profile_data

File diff suppressed because it is too large Load diff