use crate::AppState; use axum::{ extract::{Query, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router, }; use contracts::auth_middleware::AuthUser; use db::models::{ onboarding_state::OnboardingStateRepository, role::RoleRepository, }; use serde::{Deserialize, Serialize}; // ── Routers ─────────────────────────────────────────────────────────────────── pub fn onboarding_router() -> Router { Router::new() .route("/state", get(get_state)) .route("/save-progress", post(save_progress)) .route("/submit", post(submit)) } pub fn me_router() -> Router { Router::new() .route("/profile-status", get(profile_status)) } // ── DTOs ────────────────────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct RoleKeyQuery { #[serde(rename = "roleKey")] pub role_key: Option, } // Accept role_key (string) so the frontend never has to know the internal UUID. #[derive(Deserialize)] pub struct SaveProgressInput { #[serde(rename = "roleKey", alias = "role_key")] pub role_key: String, pub progress_json: Option, } #[derive(Deserialize)] pub struct SubmitInput { #[serde(rename = "roleKey", alias = "role_key")] pub role_key: String, pub progress_json: Option, } #[derive(Serialize)] pub struct ProfileStatusResponse { pub onboarding_complete: bool, pub active_role: Option, pub roles: Vec, pub email_verified: bool, } // ── Handlers ────────────────────────────────────────────────────────────────── /// GET /api/onboarding/state?roleKey=COMPANY async fn get_state( auth: AuthUser, State(state): State, Query(query): Query, ) -> Result { let role_key = query .role_key .filter(|k| !k.is_empty()) .unwrap_or_else(|| auth.claims.active_role.clone()); let role = RoleRepository::get_by_key(&state.pool, &role_key) .await .map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", role_key)))?; let onboarding_state = OnboardingStateRepository::get(&state.pool, auth.user_id, role.id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let response = match onboarding_state { Some(s) => serde_json::json!({ "status": s.status, "currentStep": s.progress_json .get("step") .and_then(|v| v.as_i64()) .unwrap_or(0), "progress": s.progress_json, "completed_at": s.completed_at, "role_key": role_key, }), None => serde_json::json!({ "status": "NOT_STARTED", "currentStep": 0, "progress": null, "completed_at": null, "role_key": role_key, }), }; Ok((StatusCode::OK, Json(response))) } /// POST /api/onboarding/save-progress /// Body: { roleKey: "PHOTOGRAPHER", progress_json: { step: 2, total: 6, data: {...} } } async fn save_progress( auth: AuthUser, State(state): State, Json(input): Json, ) -> Result { let role = RoleRepository::get_by_key(&state.pool, &input.role_key) .await .map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", input.role_key)))?; let progress = input.progress_json.unwrap_or(serde_json::Value::Object(Default::default())); let saved = OnboardingStateRepository::save_progress( &state.pool, auth.user_id, role.id, &progress, ) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok((StatusCode::OK, Json(saved))) } /// POST /api/onboarding/submit /// Body: { roleKey: "PHOTOGRAPHER", progress_json: { ...all form values... } } async fn submit( auth: AuthUser, State(state): State, Json(input): Json, ) -> Result { let role = RoleRepository::get_by_key(&state.pool, &input.role_key) .await .map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", input.role_key)))?; let progress = input.progress_json.unwrap_or(serde_json::Value::Object(Default::default())); // 1. Complete onboarding state let completed = OnboardingStateRepository::complete( &state.pool, auth.user_id, role.id, &progress, ) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // 2. Create/Update specialized profile based on role let role_key = input.role_key.to_uppercase(); let table_name = match role_key.as_str() { "PHOTOGRAPHER" => Some("photographer_profiles"), "MAKEUP_ARTIST" => Some("makeup_artist_profiles"), "TUTOR" => Some("tutor_profiles"), "JOB_SEEKER" | "JOBSEEKER" => Some("job_seeker_profiles"), _ => None, }; if let Some(tbl) = table_name { let user_role_profile_id = get_or_create_user_role_profile_id( &state.pool, auth.user_id, &role_key, role.id, ) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create user role profile: {}", e)))?; let query = format!( r#" INSERT INTO {} (id, custom_data, status, updated_at) VALUES ($1, $2, 'PENDING', NOW()) ON CONFLICT (id) DO UPDATE SET custom_data = EXCLUDED.custom_data, status = 'PENDING', updated_at = NOW() "#, tbl ); sqlx::query(&query) .bind(user_role_profile_id) .bind(&progress) .execute(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Profile creation error: {}", e)))?; } else if role_key == "COMPANY" { // Simple companies upsert (using basic fields if possible) sqlx::query( r#" INSERT INTO company_profiles (user_id, status, updated_at) VALUES ($1, 'PENDING', NOW()) ON CONFLICT (user_id) DO UPDATE SET status = 'PENDING', updated_at = NOW() "#, ) .bind(auth.user_id) .execute(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Company profile error: {}", e)))?; } // 3. Mark the user_role as PENDING (awaiting admin review of onboarding) sqlx::query( r#" UPDATE user_role_assignments SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2 "#, ) .bind(auth.user_id) .bind(role.id) .execute(&state.pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok((StatusCode::OK, Json(completed))) } /// GET /api/me/profile-status async fn profile_status( auth: AuthUser, State(state): State, ) -> Result { use db::models::user::UserRepository; let user = UserRepository::get_by_id(&state.pool, auth.user_id) .await .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; let roles = UserRepository::get_user_role_keys(&state.pool, auth.user_id) .await .unwrap_or_default(); let active_role = auth.claims.active_role.clone(); let active_role_opt = if active_role.is_empty() { None } else { Some(active_role.clone()) }; let onboarding_complete = if let Some(ref role_key) = active_role_opt { match RoleRepository::get_by_key(&state.pool, role_key).await { Ok(role) => OnboardingStateRepository::is_complete(&state.pool, auth.user_id, role.id) .await .unwrap_or(false), Err(_) => false, } } else { false }; Ok(( StatusCode::OK, Json(ProfileStatusResponse { onboarding_complete, active_role: active_role_opt, roles, email_verified: user.email_verified, }), )) } async fn get_or_create_user_role_profile_id( pool: &sqlx::PgPool, user_id: uuid::Uuid, role_key: &str, _role_id: uuid::Uuid, ) -> Result { if let Some(id) = sqlx::query_scalar::<_, uuid::Uuid>( r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2"#, ) .bind(user_id) .bind(role_key) .fetch_optional(pool) .await? { return Ok(id); } sqlx::query_scalar::<_, uuid::Uuid>( r#" INSERT INTO user_role_profiles (user_id, role_key, status) VALUES ($1, $2, 'DRAFT') ON CONFLICT (user_id, role_key) DO UPDATE SET updated_at = NOW() RETURNING id "#, ) .bind(user_id) .bind(role_key) .fetch_one(pool) .await }