feat(fileDto, folderDto): add is_favorite + is_shared

- provide is_shared and is_favorite information in DTO, information propagated as badge/buttons per items
      regarding performances I try to be minimalis on SQL to prevent any perf regression

    - remove old set of sharedids and favoriteids (was not functionnal anymore)
    - fix date picker (no past selection) in grant
    - fix contextmenu close on /files section
This commit is contained in:
Edouard Vanbelle
2026-07-22 00:36:14 +02:00
parent b7d5d41c90
commit d9987782c4
50 changed files with 1140 additions and 287 deletions
@@ -0,0 +1,88 @@
//! Shared enrichment helpers that populate the `is_favorite` and
//! `is_shared` wire-contract flags on `FileDto` / `FolderDto` before
//! Json emission.
//!
//! The two functions live here so single-item handlers across
//! `folder_handler`, `file_handler`, `delta_upload_handler`,
//! `photos_handler`, etc. all go through the same path — one place
//! to change if the enrichment strategy ever moves (e.g. batch
//! lookups, background prefetch).
//!
//! Every handler that returns a `FileDto` or `FolderDto` to the SPA
//! MUST call one of these helpers. Handlers that emit only to
//! WebDAV / NextCloud DAV surfaces (which drop these fields via the
//! XML property serializer) can skip enrichment — the default `false`
//! is never observable on those wires.
use std::sync::Arc;
use uuid::Uuid;
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::common::di::AppState as GlobalAppState;
/// Populate the `is_favorite` + `is_shared` flags on a `FolderDto`.
///
/// Silently leaves the flags at their default `false` when the
/// favorites service isn't wired (feature-off) or when the resource
/// id doesn't parse as a UUID — the DTO stays valid on the wire and
/// the misleading-`false` window closes as soon as the next listing
/// refetch runs.
pub async fn enrich_folder_flags(
state: &Arc<GlobalAppState>,
dto: &mut FolderDto,
caller_id: Uuid,
) {
let Some(favs) = state.favorites_service.as_ref() else {
return;
};
let Ok(resource_id) = Uuid::parse_str(&dto.id) else {
return;
};
if let Ok((fav, shr)) = favs.caller_flags(caller_id, "folder", resource_id).await {
dto.is_favorite = fav;
dto.is_shared = shr;
}
}
/// File counterpart of [`enrich_folder_flags`] — see that doc.
pub async fn enrich_file_flags(state: &Arc<GlobalAppState>, dto: &mut FileDto, caller_id: Uuid) {
let Some(favs) = state.favorites_service.as_ref() else {
return;
};
let Ok(resource_id) = Uuid::parse_str(&dto.id) else {
return;
};
if let Ok((fav, shr)) = favs.caller_flags(caller_id, "file", resource_id).await {
dto.is_favorite = fav;
dto.is_shared = shr;
}
}
/// Batch variant: enrich every `FileDto` in a slice with per-item
/// `caller_flags`. Runs the lookups sequentially — for the bulk
/// endpoints (`get_files_by_ids`, `photos_handler`) this is one
/// round trip per item; if that becomes hot on a large fetch, the
/// callsite can be replaced with a single SQL query returning the
/// pairs. Kept simple for now; the DTO is `&mut`, no clones.
pub async fn enrich_file_flags_batch(
state: &Arc<GlobalAppState>,
dtos: &mut [FileDto],
caller_id: Uuid,
) {
for dto in dtos.iter_mut() {
enrich_file_flags(state, dto, caller_id).await;
}
}
/// Batch variant for folders — mirror of [`enrich_file_flags_batch`].
pub async fn enrich_folder_flags_batch(
state: &Arc<GlobalAppState>,
dtos: &mut [FolderDto],
caller_id: Uuid,
) {
for dto in dtos.iter_mut() {
enrich_folder_flags(state, dto, caller_id).await;
}
}
@@ -235,12 +235,18 @@ pub async fn delta_commit(
.await
.map_err(AppError::from)?;
Ok(match outcome {
DeltaCommitOutcome::Done { file, created } => {
DeltaCommitOutcome::Done { mut file, created } => {
let status = if created {
StatusCode::CREATED
} else {
StatusCode::OK
};
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
(status, Json(file)).into_response()
}
DeltaCommitOutcome::StillMissing(still_missing) => (
@@ -219,6 +219,11 @@ pub async fn list_favorites_resources(
category: intern_display("Folder"),
created_by: row.created_by,
updated_by: row.updated_by,
// Every row on this listing is a favorite by
// construction; the listing repo returns
// `TRUE AS is_favorite` unconditionally.
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
FavoritesResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -267,6 +272,8 @@ pub async fn list_favorites_resources(
etag,
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
FavoritesResourceItemDto {
resource_type: ResourceTypeDto::File,
+34 -5
View File
@@ -64,7 +64,15 @@ impl FileHandler {
multipart: Multipart,
) -> impl IntoResponse {
match Self::upload_file_inner(&state, &auth_user, multipart).await {
Ok((file, _blob_hash)) => Self::created_json_response(&file).into_response(),
Ok((mut file, _blob_hash)) => {
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
Self::created_json_response(&file).into_response()
}
Err(response) => response.into_response(),
}
}
@@ -115,7 +123,15 @@ impl FileHandler {
)
.await
{
Ok(file) => Self::created_json_response(&file).into_response(),
Ok(mut file) => {
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
Self::created_json_response(&file).into_response()
}
Err(err) => {
// Anti-enumeration shape: every "caller cannot reach this
// hash" outcome collapses into the same 404 with an
@@ -890,11 +906,16 @@ impl FileHandler {
auth_user: AuthUser,
multipart: Multipart,
) -> impl IntoResponse {
let (file, _) = match Self::upload_file_inner(&state, &auth_user, multipart).await {
let (mut file, _) = match Self::upload_file_inner(&state, &auth_user, multipart).await {
Ok(pair) => pair,
Err(response) => return response.into_response(),
};
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
Self::created_json_response(&file).into_response()
}
@@ -1026,7 +1047,15 @@ impl FileHandler {
.move_file_with_perms(&id, auth_user.id, payload.folder_id)
.await
{
Ok(file) => (StatusCode::OK, Json(file)).into_response(),
Ok(mut file) => {
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
(StatusCode::OK, Json(file)).into_response()
}
Err(err) => AppError::from(err).into_response(),
}
}
+34 -12
View File
@@ -25,6 +25,8 @@ use crate::interfaces::middleware::auth::AuthUser;
type AppState = Arc<FolderService>;
use crate::interfaces::api::handlers::caller_flags::enrich_folder_flags;
/// Handler for folder-related API endpoints
pub struct FolderHandler;
@@ -40,10 +42,11 @@ impl FolderHandler {
/// When parent_id is not provided, the folder is created inside the
/// authenticated user's home folder rather than at the storage root.
pub(super) async fn create_folder_impl(
State(service): State<AppState>,
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Json(mut dto): Json<CreateFolderDto>,
) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
// If no parent_id was supplied, resolve the user's home folder as
// the default parent so the new folder is nested correctly.
if dto.parent_id.is_none() {
@@ -77,7 +80,10 @@ impl FolderHandler {
}
match service.create_folder_with_perms(dto, auth_user.id).await {
Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(),
Ok(mut folder) => {
enrich_folder_flags(&state, &mut folder, auth_user.id).await;
(StatusCode::CREATED, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(),
}
}
@@ -85,12 +91,16 @@ impl FolderHandler {
/// Gets a folder by ID.
/// Validates that the authenticated user owns the folder.
pub(super) async fn get_folder_impl(
State(service): State<AppState>,
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
match service.get_folder_with_perms(&id, auth_user.id).await {
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Ok(mut folder) => {
enrich_folder_flags(&state, &mut folder, auth_user.id).await;
(StatusCode::OK, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(),
}
}
@@ -126,29 +136,37 @@ impl FolderHandler {
/// Renames a folder (ownership enforced).
pub(super) async fn rename_folder_impl(
State(service): State<AppState>,
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<RenameFolderDto>,
) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
match service
.rename_folder_with_perms(&id, dto, auth_user.id)
.await
{
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Ok(mut folder) => {
enrich_folder_flags(&state, &mut folder, auth_user.id).await;
(StatusCode::OK, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(),
}
}
/// Moves a folder to a new parent (ownership enforced).
pub(super) async fn move_folder_impl(
State(service): State<AppState>,
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<MoveFolderDto>,
) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
match service.move_folder_with_perms(&id, dto, auth_user.id).await {
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Ok(mut folder) => {
enrich_folder_flags(&state, &mut folder, auth_user.id).await;
(StatusCode::OK, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(),
}
}
@@ -300,7 +318,7 @@ impl FolderHandler {
tag = "folders"
)]
pub async fn create_folder(
state: State<AppState>,
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
json: Json<CreateFolderDto>,
) -> impl IntoResponse {
@@ -319,7 +337,7 @@ pub async fn create_folder(
tag = "folders"
)]
pub async fn get_folder(
state: State<AppState>,
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
) -> impl IntoResponse {
@@ -355,7 +373,7 @@ pub async fn list_root_folders(
tag = "folders"
)]
pub async fn rename_folder(
state: State<AppState>,
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
json: Json<RenameFolderDto>,
@@ -376,7 +394,7 @@ pub async fn rename_folder(
tag = "folders"
)]
pub async fn move_folder(
state: State<AppState>,
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
json: Json<MoveFolderDto>,
@@ -490,6 +508,8 @@ pub async fn list_folder_resources(
category: intern_display("Folder"),
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -541,6 +561,8 @@ pub async fn list_folder_resources(
etag,
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::File,
+34 -8
View File
@@ -933,19 +933,28 @@ pub async fn list_shared_with_me(
// looking each resolved resource up by id.
let mut items: Vec<SharedWithMeItemDto> = Vec::with_capacity(summaries.len());
// Enrich caller flags on every returned resource DTO. Incoming
// grants pages are typically small (10-50 items), so N sequential
// helper calls is acceptable; the folded-into-SQL treatment
// photos got is overkill here. Follow-up path if this grows
// hot: same LATERAL EXISTS shape in `get_files_by_ids` /
// `get_folders_by_ids`.
for summary in &summaries {
let rid = summary.resource_id.to_string();
match summary.resource_type {
ResourceKind::File => match file_map.get(&rid) {
Some(file_dto) => {
let mut dto = file_dto.clone().without_hierarchy_info();
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state, &mut dto, caller_id,
)
.await;
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::File,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::File(
file_dto.clone().without_hierarchy_info(),
),
resource: ResourceContentDto::File(dto),
});
}
None => warn!(
@@ -955,14 +964,17 @@ pub async fn list_shared_with_me(
},
ResourceKind::Folder => match folder_map.get(&rid) {
Some(folder_dto) => {
let mut dto = folder_dto.clone().without_hierarchy_info();
crate::interfaces::api::handlers::caller_flags::enrich_folder_flags(
&state, &mut dto, caller_id,
)
.await;
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::Folder,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::Folder(
folder_dto.clone().without_hierarchy_info(),
),
resource: ResourceContentDto::Folder(dto),
});
}
None => warn!(
@@ -1209,10 +1221,18 @@ pub async fn list_my_shares(
// Caller is the granter — they had share-access to the
// resource, so the containing hierarchy is already known
// to them. Keep `path` (unlike list_shared_with_me).
let mut dto = file_dto.clone();
// is_shared: TRUE by construction on this feed.
// is_favorite: real EXISTS via the shared helper.
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state, &mut dto, caller_id,
)
.await;
dto.is_shared = true;
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::File,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::File(file_dto.clone()),
resource: ResourceContentDto::File(dto),
grants,
});
}
@@ -1223,10 +1243,16 @@ pub async fn list_my_shares(
},
ResourceKind::Folder => match folder_map.get(&rid) {
Some(folder_dto) => {
let mut dto = folder_dto.clone();
crate::interfaces::api::handlers::caller_flags::enrich_folder_flags(
&state, &mut dto, caller_id,
)
.await;
dto.is_shared = true;
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::Folder,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::Folder(folder_dto.clone()),
resource: ResourceContentDto::Folder(dto),
grants,
});
}
+1
View File
@@ -3,6 +3,7 @@ pub mod app_password_handler;
pub mod auth_handler;
pub mod batch_handler;
pub mod caldav_handler;
pub mod caller_flags;
pub mod carddav_handler;
pub mod chunked_upload_handler;
pub mod contacts_handler;
+11 -3
View File
@@ -75,7 +75,7 @@ pub async fn list_photos(
.list_media_files(caller_id, params.before, limit)
.await
{
Ok((files, sort_dates, dims)) => {
Ok((files, sort_dates, dims, flags)) => {
// Lightweight revalidation ETag: page identity (cursor + limit) plus a
// freshness signal (max modified_at + row count over the page),
// mirroring the file-list endpoint. With `Cache-Control: no-cache` the
@@ -106,14 +106,22 @@ pub async fn list_photos(
info!("Photos: returned {} media files for user", count);
// Convert to DTOs with sort_date + pixel dimensions populated.
// Convert to DTOs with sort_date + pixel dimensions + inline
// caller flags populated. `list_media_files` computes
// `is_favorite` / `is_shared` via two per-row `EXISTS`
// columns in its SELECT — the same pattern the four
// `list_resources_paged` repos use — so this stays a
// single round trip regardless of page size.
let dtos: Vec<PhotoDto> = files
.into_iter()
.zip(sort_dates.iter())
.zip(dims.iter())
.map(|((file, &sd), &(w, h))| {
.zip(flags.iter())
.map(|(((file, &sd), &(w, h)), &(is_fav, is_shr))| {
let mut dto = FileDto::from(file);
dto.sort_date = Some(sd as u64);
dto.is_favorite = is_fav;
dto.is_shared = is_shr;
PhotoDto {
file: dto,
width: w.map(|v| v.max(0) as u32),
@@ -239,6 +239,8 @@ pub async fn list_recent_resources(
category: intern_display("Folder"),
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -285,6 +287,8 @@ pub async fn list_recent_resources(
etag,
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::File,
@@ -560,6 +560,9 @@ async fn handle_propfind(
category: intern_display("Folder"),
created_by: None,
updated_by: None,
// Synthetic root, not on the SPA path — safe default.
is_favorite: false,
is_shared: false,
};
// Skip the 2-query quota resolution when the request's prop list
// never mentions quota (benches/QUOTA-PATH.md).
+13 -4
View File
@@ -206,15 +206,23 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
batch_service: batch_service.clone(),
};
// Create the basic folders router with service operations
// Basic folder listing (no caller-flag enrichment needed — flags
// come from the per-row SQL EXISTS in the listing repo).
let folders_basic_router = Router::new()
.route("/", post(create_folder))
.route("/", get(list_root_folders))
.route("/{id}", get(get_folder))
.route("/{id}/resources", get(list_folder_resources))
.with_state(folder_service.clone());
// Single-item CRUD emits a FolderDto to the SPA and MUST carry
// authoritative `is_favorite` / `is_shared` flags. The impls call
// the `caller_flags` enrichment helper via
// `state.favorites_service`, so the full `AppState` is required.
let folders_crud_router = Router::new()
.route("/", post(create_folder))
.route("/{id}", get(get_folder))
.route("/{id}/rename", put(rename_folder))
.route("/{id}/move", put(move_folder))
.with_state(folder_service.clone());
.with_state(app_state.clone());
// Special route for ZIP download that requires AppState instead of just FolderService
let folder_zip_router = Router::new()
@@ -226,6 +234,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// Merge the routers
let folders_router = folders_basic_router
.merge(folders_crud_router)
.merge(folders_ops_router)
.merge(folder_zip_router);