Merge pull request #683 from EdouardVanbelle/refactor/userdto

This commit is contained in:
Dionisio Pozo
2026-08-22 23:31:25 +02:00
committed by GitHub
83 changed files with 2484 additions and 1068 deletions
+1 -1
View File
@@ -207,7 +207,7 @@ pub struct User {
/// Owned decomposition of a [`User`] (mirrors `FileParts` / `FolderParts` /
/// `ContactParts`). Lets a consumer MOVE the heap fields out instead of cloning
/// them through the borrowing accessors — notably `image` (a data URI up to
/// 512 KiB) and `ui_preferences` (a JSON tree). See `UserDto::from`
/// 512 KiB) and `ui_preferences` (a JSON tree). See `PublicUserDto::from`
/// (benches/ROUND20.md §A2).
pub struct UserParts {
pub id: Uuid,
+42 -45
View File
@@ -1,6 +1,5 @@
use crate::common::errors::DomainError;
use crate::domain::entities::user::{User, UserRole};
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
@@ -26,49 +25,26 @@ pub enum UserRepositoryError {
pub type UserRepositoryResult<T> = Result<T, UserRepositoryError>;
/// Narrow projection for user-directory tables that do not need secrets,
/// profile pictures, or the cross-device UI-preferences document.
/// 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).
///
/// 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<String>,
pub email: String,
pub role: UserRole,
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
pub federation_kind: Option<String>,
pub federation_issuer: Option<String>,
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).
/// 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 design; this type
/// replaced the earlier `UserListEntry` narrow projection as of P6.
#[derive(Debug, Clone, Copy)]
pub struct UserDerivedFlags {
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,
pub is_online: bool,
}
// Conversion from UserRepositoryError to DomainError
@@ -94,6 +70,20 @@ pub trait UserRepository: Send + Sync + 'static {
/// Gets a user by ID
async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult<User>;
/// Fetch the full `User` entity + the [`UserDerivedFlags`] in a
/// single query. Used by `/api/auth/me` and future admin single-user
/// views — anywhere the caller needs both the row itself AND the
/// derived booleans (`has_password`, OPAQUE flags, `is_online`) to
/// build a [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto)
/// or [`SelfUserDto`](crate::application::dtos::user_dto::SelfUserDto).
/// Single query is cheaper than `get_user_by_id` + separate lookups
/// for OPAQUE state + `is_online`; the EXISTS subquery is cheap
/// thanks to the partial index `idx_sessions_last_seen_at`.
async fn get_user_with_derived_flags(
&self,
id: Uuid,
) -> UserRepositoryResult<(User, UserDerivedFlags)>;
/// 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
@@ -152,15 +142,22 @@ pub trait UserRepository: Send + Sync + 'static {
include_external: bool,
) -> UserRepositoryResult<Vec<User>>;
/// 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(
/// Paginated admin user listing — full `User` entity + the derived
/// booleans (`has_password`, OPAQUE flags, `is_online`) in one wide
/// SELECT. Called by the admin service to build
/// `Vec<FullUserDto>` for `/api/admin/users` without paying two
/// round-trips per row (once for User, once for derived flags).
///
/// Same `include_external` semantics as [`Self::list_users`]:
/// admin management UI passes `true`; every other caller passes
/// `false` so external / grant-only users stay off internal-user
/// surfaces.
async fn list_users_with_derived_flags(
&self,
limit: i64,
offset: i64,
include_external: bool,
) -> UserRepositoryResult<Vec<UserListEntry>>;
) -> UserRepositoryResult<Vec<(User, UserDerivedFlags)>>;
/// Searches users by username or email (SQL ILIKE) with a limit.
/// See [`list_users`] for the meaning of `include_external`.