Merge pull request #577 from EdouardVanbelle/feat/users-perfs-and-filter-dotfiles
feat: users prefs server side + filter dotfiles + filter shares by resource type
This commit is contained in:
@@ -61,6 +61,14 @@ pub struct UserDto {
|
||||
/// could never claim the share. Round-trips through `/api/auth/me`
|
||||
/// and `PATCH /api/auth/me/profile`.
|
||||
pub notify_on_share: bool,
|
||||
/// Opaque UI preferences bag. Cross-device store for pure UI
|
||||
/// toggles (hide dotfiles, view mode, sidebar collapse, …). The
|
||||
/// server never inspects the contents — this DTO field just echoes
|
||||
/// what was PATCHed via `PATCH /api/auth/me/profile`. Shape is a
|
||||
/// JSON object; the frontend defines the keys it cares about (see
|
||||
/// `frontend/src/lib/stores/preferences.svelte.ts`). Always present
|
||||
/// on the wire; empty bag is `{}`, never `null`.
|
||||
pub ui_preferences: serde_json::Value,
|
||||
}
|
||||
|
||||
impl From<User> for UserDto {
|
||||
@@ -85,6 +93,7 @@ impl From<User> for UserDto {
|
||||
email_verified_at: user.email_verified_at(),
|
||||
preferred_locale: user.preferred_locale().map(str::to_string),
|
||||
notify_on_share: user.notify_on_share(),
|
||||
ui_preferences: user.ui_preferences().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +194,19 @@ pub struct UpdateProfileDto {
|
||||
/// always send.
|
||||
#[serde(default)]
|
||||
pub notify_on_share: Option<bool>,
|
||||
/// Partial patch into the opaque UI preferences bag. **Must be a
|
||||
/// JSON object.** Applied via a SHALLOW merge on the server:
|
||||
/// keys present here overwrite existing top-level keys; keys not
|
||||
/// present survive. A key value of `null` REMOVES that key from
|
||||
/// the bag (implemented via `jsonb_strip_nulls` after the merge).
|
||||
///
|
||||
/// Example: current bag `{"a":1,"b":2}`, patch `{"b":3,"c":4}`
|
||||
/// → merged `{"a":1,"b":3,"c":4}`. Patch `{"a":null}` → `{"b":2}`.
|
||||
///
|
||||
/// Absent → no change to the bag. This is a UI-only surface;
|
||||
/// server never inspects the keys.
|
||||
#[serde(default)]
|
||||
pub ui_preferences: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -1542,12 +1542,51 @@ impl AuthApplicationService {
|
||||
changed.push("notify_on_share");
|
||||
}
|
||||
|
||||
if changed.is_empty() {
|
||||
// ── UI preferences shallow-merge ──────────────────────────
|
||||
// The other fields above modify the in-memory `user` and land
|
||||
// via `update_user(user)` at the end. UI preferences take a
|
||||
// different path because the merge has to happen at write
|
||||
// time in SQL — two devices PATCH'ing partial patches
|
||||
// concurrently would otherwise race and clobber each other if
|
||||
// we did merge-then-write in application code. See
|
||||
// `UserPgRepository::update_ui_preferences` for the SQL.
|
||||
//
|
||||
// Boundary validation only: shape must be a JSON object.
|
||||
// Contents are opaque to the server — no key inspection here.
|
||||
// Size cap is enforced by the schema CHECK constraint; a
|
||||
// violating merge surfaces as a repo error.
|
||||
let ui_prefs_patch = if let Some(patch) = dto.ui_preferences.as_ref() {
|
||||
if !patch.is_object() {
|
||||
return Err(DomainError::validation_error(
|
||||
"ui_preferences must be a JSON object".to_string(),
|
||||
));
|
||||
}
|
||||
Some(patch.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if changed.is_empty() && ui_prefs_patch.is_none() {
|
||||
// No-op — return the current user without a DB write.
|
||||
return Ok(UserDto::from(user));
|
||||
}
|
||||
|
||||
let updated = self.user_storage.update_user(user).await?;
|
||||
// Persist the typed-field changes first (if any). Skip the
|
||||
// `update_user` call entirely when only `ui_preferences`
|
||||
// changed — the shallow-merge SQL below is authoritative for
|
||||
// that field, and running `update_user` unnecessarily would
|
||||
// rewrite every column with its current in-memory value.
|
||||
if !changed.is_empty() {
|
||||
self.user_storage.update_user(user).await?;
|
||||
}
|
||||
|
||||
if let Some(patch) = ui_prefs_patch {
|
||||
self.user_storage
|
||||
.update_ui_preferences(caller_id, &patch)
|
||||
.await?;
|
||||
changed.push("ui_preferences");
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.profile_updated",
|
||||
@@ -1556,7 +1595,11 @@ impl AuthApplicationService {
|
||||
"👤 profile updated for {}",
|
||||
caller_id,
|
||||
);
|
||||
Ok(UserDto::from(updated))
|
||||
|
||||
// Refetch so the returned DTO reflects the merged JSONB bag
|
||||
// (the in-memory `user` above holds the pre-merge value).
|
||||
let refreshed = self.user_storage.get_user_by_id(caller_id).await?;
|
||||
Ok(UserDto::from(refreshed))
|
||||
}
|
||||
|
||||
// Alias for consistency with handler method
|
||||
|
||||
@@ -107,6 +107,24 @@ pub struct User {
|
||||
/// and opts out, subsequent shares from other granters honor the
|
||||
/// flag.
|
||||
notify_on_share: bool,
|
||||
/// Opaque UI preferences bag (PR — this session). Stored as JSONB
|
||||
/// on `auth.users.ui_preferences`; the server NEVER inspects the
|
||||
/// contents. This is the SPA's cross-device backing store for pure
|
||||
/// UI toggles (hide-dotfiles, view mode, sidebar collapse, …).
|
||||
///
|
||||
/// Merge semantics live in the repo layer: `PATCH /me/profile` does
|
||||
/// a SHALLOW merge via `ui_preferences || $1::jsonb`, so partial
|
||||
/// writes from one device don't clobber keys set on another.
|
||||
///
|
||||
/// Load-bearing rule: if a preference EVER becomes something the
|
||||
/// server reads (like `preferred_locale` did), promote it out of
|
||||
/// this bag into a typed column. Keep this field for UI-only
|
||||
/// toggles.
|
||||
///
|
||||
/// Invariant: always a JSON object (enforced by the schema CHECK
|
||||
/// `users_ui_preferences_is_object`). Empty bag is `{}`, never
|
||||
/// `null` or missing.
|
||||
ui_preferences: serde_json::Value,
|
||||
}
|
||||
|
||||
impl User {
|
||||
@@ -205,6 +223,11 @@ impl User {
|
||||
// `users_notify_on_share` mirrors this for rows reconstructed
|
||||
// from disk without going through `new`.
|
||||
notify_on_share: true,
|
||||
// Empty bag on creation. The SPA writes into it via
|
||||
// `PATCH /me/profile { ui_preferences: {...} }` after
|
||||
// login. Never NULL — the DB CHECK enforces JSON object
|
||||
// shape.
|
||||
ui_preferences: serde_json::json!({}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -249,6 +272,7 @@ impl User {
|
||||
email_verified_at: None,
|
||||
preferred_locale: None,
|
||||
notify_on_share: true,
|
||||
ui_preferences: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +298,10 @@ impl User {
|
||||
email_verified_at: Option<DateTime<Utc>>,
|
||||
preferred_locale: Option<String>,
|
||||
notify_on_share: bool,
|
||||
// Opaque UI-preferences bag. Callers reading from the DB pass
|
||||
// `row.get("ui_preferences")`; tests that don't care can pass
|
||||
// `serde_json::json!({})`.
|
||||
ui_preferences: serde_json::Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -296,6 +324,7 @@ impl User {
|
||||
email_verified_at,
|
||||
preferred_locale,
|
||||
notify_on_share,
|
||||
ui_preferences,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,6 +565,16 @@ impl User {
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Opaque UI preferences bag. Read-only accessor for the DTO
|
||||
/// conversion; mutation goes through the repo's shallow-merge SQL
|
||||
/// (`UserPgRepository::update_ui_preferences`) rather than a
|
||||
/// setter here — the DB is authoritative on the merged state
|
||||
/// because two devices can PATCH concurrently and the merge has
|
||||
/// to happen at write time, not at read time.
|
||||
pub fn ui_preferences(&self) -> &serde_json::Value {
|
||||
&self.ui_preferences
|
||||
}
|
||||
|
||||
/// Claim or change the username. Runs the same validation as the
|
||||
/// constructor — callers must still ensure uniqueness at the repo
|
||||
/// level. Bumps `updated_at`. Used by the post-create profile-edit
|
||||
@@ -722,6 +761,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
serde_json::json!({}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,48 @@ impl UserPgRepository {
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shallow-merge a partial UI-preferences patch into
|
||||
/// `ui_preferences`. The Postgres `||` operator merges top-level
|
||||
/// keys — `{"a":1,"b":2} || {"b":3,"c":4}` → `{"a":1,"b":3,"c":4}`,
|
||||
/// which is exactly the semantic PATCH callers want: a partial
|
||||
/// write only touches the keys it mentions, so a preference set on
|
||||
/// one device isn't wiped by a partial write from another.
|
||||
///
|
||||
/// `jsonb_strip_nulls` removes any key whose incoming value is
|
||||
/// null, giving callers a documented delete-a-key path (`PATCH
|
||||
/// {"foo": null}` clears `foo`). Nested nulls inside a value
|
||||
/// object survive — we only strip at the top level via the merge
|
||||
/// result.
|
||||
///
|
||||
/// Not part of the `UserRepository` trait — called directly from
|
||||
/// `AuthApplicationService::update_profile`. Bumps `updated_at`
|
||||
/// so the standard "when did this row change" audits stay useful.
|
||||
///
|
||||
/// The CHECK constraints
|
||||
/// (`users_ui_preferences_is_object` + `_size_cap`) enforce shape
|
||||
/// and cap at the schema layer; a violating patch surfaces as an
|
||||
/// sqlx error and returns to the handler as 400.
|
||||
pub async fn update_ui_preferences(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
patch: &serde_json::Value,
|
||||
) -> UserRepositoryResult<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users
|
||||
SET ui_preferences = jsonb_strip_nulls(ui_preferences || $2::jsonb),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(patch)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl UserRepository for UserPgRepository {
|
||||
@@ -138,10 +180,10 @@ impl UserRepository for UserPgRepository {
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at,
|
||||
preferred_locale, notify_on_share
|
||||
preferred_locale, notify_on_share, ui_preferences
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16, $17, $18, $19, $20
|
||||
$12, $13, $14, $15, $16, $17, $18, $19, $20, $21
|
||||
)
|
||||
RETURNING *
|
||||
"#,
|
||||
@@ -166,6 +208,10 @@ impl UserRepository for UserPgRepository {
|
||||
.bind(user_clone.email_verified_at())
|
||||
.bind(user_clone.preferred_locale())
|
||||
.bind(user_clone.notify_on_share())
|
||||
// ui_preferences bind: always a JSON object. `User::new`
|
||||
// initialises the bag to `{}`; ownership stays with the
|
||||
// repo for shallow-merge writes via `update_ui_preferences`.
|
||||
.bind(user_clone.ui_preferences())
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
@@ -190,7 +236,8 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||
ui_preferences
|
||||
FROM auth.users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -228,6 +275,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
row.get::<serde_json::Value, _>("ui_preferences"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -240,7 +288,8 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||
ui_preferences
|
||||
FROM auth.users
|
||||
WHERE username = $1
|
||||
"#,
|
||||
@@ -278,6 +327,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
row.get::<serde_json::Value, _>("ui_preferences"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -290,7 +340,8 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||
ui_preferences
|
||||
FROM auth.users
|
||||
WHERE email = $1
|
||||
"#,
|
||||
@@ -328,6 +379,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
row.get::<serde_json::Value, _>("ui_preferences"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -347,7 +399,8 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||
ui_preferences
|
||||
FROM auth.users
|
||||
WHERE id = ANY($1)
|
||||
"#,
|
||||
@@ -387,6 +440,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
row.get::<serde_json::Value, _>("ui_preferences"),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
@@ -514,7 +568,8 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||
ui_preferences
|
||||
FROM auth.users
|
||||
WHERE ($3 OR is_external = FALSE)
|
||||
ORDER BY created_at DESC
|
||||
@@ -559,6 +614,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
row.get::<serde_json::Value, _>("ui_preferences"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -580,7 +636,8 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||
ui_preferences
|
||||
FROM auth.users
|
||||
WHERE (username ILIKE $1 OR email ILIKE $1)
|
||||
AND ($3 OR is_external = FALSE)
|
||||
@@ -625,6 +682,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
row.get::<serde_json::Value, _>("ui_preferences"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -712,7 +770,8 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||
ui_preferences
|
||||
FROM auth.users
|
||||
WHERE role::text = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -754,6 +813,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
row.get::<serde_json::Value, _>("ui_preferences"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -790,7 +850,8 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||
ui_preferences
|
||||
FROM auth.users
|
||||
WHERE oidc_provider = $1 AND oidc_subject = $2
|
||||
"#,
|
||||
@@ -828,6 +889,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
row.get::<serde_json::Value, _>("ui_preferences"),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user