//! CRUD over the `app_users` table (v1.1.8 data-plane user management). //! //! Distinct from `admin_user_repo.rs`: that's the control-plane //! operator table (you / me / the dashboard login). This is the //! script-end-user surface — created by app scripts via `users::*`, //! managed by the dashboard's per-app Users tab. //! //! Identity tuple is `(app_id, id)`; uniqueness is enforced on //! `(app_id, lower(email))` so the same email can exist in different //! apps but not twice in the same app. The repo always filters by //! `app_id` — there is no "get any user" query. Cross-app reads must //! pass two `app_id` values explicitly, which keeps the v1.1.3-style //! cross-app discipline obvious at the call site. //! //! Password hashes go in and come out as opaque Argon2id PHC strings — //! the repo never computes or inspects them; that's `auth.rs`'s job. use async_trait::async_trait; use chrono::{DateTime, Utc}; use picloud_shared::{AppId, AppUserId}; use sqlx::PgPool; #[derive(Debug, thiserror::Error)] pub enum AppUserRepositoryError { #[error("database error: {0}")] Db(#[from] sqlx::Error), #[error("not found: {0}")] NotFound(AppUserId), #[error("email already in use: {0}")] DuplicateEmail(String), } /// Row returned to handlers and the service layer. Never includes the /// password hash — that ships out of [`AppUserCredentials`] on the /// dedicated login lookup, mirroring how `admin_user_repo` splits its /// public row from its credential row. #[derive(Debug, Clone)] pub struct AppUserRow { pub id: AppUserId, pub app_id: AppId, pub email: String, pub display_name: Option, pub email_verified_at: Option>, pub last_login_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } /// Credentials fetched for the login path only. Splitting the hash off /// from the public row makes it obvious in service code which calls /// touch a secret. #[derive(Debug, Clone)] pub struct AppUserCredentials { pub id: AppUserId, pub app_id: AppId, pub email: String, pub password_hash: String, } #[async_trait] pub trait AppUserRepository: Send + Sync { async fn get( &self, app_id: AppId, id: AppUserId, ) -> Result, AppUserRepositoryError>; /// Case-insensitive email lookup, scoped to a single app. async fn find_by_email( &self, app_id: AppId, email: &str, ) -> Result, AppUserRepositoryError>; /// Credentials lookup for the login path. Case-insensitive on email. /// Returns `None` for missing — callers run a timing-flat dummy /// verify on miss to avoid leaking which path was taken. async fn get_credentials_by_email( &self, app_id: AppId, email: &str, ) -> Result, AppUserRepositoryError>; async fn list( &self, app_id: AppId, opts: ListOpts, ) -> Result, AppUserRepositoryError>; async fn create( &self, app_id: AppId, email: &str, password_hash: &str, display_name: Option<&str>, ) -> Result; async fn update_display_name( &self, app_id: AppId, id: AppUserId, display_name: Option<&str>, ) -> Result; async fn update_password_hash( &self, app_id: AppId, id: AppUserId, password_hash: &str, ) -> Result; async fn mark_email_verified( &self, app_id: AppId, id: AppUserId, ) -> Result; async fn touch_last_login( &self, app_id: AppId, id: AppUserId, ) -> Result<(), AppUserRepositoryError>; async fn delete(&self, app_id: AppId, id: AppUserId) -> Result; } /// F-P-012: cursor carries `(created_at, id)` for keyset pagination. /// Without the `id` tiebreaker, two users created at the same instant /// could be skipped or duplicated at a page boundary. #[derive(Debug, Clone, Copy)] pub struct ListCursor { pub created_at: DateTime, pub id: picloud_shared::AppUserId, } impl ListCursor { /// Encode as `_` — what we surface to script-land /// via `users::list`'s `next_cursor` field. #[must_use] pub fn encode(&self) -> String { format!("{}_{}", self.created_at.to_rfc3339(), self.id.into_inner()) } /// Decode the wire format. Returns `None` on any parse error — the /// caller treats that as "start of page" rather than 400'ing on a /// stale cursor from a previous schema. #[must_use] pub fn decode(s: &str) -> Option { let (ts, id) = s.rsplit_once('_')?; let created_at = DateTime::parse_from_rfc3339(ts).ok()?.to_utc(); let id = uuid::Uuid::parse_str(id).ok()?; Some(Self { created_at, id: picloud_shared::AppUserId::from(id), }) } } #[derive(Debug, Clone, Default)] pub struct ListOpts { pub cursor: Option, pub limit: i64, } #[derive(Debug, Clone)] pub struct ListPage { pub items: Vec, pub next_cursor: Option, } pub struct PostgresAppUserRepository { pool: PgPool, } impl PostgresAppUserRepository { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl AppUserRepository for PostgresAppUserRepository { async fn get( &self, app_id: AppId, id: AppUserId, ) -> Result, AppUserRepositoryError> { let row = sqlx::query_as::<_, AppUserRecord>( "SELECT id, app_id, email, display_name, email_verified_at, \ last_login_at, created_at, updated_at \ FROM app_users WHERE app_id = $1 AND id = $2", ) .bind(app_id.into_inner()) .bind(id.into_inner()) .fetch_optional(&self.pool) .await?; Ok(row.map(Into::into)) } async fn find_by_email( &self, app_id: AppId, email: &str, ) -> Result, AppUserRepositoryError> { let row = sqlx::query_as::<_, AppUserRecord>( "SELECT id, app_id, email, display_name, email_verified_at, \ last_login_at, created_at, updated_at \ FROM app_users WHERE app_id = $1 AND lower(email) = lower($2)", ) .bind(app_id.into_inner()) .bind(email) .fetch_optional(&self.pool) .await?; Ok(row.map(Into::into)) } async fn get_credentials_by_email( &self, app_id: AppId, email: &str, ) -> Result, AppUserRepositoryError> { let row = sqlx::query_as::<_, AppUserCredsRecord>( "SELECT id, app_id, email, password_hash \ FROM app_users WHERE app_id = $1 AND lower(email) = lower($2)", ) .bind(app_id.into_inner()) .bind(email) .fetch_optional(&self.pool) .await?; Ok(row.map(Into::into)) } async fn list( &self, app_id: AppId, opts: ListOpts, ) -> Result, AppUserRepositoryError> { let limit = opts.limit.clamp(1, 500); // Fetch one extra to detect whether more pages exist. let rows = if let Some(cursor) = opts.cursor { sqlx::query_as::<_, AppUserRecord>( "SELECT id, app_id, email, display_name, email_verified_at, \ last_login_at, created_at, updated_at \ FROM app_users WHERE app_id = $1 \ AND (created_at, id) < ($2, $3) \ ORDER BY created_at DESC, id DESC LIMIT $4", ) .bind(app_id.into_inner()) .bind(cursor.created_at) .bind(cursor.id.into_inner()) .bind(limit + 1) .fetch_all(&self.pool) .await? } else { sqlx::query_as::<_, AppUserRecord>( "SELECT id, app_id, email, display_name, email_verified_at, \ last_login_at, created_at, updated_at \ FROM app_users WHERE app_id = $1 \ ORDER BY created_at DESC, id DESC LIMIT $2", ) .bind(app_id.into_inner()) .bind(limit + 1) .fetch_all(&self.pool) .await? }; let mut items: Vec = rows.into_iter().map(Into::into).collect(); let next_cursor = if i64::try_from(items.len()).unwrap_or(i64::MAX) > limit { let cursor_row = items.pop().expect("len > limit so there is a row"); Some(ListCursor { created_at: cursor_row.created_at, id: cursor_row.id, }) } else { None }; Ok(ListPage { items, next_cursor }) } async fn create( &self, app_id: AppId, email: &str, password_hash: &str, display_name: Option<&str>, ) -> Result { let res = sqlx::query_as::<_, AppUserRecord>( "INSERT INTO app_users (app_id, email, password_hash, display_name) \ VALUES ($1, $2, $3, $4) \ RETURNING id, app_id, email, display_name, email_verified_at, \ last_login_at, created_at, updated_at", ) .bind(app_id.into_inner()) .bind(email) .bind(password_hash) .bind(display_name) .fetch_one(&self.pool) .await; match res { Ok(row) => Ok(row.into()), Err(sqlx::Error::Database(e)) if e.is_unique_violation() => { Err(AppUserRepositoryError::DuplicateEmail(email.to_string())) } Err(e) => Err(e.into()), } } async fn update_display_name( &self, app_id: AppId, id: AppUserId, display_name: Option<&str>, ) -> Result { let row = sqlx::query_as::<_, AppUserRecord>( "UPDATE app_users SET display_name = $3, updated_at = NOW() \ WHERE app_id = $1 AND id = $2 \ RETURNING id, app_id, email, display_name, email_verified_at, \ last_login_at, created_at, updated_at", ) .bind(app_id.into_inner()) .bind(id.into_inner()) .bind(display_name) .fetch_optional(&self.pool) .await?; row.map(Into::into) .ok_or(AppUserRepositoryError::NotFound(id)) } async fn update_password_hash( &self, app_id: AppId, id: AppUserId, password_hash: &str, ) -> Result { let row = sqlx::query_as::<_, AppUserRecord>( "UPDATE app_users SET password_hash = $3, updated_at = NOW() \ WHERE app_id = $1 AND id = $2 \ RETURNING id, app_id, email, display_name, email_verified_at, \ last_login_at, created_at, updated_at", ) .bind(app_id.into_inner()) .bind(id.into_inner()) .bind(password_hash) .fetch_optional(&self.pool) .await?; row.map(Into::into) .ok_or(AppUserRepositoryError::NotFound(id)) } async fn mark_email_verified( &self, app_id: AppId, id: AppUserId, ) -> Result { let row = sqlx::query_as::<_, AppUserRecord>( "UPDATE app_users SET email_verified_at = NOW(), updated_at = NOW() \ WHERE app_id = $1 AND id = $2 \ RETURNING id, app_id, email, display_name, email_verified_at, \ last_login_at, created_at, updated_at", ) .bind(app_id.into_inner()) .bind(id.into_inner()) .fetch_optional(&self.pool) .await?; row.map(Into::into) .ok_or(AppUserRepositoryError::NotFound(id)) } async fn touch_last_login( &self, app_id: AppId, id: AppUserId, ) -> Result<(), AppUserRepositoryError> { sqlx::query("UPDATE app_users SET last_login_at = NOW() WHERE app_id = $1 AND id = $2") .bind(app_id.into_inner()) .bind(id.into_inner()) .execute(&self.pool) .await?; Ok(()) } async fn delete(&self, app_id: AppId, id: AppUserId) -> Result { let res = sqlx::query("DELETE FROM app_users WHERE app_id = $1 AND id = $2") .bind(app_id.into_inner()) .bind(id.into_inner()) .execute(&self.pool) .await?; Ok(res.rows_affected() > 0) } } #[derive(sqlx::FromRow)] struct AppUserRecord { id: uuid::Uuid, app_id: uuid::Uuid, email: String, display_name: Option, email_verified_at: Option>, last_login_at: Option>, created_at: DateTime, updated_at: DateTime, } impl From for AppUserRow { fn from(r: AppUserRecord) -> Self { Self { id: r.id.into(), app_id: r.app_id.into(), email: r.email, display_name: r.display_name, email_verified_at: r.email_verified_at, last_login_at: r.last_login_at, created_at: r.created_at, updated_at: r.updated_at, } } } #[derive(sqlx::FromRow)] struct AppUserCredsRecord { id: uuid::Uuid, app_id: uuid::Uuid, email: String, password_hash: String, } impl From for AppUserCredentials { fn from(r: AppUserCredsRecord) -> Self { Self { id: r.id.into(), app_id: r.app_id.into(), email: r.email, password_hash: r.password_hash, } } }