use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::{FromRow, PgPool}; use uuid::Uuid; // ── Structs ─────────────────────────────────────────────────────────────────── #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct OnboardingState { pub id: Uuid, pub user_id: Uuid, pub role_id: Uuid, pub status: String, // NOT_STARTED | IN_PROGRESS | COMPLETED pub progress_json: Value, pub completed_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Deserialize)] pub struct SaveProgressPayload { pub role_id: Uuid, pub progress_json: Value, } #[derive(Debug, Deserialize)] pub struct SubmitOnboardingPayload { pub role_id: Uuid, pub progress_json: Value, } // ── Repository ──────────────────────────────────────────────────────────────── pub struct OnboardingStateRepository; impl OnboardingStateRepository { /// Fetch onboarding state for a user + role. Returns None if no record exists yet. pub async fn get( pool: &PgPool, user_id: Uuid, role_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( OnboardingState, r#" SELECT id, user_id, role_id, status, progress_json, completed_at, created_at, updated_at FROM onboarding_states WHERE user_id = $1 AND role_id = $2 "#, user_id, role_id, ) .fetch_optional(pool) .await } /// Upsert progress — inserts a new record or updates progress on conflict. pub async fn save_progress( pool: &PgPool, user_id: Uuid, role_id: Uuid, progress: &Value, ) -> Result { sqlx::query_as!( OnboardingState, r#" INSERT INTO onboarding_states (user_id, role_id, status, progress_json) VALUES ($1, $2, 'IN_PROGRESS', $3) ON CONFLICT (user_id, role_id) DO UPDATE SET status = CASE WHEN onboarding_states.status = 'COMPLETED' THEN 'COMPLETED' ELSE 'IN_PROGRESS' END, progress_json = EXCLUDED.progress_json, updated_at = NOW() RETURNING id, user_id, role_id, status, progress_json, completed_at, created_at, updated_at "#, user_id, role_id, progress, ) .fetch_one(pool) .await } /// Mark onboarding as COMPLETED and freeze the final answers. pub async fn complete( pool: &PgPool, user_id: Uuid, role_id: Uuid, final_answers: &Value, ) -> Result { sqlx::query_as!( OnboardingState, r#" INSERT INTO onboarding_states (user_id, role_id, status, progress_json, completed_at) VALUES ($1, $2, 'COMPLETED', $3, NOW()) ON CONFLICT (user_id, role_id) DO UPDATE SET status = 'COMPLETED', progress_json = EXCLUDED.progress_json, completed_at = NOW(), updated_at = NOW() RETURNING id, user_id, role_id, status, progress_json, completed_at, created_at, updated_at "#, user_id, role_id, final_answers, ) .fetch_one(pool) .await } /// Check whether onboarding is complete for a user+role. Returns false if no record. pub async fn is_complete( pool: &PgPool, user_id: Uuid, role_id: Uuid, ) -> Result { let status = sqlx::query_scalar!( r#" SELECT status FROM onboarding_states WHERE user_id = $1 AND role_id = $2 "#, user_id, role_id, ) .fetch_optional(pool) .await?; Ok(status.map(|s| s == "COMPLETED").unwrap_or(false)) } }