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
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:
parent
80acfdd64f
commit
a24e03fd02
3 changed files with 242 additions and 812 deletions
|
|
@ -486,9 +486,13 @@ mod tests {
|
||||||
|
|
||||||
|
|
||||||
async fn create_onboarding_config(
|
async fn create_onboarding_config(
|
||||||
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<CreateOnboardingConfigPayload>,
|
Json(payload): Json<CreateOnboardingConfigPayload>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> 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 {
|
match ConfigRepository::create_onboarding_config(&state.pool, payload).await {
|
||||||
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
|
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
|
||||||
Err(e) => Err((
|
Err(e) => Err((
|
||||||
|
|
@ -528,9 +532,13 @@ async fn list_onboarding_configs(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_dashboard_config(
|
async fn create_dashboard_config(
|
||||||
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<CreateDashboardConfigPayload>,
|
Json(payload): Json<CreateDashboardConfigPayload>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> 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 {
|
match ConfigRepository::create_dashboard_config(&state.pool, payload).await {
|
||||||
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
|
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
|
||||||
Err(e) => Err((
|
Err(e) => Err((
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::AuthUser;
|
||||||
|
use db::models::config::ConfigRepository;
|
||||||
use db::models::{role::RoleRepository, user::UserRepository, verification::VerificationRepository};
|
use db::models::{role::RoleRepository, user::UserRepository, verification::VerificationRepository};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -99,6 +100,136 @@ fn resolve_role_key(auth_role: &str, query_role: Option<String>) -> String {
|
||||||
.to_uppercase()
|
.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 != ¤t_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 ──────────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// GET /api/profile?roleKey=PHOTOGRAPHER
|
/// GET /api/profile?roleKey=PHOTOGRAPHER
|
||||||
|
|
@ -260,6 +391,12 @@ async fn save_profile(
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let role_key = input.role_key.to_uppercase();
|
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" {
|
if role_key == "COMPANY" {
|
||||||
let name = input
|
let name = input
|
||||||
.profile_data
|
.profile_data
|
||||||
|
|
|
||||||
909
scripts/seed.sql
909
scripts/seed.sql
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue