use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[serde(rename_all = "lowercase")] #[sqlx(type_name = "user_role", rename_all = "lowercase")] pub enum UserRole { Guest, Host, Admin, } impl UserRole { pub fn as_str(&self) -> &'static str { match self { UserRole::Guest => "guest", UserRole::Host => "host", UserRole::Admin => "admin", } } } // Row shape for `user`: every field is populated by sqlx from `SELECT *` / `RETURNING *`. // `uploads_hidden`, `failed_pin_attempts` and `created_at` are enforced/updated in SQL rather than // read in Rust, but they are part of the row and stay here so the struct keeps mirroring the table. #[allow(dead_code)] #[derive(Debug, sqlx::FromRow)] pub struct User { pub id: Uuid, pub event_id: Uuid, pub display_name: String, pub role: UserRole, pub is_banned: bool, pub uploads_hidden: bool, pub recovery_pin_hash: String, pub total_upload_bytes: i64, pub failed_pin_attempts: i16, pub pin_locked_until: Option>, pub created_at: DateTime, } impl User { pub async fn create( pool: &PgPool, event_id: Uuid, display_name: &str, pin_hash: &str, ) -> Result { sqlx::query_as::<_, Self>( "INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash) VALUES ($1, $2, $3) RETURNING *", ) .bind(event_id) .bind(display_name) .bind(pin_hash) .fetch_one(pool) .await } pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, Self>("SELECT * FROM \"user\" WHERE id = $1") .bind(id) .fetch_optional(pool) .await } pub async fn find_by_event_and_name( pool: &PgPool, event_id: Uuid, display_name: &str, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Self>( "SELECT * FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2)", ) .bind(event_id) .bind(display_name) .fetch_all(pool) .await } pub async fn name_taken( pool: &PgPool, event_id: Uuid, display_name: &str, ) -> Result { let row: (bool,) = sqlx::query_as( "SELECT EXISTS(SELECT 1 FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2))", ) .bind(event_id) .bind(display_name) .fetch_one(pool) .await?; Ok(row.0) } pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result { let row: (i16,) = sqlx::query_as( "UPDATE \"user\" SET failed_pin_attempts = failed_pin_attempts + 1 WHERE id = $1 RETURNING failed_pin_attempts", ) .bind(id) .fetch_one(pool) .await?; Ok(row.0) } pub async fn lock_pin(pool: &PgPool, id: Uuid, until: DateTime) -> Result<(), sqlx::Error> { sqlx::query( "UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1", ) .bind(id) .bind(until) .execute(pool) .await?; Ok(()) } pub async fn reset_pin_attempts(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { sqlx::query( "UPDATE \"user\" SET failed_pin_attempts = 0, pin_locked_until = NULL WHERE id = $1", ) .bind(id) .execute(pool) .await?; Ok(()) } }