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:
Dionisio Pozo
2026-07-14 09:07:09 +02:00
committed by GitHub
43 changed files with 1873 additions and 94 deletions
+22
View File
@@ -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