use crate::common::errors::DomainError; use crate::domain::entities::user::{User, UserRole}; use chrono::{DateTime, Utc}; use uuid::Uuid; #[derive(Debug, thiserror::Error)] pub enum UserRepositoryError { #[error("User not found: {0}")] NotFound(String), #[error("User already exists: {0}")] AlreadyExists(String), #[error("Database error: {0}")] DatabaseError(String), #[error("Validation error: {0}")] ValidationError(String), #[error("Timeout error: {0}")] Timeout(String), #[error("Operation not allowed: {0}")] OperationNotAllowed(String), } pub type UserRepositoryResult = Result; /// Narrow projection for user-directory tables that do not need secrets, /// profile pictures, or the cross-device UI-preferences document. /// /// The full [`User`] row intentionally carries all of those fields for account /// detail and the system address book. Reusing it for the paginated admin /// table made PostgreSQL detoast and transfer an avatar of up to 512 KiB per /// row, only for the handler to serialize it back to the browser where the /// table never reads it. Keeping the projection explicit prevents a future /// full-row field from silently returning to that hot path. #[derive(Debug, Clone)] pub struct UserListEntry { pub id: Uuid, pub username: Option, pub email: String, pub role: UserRole, pub storage_quota_bytes: i64, pub storage_used_bytes: i64, pub last_login_at: Option>, pub active: bool, pub federation_kind: Option, pub federation_issuer: Option, pub is_external: bool, /// TRUE when `auth.users.password_hash IS NOT NULL` — user has a /// server-verifiable password on file (legacy or admin-set). /// Distinct from `opaque_registered` (which is the zero-knowledge /// envelope): a fully-migrated user carries BOTH — password for /// the fallback / operator flows, envelope for the actual login. /// A user with `has_password = false AND !opaque_registered AND /// federation_issuer IS NULL` is passwordless — the only path in is /// via magic-link (or, for externals, whatever grant they hold). pub has_password: bool, /// TRUE when `auth.users.opaque_envelope IS NOT NULL` — the user /// has completed OPAQUE registration (typically via the Phase 2 /// silent-migration hook after a successful legacy login). Surfaced /// on the admin user table so operators can see rollout progress /// per-user. Admin-only exposure — see `AdminUserSummaryDto`. pub opaque_registered: bool, /// TRUE when `auth.users.opaque_migrated_at IS NOT NULL` — the /// user has completed at least one successful OPAQUE login. Distinct /// from `opaque_registered` because a user can have an envelope on /// file without having actually logged in via OPAQUE yet (e.g. /// admin cleared the envelope, silent-migration hasn't re-run). pub opaque_migrated: bool, /// Optional avatar payload (base64, up to 512 KiB per row). Included /// on the admin list projection so the SPA can seed its per-user /// `resolveUser` cache from the list row and skip the follow-up /// `/api/users/{id}` fetch UserVignette would otherwise trigger. /// The narrow-projection concern that motivated omitting this /// column originally is retired by that cache-seeding path — the /// bytes now do useful work per page load instead of being /// discarded. Deferred: moving avatar storage out of the row /// entirely (planned refactor); this shape is transitional. pub image: Option, /// Presence signal — TRUE when the server observed a request on /// any of this user's non-revoked sessions within the last /// [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW) /// (5 min). Populated via an `EXISTS(...)` subquery on /// `auth.sessions` in the list projection — the partial index /// `idx_sessions_last_seen_at WHERE revoked = FALSE` covers the /// scan, so per-row cost is ~μs. Surfaces to the FE via /// `UserDto::is_online` so both `/api/users/{id}` and the admin /// listing carry it, and the admin table renders a green/grey /// presence dot next to each vignette. pub is_online: bool, } /// DB-computed booleans about a user that aren't fields on the /// [`User`](crate::domain::entities::user::User) entity itself — /// either derived from column presence (`password_hash IS NOT NULL`) /// or from a cross-table lookup (`auth.sessions.last_seen_at` for /// `is_online`). Companion to `User` on the list projection: the /// repo computes both, the application layer packs them into /// [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto). /// /// Not "admin-only" — every field ends up on `FullUserDto`, which /// both admin AND self read. The name reflects "derived from the DB /// row, not intrinsic to the User entity". /// /// See `docs/plan/userdto-refactor.md` for the phasing that /// introduces this type; it will replace [`UserListEntry`] once the /// list repo is switched from narrow projection to /// `Vec<(User, UserDerivedFlags)>` (P6 of the refactor). #[derive(Debug, Clone, Copy)] pub struct UserDerivedFlags { pub has_password: bool, pub opaque_registered: bool, pub opaque_migrated: bool, pub is_online: bool, } // Conversion from UserRepositoryError to DomainError impl From for DomainError { fn from(err: UserRepositoryError) -> Self { match err { UserRepositoryError::NotFound(msg) => DomainError::not_found("User", msg), UserRepositoryError::AlreadyExists(msg) => DomainError::already_exists("User", msg), UserRepositoryError::DatabaseError(msg) => DomainError::internal_error("Database", msg), UserRepositoryError::ValidationError(msg) => DomainError::validation_error(msg), UserRepositoryError::Timeout(msg) => DomainError::timeout("Database", msg), UserRepositoryError::OperationNotAllowed(msg) => { DomainError::access_denied("User", msg) } } } } pub trait UserRepository: Send + Sync + 'static { /// Creates a new user async fn create_user(&self, user: User) -> UserRepositoryResult; /// Gets a user by ID async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult; /// Batch-loads a set of users by id, preserving no particular order /// and silently skipping ids that don't match any row. Caller is /// responsible for de-duplicating the input vec. Returns an empty /// vec when given an empty input. Used by group-recipient expansion /// in `RecipientNotificationService` to avoid N+1 queries. async fn get_users_by_ids(&self, ids: Vec) -> UserRepositoryResult>; /// Gets a user by username async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult; /// Gets a user by email async fn get_user_by_email(&self, email: &str) -> UserRepositoryResult; /// Returns every user whose email normalizes to `normalized_email`. /// /// Normalization matches `common::text::normalize_email_for_link` — /// lowercase + strip `+alias` sub-addressing — so /// `Alice+work@Example.com` and `alice@example.com` collapse to the /// same key. Used by the OIDC auto-link decision tree to detect /// ambiguity: two local rows normalizing to the IdP-returned email /// means we can't safely pick one to auto-link, and the callback /// must refuse (`email_ambiguous`). /// /// Caller passes the already-normalized value; the SQL applies the /// same normalization to the stored side symmetrically so casing /// and `+alias` differences on either side collapse. async fn list_users_by_normalized_email( &self, normalized_email: &str, ) -> UserRepositoryResult>; /// Updates an existing user async fn update_user(&self, user: User) -> UserRepositoryResult; /// Updates only a user's storage usage async fn update_storage_usage( &self, user_id: Uuid, usage_bytes: i64, ) -> UserRepositoryResult<()>; /// Updates the last login date async fn update_last_login(&self, user_id: Uuid) -> UserRepositoryResult<()>; /// Lists users with pagination. /// /// `include_external` controls whether external (grant-only) users /// appear in the result. Default callers should pass `false` so /// external users stay invisible to internal-user surfaces (system /// address book autocomplete, sharee search, etc.). Only the admin /// management UI should request `true`. async fn list_users( &self, limit: i64, offset: i64, include_external: bool, ) -> UserRepositoryResult>; /// Lists the columns needed by compact user-management tables. Unlike /// [`Self::list_users`], this never fetches password hashes, OIDC subjects, /// avatars, names, locale state, or UI preferences. async fn list_user_summaries( &self, limit: i64, offset: i64, include_external: bool, ) -> UserRepositoryResult>; /// Searches users by username or email (SQL ILIKE) with a limit. /// See [`list_users`] for the meaning of `include_external`. async fn search_users( &self, query: &str, limit: i64, include_external: bool, ) -> UserRepositoryResult>; /// Activates or deactivates a user async fn set_user_active_status(&self, user_id: Uuid, active: bool) -> UserRepositoryResult<()>; /// Changes a user's password async fn change_password(&self, user_id: Uuid, password_hash: &str) -> UserRepositoryResult<()>; /// Changes a user's role async fn change_role(&self, user_id: Uuid, role: UserRole) -> UserRepositoryResult<()>; /// Lists users by role (admin or user) async fn list_users_by_role(&self, role: &str) -> UserRepositoryResult>; /// Counts users with a given role via a scalar `COUNT(*)` — no row /// hydration (benches/ROUND29.md §G). async fn count_users_by_role(&self, role: &str) -> UserRepositoryResult; /// Deletes a user async fn delete_user(&self, user_id: Uuid) -> UserRepositoryResult<()>; /// Finds a user by federation (issuer, subject) pair. async fn get_user_by_federation_subject( &self, issuer: &str, subject: &str, ) -> UserRepositoryResult; /// Updates a user's storage quota async fn update_storage_quota( &self, user_id: Uuid, quota_bytes: i64, ) -> UserRepositoryResult<()>; /// Counts the total number of users async fn count_users(&self) -> UserRepositoryResult; /// Gets aggregated storage statistics async fn get_storage_stats(&self) -> UserRepositoryResult; } /// Aggregated storage statistics #[derive(Debug, Clone)] pub struct StorageStats { pub total_users: i64, pub active_users: i64, pub total_quota_bytes: i64, pub total_used_bytes: i64, pub users_over_80_percent: i64, pub users_over_quota: i64, }