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, client_join_id: Option, ) -> Result { sqlx::query_as::<_, Self>( "INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash, client_join_id) VALUES ($1, $2, $3, $4) RETURNING *", ) .bind(event_id) .bind(display_name) .bind(pin_hash) .bind(client_join_id) .fetch_one(pool) .await } /// Look up a join that already succeeded, by the idempotency key its client sent. /// /// The retry path for H16: the account was created but the response never arrived, so the /// client re-sends the same `client_join_id`. Finding a row here means "this join already /// happened" — the caller rotates the PIN and answers with a usable one rather than 409ing /// on a name the caller itself owns. pub async fn find_by_client_join_id( pool: &PgPool, event_id: Uuid, client_join_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Self>( "SELECT * FROM \"user\" WHERE event_id = $1 AND client_join_id = $2", ) .bind(event_id) .bind(client_join_id) .fetch_optional(pool) .await } /// Create a user with an explicit role, in ONE statement. /// /// `create` + a separate `UPDATE ... SET role` is not equivalent: a crash or a pool error /// between the two leaves a GUEST row holding a reserved name, which is exactly the /// poisoned state that bricked admin login — now self-inflicted, and invisible to a /// role-based lookup, so the next login would create yet another. pub async fn create_with_role( pool: &PgPool, event_id: Uuid, display_name: &str, pin_hash: &str, role: UserRole, ) -> Result { sqlx::query_as::<_, Self>( "INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash, role) VALUES ($1, $2, $3, $4) RETURNING *", ) .bind(event_id) .bind(display_name) .bind(pin_hash) .bind(role) .fetch_one(pool) .await } /// The event's admin, looked up BY ROLE. /// /// The name is not the identity and never was. Looking the admin up by `display_name` /// meant any guest who joined as "Admin" first made the lookup miss, and the fallback /// `create` then violated the case-insensitive unique index from migration 007 — a /// permanent 500 on admin login, recoverable only by hand-editing the database. /// /// `ORDER BY created_at` so a database that somehow acquired two admin rows resolves to a /// stable one rather than alternating between them. pub async fn find_admin_for_event( pool: &PgPool, event_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Self>( "SELECT * FROM \"user\" WHERE event_id = $1 AND role = 'admin' ORDER BY created_at ASC LIMIT 1", ) .bind(event_id) .fetch_optional(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) } /// Window after which a failed-PIN streak is forgotten. Matches the lockout duration, so /// "wait out the cooldown" and "start clean" are the same interval to a guest. const PIN_ATTEMPT_DECAY_MINUTES: i64 = 15; /// Record a wrong PIN and return the CURRENT streak length. /// /// The counter decays: before this, it only ever cleared on a successful recovery or after /// a lockout expired, so ordinary typos accumulated across days and a guest could arrive at /// an event already most of the way to being locked out by mistakes made the night before. /// Decay is what makes the raised lock threshold safe rather than merely lenient. pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result { let row: (i16,) = sqlx::query_as( "UPDATE \"user\" SET failed_pin_attempts = CASE WHEN last_failed_pin_at IS NULL OR last_failed_pin_at < NOW() - ($2 || ' minutes')::interval THEN 1 ELSE failed_pin_attempts + 1 END, last_failed_pin_at = NOW() WHERE id = $1 RETURNING failed_pin_attempts", ) .bind(id) .bind(Self::PIN_ATTEMPT_DECAY_MINUTES.to_string()) .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, last_failed_pin_at = NULL WHERE id = $1", ) .bind(id) .execute(pool) .await?; Ok(()) } }