135 lines
4.4 KiB
Rust
135 lines
4.4 KiB
Rust
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<DateTime<Utc>>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[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<Option<OnboardingState>, 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<OnboardingState, sqlx::Error> {
|
|
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<OnboardingState, sqlx::Error> {
|
|
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<bool, sqlx::Error> {
|
|
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))
|
|
}
|
|
}
|