Merge pull request #641 from EdouardVanbelle/refactor/front-resource-list

feat(fileDto, folderDto): add is_favorite + is_shared
This commit is contained in:
Dionisio Pozo
2026-07-22 06:51:28 +02:00
committed by GitHub
50 changed files with 1140 additions and 287 deletions
+8
View File
@@ -132,6 +132,14 @@ pub struct FavoriteResourceRow {
pub created_by: Option<Uuid>,
/// §14 provenance — who last touched the row.
pub updated_by: Option<Uuid>,
/// Caller-scoped favorite state — `TRUE` by construction on this
/// listing (every row IS a favorite). The listing SQL hardcodes
/// `TRUE AS is_favorite`; this field stays here so the DTO builder
/// signature stays symmetric with the other listings.
pub is_favorite: bool,
/// Resource-scoped: `true` when the row has any `storage.role_grants`
/// entry. Computed by a per-row `EXISTS` in the listing SQL.
pub is_shared: bool,
/// `true` when `owner_id == requesting user_id`.
pub is_owner: bool,
pub favorited_at: DateTime<Utc>,
+26
View File
@@ -83,6 +83,25 @@ pub struct FileDto {
/// stub/legacy files.
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this file. **Wire contract: always present**, never null and
/// never absent — the SPA reads it as a required `boolean` with no
/// nullish branch. Every emission path (listing endpoints inline
/// via a per-row `EXISTS` in the listing SQL; single-item endpoints
/// via the shared `caller_flags` helper on the favorites port) is
/// responsible for populating this before the DTO reaches the
/// wire. WebDAV/CalDAV/CardDAV DTOs default to `false` — the XML
/// property serializer drops the field entirely, so a stale
/// default is never observable on those surfaces.
pub is_favorite: bool,
/// Resource-scoped: `true` when the file has ANY explicit
/// role-grant on it (link share via `subject_type = 'token'`,
/// user/group grant, any role). "Someone was given access to
/// this beyond drive membership." Same wire contract as
/// `is_favorite` — always present.
pub is_shared: bool,
}
impl From<File> for FileDto {
@@ -132,6 +151,11 @@ impl From<File> for FileDto {
etag,
created_by: parts.created_by,
updated_by: parts.updated_by,
// `From<File>` has no caller context. Callers that will
// emit the DTO to the SPA MUST override these before
// Json emission via the `caller_flags` helper.
is_favorite: false,
is_shared: false,
}
}
}
@@ -189,6 +213,8 @@ impl FileDto {
sort_date: None,
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
}
}
}
+28
View File
@@ -96,6 +96,17 @@ pub struct FolderDto {
/// stub/legacy folders.
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this folder. See `FileDto::is_favorite` for the full wire
/// contract note (always present, never null; enrichment path
/// covers listing rows via inline `EXISTS` and single-item
/// endpoints via the `caller_flags` helper).
pub is_favorite: bool,
/// Resource-scoped: `true` when the folder has ANY explicit
/// role-grant on it. Same wire contract as `is_favorite`.
pub is_shared: bool,
}
impl From<Folder> for FolderDto {
@@ -127,6 +138,11 @@ impl From<Folder> for FolderDto {
etag,
created_by: parts.created_by,
updated_by: parts.updated_by,
// `From<Folder>` has no caller context. Handlers that
// emit to the SPA MUST override via `caller_flags` before
// Json response.
is_favorite: false,
is_shared: false,
}
}
}
@@ -179,6 +195,8 @@ impl FolderDto {
etag: String::new(),
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
}
}
}
@@ -226,6 +244,16 @@ pub struct FolderResourceRow {
pub created_by: Option<Uuid>,
/// §14 provenance — who last touched the row.
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this row. Populates `FileDto::is_favorite` / `FolderDto::is_favorite`
/// on the listing without a follow-up query. Computed by the
/// per-row `EXISTS` in `list_resources_paged`.
pub is_favorite: bool,
/// Resource-scoped: `true` when the row has any `storage.role_grants`
/// entry — link share (`subject_type = 'token'`), user grant, group
/// grant, or any role. Populates `FileDto::is_shared` /
/// `FolderDto::is_shared`.
pub is_shared: bool,
// Pre-computed sort fields — returned by the SQL for cursor construction.
/// `LOWER(name)` used by `name`/`type` sorts.
pub sort_str: String,
+8
View File
@@ -122,6 +122,14 @@ pub struct RecentResourceRow {
/// consumed by the UI but surfaced for API parity with the other
/// listing endpoints.
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this row. Populates `FileDto::is_favorite` /
/// `FolderDto::is_favorite` on this listing via a per-row
/// `EXISTS` in the SQL.
pub is_favorite: bool,
/// Resource-scoped: `true` when the row has any
/// `storage.role_grants` entry.
pub is_shared: bool,
/// `true` when `owner_id == requesting user_id`.
pub is_owner: bool,
pub accessed_at: DateTime<Utc>,
+7
View File
@@ -77,6 +77,13 @@ pub struct TrashResourceRow {
/// §14 provenance — who last touched the row (includes the trash
/// action itself, which stamps `updated_by = caller_id`).
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the caller has favorited this
/// trashed item.
pub is_favorite: bool,
/// Resource-scoped: `true` when the row has any
/// `storage.role_grants` entry (surviving trash — grants are
/// GC'd by `purge_expired_grants` on the 15-day grace).
pub is_shared: bool,
pub trashed_at: DateTime<Utc>,
pub deletion_date: DateTime<Utc>,
/// Original location path (for folders: `path`; for files: `parent.path || '/' || name`).
+22
View File
@@ -90,4 +90,26 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static {
kinds: Option<&[ResourceKind]>,
reverse: bool,
) -> Result<Vec<FavoriteResourceRow>>;
/// Caller-scoped inline state flags for a single resource — the
/// shared enrichment helper called by every single-item handler
/// that emits `FileDto` / `FolderDto` to the SPA (get / rename /
/// move / upload / delta upload / photos / bulk get by ids).
///
/// Runs one SQL round trip with two `EXISTS` in the SELECT:
/// * `is_favorite` — `EXISTS on auth.user_favorites` for the
/// `(caller_id, resource_id, resource_type)` triple.
/// * `is_shared` — `EXISTS on storage.role_grants` for the
/// `(resource_id, resource_type)` pair, regardless of role /
/// subject_type / granter. Link shares live in `role_grants`
/// as `subject_type = 'token'` under the unified model, so
/// one EXISTS covers link shares + user grants + group grants.
///
/// `resource_type` MUST be `"file"` or `"folder"`.
async fn caller_flags(
&self,
caller_id: Uuid,
resource_type: &str,
resource_id: Uuid,
) -> Result<(bool, bool)>;
}
@@ -50,6 +50,26 @@ impl FavoritesService {
) -> Result<HashSet<String>> {
self.repo.batch_check_favorites(user_id, items).await
}
/// Shared enrichment helper — computes `is_favorite` + `is_shared`
/// for a single resource so single-item handlers (get / rename /
/// move / upload / delta upload / photos / bulk get by ids) can
/// populate the two wire-contract flags on FileDto / FolderDto
/// before Json emission. Delegates straight to the repository
/// port; kept on `FavoritesService` because the port already
/// lives on that service and callers already hold it in DI.
///
/// `resource_type` MUST be `"file"` or `"folder"`.
pub async fn caller_flags(
&self,
caller_id: Uuid,
resource_type: &str,
resource_id: Uuid,
) -> Result<(bool, bool)> {
self.repo
.caller_flags(caller_id, resource_type, resource_id)
.await
}
}
impl FavoritesUseCase for FavoritesService {
+9 -1
View File
@@ -828,7 +828,15 @@ impl FolderService {
// 2. Fetch limit+1 rows so we can detect has_next
let mut rows = self
.folder_storage
.list_resources_paged(pid, limit + 1, cursor.as_ref(), order_by, kinds, reverse)
.list_resources_paged(
pid,
caller_id,
limit + 1,
cursor.as_ref(),
order_by,
kinds,
reverse,
)
.await?;
// 3. Detect has_next, build encoded next cursor
@@ -824,6 +824,7 @@ impl TrashService {
.trash_repository
.list_resources_paged(
&drive_ids,
user_id,
limit + 1,
cursor.as_ref(),
order_by,
@@ -890,6 +891,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
category: intern_display("Folder"),
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
TrashResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -933,6 +936,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
etag,
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
TrashResourceItemDto {
resource_type: ResourceTypeDto::File,
@@ -289,6 +289,53 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
Ok(rows.iter().map(|r| r.get::<String, _>("item_id")).collect())
}
async fn caller_flags(
&self,
caller_id: Uuid,
resource_type: &str,
resource_id: Uuid,
) -> Result<(bool, bool)> {
// One round trip, two per-row EXISTS in the SELECT. Both hit
// covering indexes: `auth.user_favorites` UNIQUE on
// `(user_id, item_id, item_type)` and
// `idx_role_grants_resource` on `(resource_type, resource_id)`.
// Sub-millisecond on hot data.
//
// Positional tuple decode (Dio's pattern) — no per-column
// name lookup, no HashMap. Sqlx's 2-tuple `query_as` decodes
// by index, matching the perf shape used in the
// folder_db_repository listing hot path.
let (is_favorite, is_shared): (bool, bool) = sqlx::query_as(
"SELECT \
EXISTS ( \
SELECT 1 FROM auth.user_favorites \
WHERE user_id = $1 \
AND item_id = $2::text \
AND item_type = $3 \
), \
EXISTS ( \
SELECT 1 FROM storage.role_grants \
WHERE resource_id = $2 \
AND resource_type = $3 \
)",
)
.bind(caller_id)
.bind(resource_id)
.bind(resource_type)
.fetch_one(&*self.db_pool)
.await
.map_err(|e| {
error!("Database error running caller_flags: {}", e);
DomainError::new(
ErrorKind::InternalError,
"CallerFlags",
format!("Failed to compute caller flags: {}", e),
)
})?;
Ok((is_favorite, is_shared))
}
async fn list_resources_paged(
&self,
user_id: Uuid,
@@ -319,6 +366,14 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
NULL::text AS blob_hash,
fld.created_by AS created_by,
fld.updated_by AS updated_by,
-- Every row on this feed IS a favorite by construction —
-- hardcode the flag to skip a per-row EXISTS.
TRUE AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = fld.id
AND g.resource_type = 'folder'
) AS is_shared,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
@@ -352,6 +407,13 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
f.blob_hash,
f.created_by AS created_by,
f.updated_by AS updated_by,
-- Every row on this feed IS a favorite by construction.
TRUE AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = f.id
AND g.resource_type = 'file'
) AS is_shared,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
@@ -532,6 +594,7 @@ SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_favorite, r.is_shared,
r.is_owner, r.favorited_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r
@@ -607,6 +670,8 @@ LIMIT $6"
blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(),
is_favorite: row.try_get("is_favorite").unwrap_or(true),
is_shared: row.try_get("is_shared").unwrap_or(false),
is_owner: row.try_get("is_owner").unwrap_or(false),
favorited_at: row.get("favorited_at"),
path: row.try_get("resource_path").ok(),
@@ -22,6 +22,8 @@ type MediaFileRow = (
String, // blob_hash
Option<Uuid>, // created_by (§14 provenance)
Option<Uuid>, // updated_by (§14 provenance)
bool, // is_favorite (caller-scoped EXISTS on user_favorites)
bool, // is_shared (resource-scoped EXISTS on role_grants)
i64, // sort_date
Option<i32>, // width
Option<i32>, // height
@@ -522,7 +524,19 @@ impl FileBlobReadRepository {
caller_id: Uuid,
before: Option<i64>,
limit: i64,
) -> Result<(Vec<File>, Vec<i64>, Vec<(Option<i32>, Option<i32>)>), DomainError> {
) -> Result<
(
Vec<File>,
Vec<i64>,
Vec<(Option<i32>, Option<i32>)>,
// Per-row caller flags (is_favorite, is_shared). Aligned
// with `files` — zip 1:1. Kept parallel to `sort_dates` /
// `dims` instead of on the File entity so the domain
// stays caller-agnostic.
Vec<(bool, bool)>,
),
DomainError,
> {
// Sargable keyset cursor: compare the RAW `media_sort_date` column
// against a timestamptz bind so the planner can use the cursor as
// an index boundary condition on `idx_files_media_timeline_by_drive`.
@@ -562,6 +576,17 @@ impl FileBlobReadRepository {
EXTRACT(EPOCH FROM top.updated_at)::bigint,
top.blob_hash,
top.created_by, top.updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $1
AND uf.item_id = top.id::text
AND uf.item_type = 'file'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = top.id
AND g.resource_type = 'file'
) AS is_shared,
EXTRACT(EPOCH FROM top.media_sort_date)::bigint AS sort_date,
fm.width, fm.height
FROM (
@@ -596,16 +621,36 @@ impl FileBlobReadRepository {
let mut files = Vec::with_capacity(rows.len());
let mut sort_dates = Vec::with_capacity(rows.len());
let mut dims = Vec::with_capacity(rows.len());
let mut flags = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, sd, w, h) in rows {
for (
id,
name,
fid,
fpath,
size,
mime,
ca,
ma,
blob_hash,
cb,
ub,
is_fav,
is_shr,
sd,
w,
h,
) in rows
{
files.push(Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub,
)?);
sort_dates.push(sd);
dims.push((w, h));
flags.push((is_fav, is_shr));
}
Ok((files, sort_dates, dims))
Ok((files, sort_dates, dims, flags))
}
/// Aggregate the caller's geotagged photos into grid cells of side `cell`
@@ -1417,9 +1417,11 @@ impl FolderDbRepository {
/// Fetches `limit` rows (caller should pass `desired_page_size + 1` to
/// detect the existence of a next page). Returns raw [`FolderResourceRow`]
/// values; the handler / service layer converts them to DTOs.
#[allow(clippy::too_many_arguments)]
pub async fn list_resources_paged(
&self,
parent_id: Uuid,
caller_id: Uuid,
limit: usize,
cursor: Option<&FolderResourceCursor>,
order_by: &str,
@@ -1448,6 +1450,17 @@ impl FolderDbRepository {
NULL::text AS blob_hash,
f.created_by,
f.updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $7::uuid
AND uf.item_id = f.id::text
AND uf.item_type = 'folder'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = f.id
AND g.resource_type = 'folder'
) AS is_shared,
LOWER(f.name) AS sort_str,
0::bigint AS type_order,
0::int AS folder_first
@@ -1469,6 +1482,17 @@ impl FolderDbRepository {
fm.blob_hash,
fm.created_by,
fm.updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $7::uuid
AND uf.item_id = fm.id::text
AND uf.item_type = 'file'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = fm.id
AND g.resource_type = 'file'
) AS is_shared,
LOWER(fm.name) AS sort_str,
fm.category_order::bigint AS type_order,
1::int AS folder_first
@@ -1660,66 +1684,71 @@ impl FolderDbRepository {
"SELECT resource_type, id, name, folder_id, mime_type, size, \
created_at, modified_at, drive_id, blob_hash, \
created_by, updated_by, \
is_favorite, is_shared, \
sort_str, type_order, folder_first \
FROM ({inner}) r \
{outer_order} \
LIMIT $6"
);
// Row: (resource_type, id, name, folder_id, mime_type, size,
// created_at, modified_at, drive_id, blob_hash,
// created_by, updated_by,
// sort_str, type_order, folder_first)
type Row = (
String,
Uuid,
String,
Option<Uuid>,
Option<String>,
i64,
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
Uuid, // drive_id
Option<String>,
Option<Uuid>, // created_by
Option<Uuid>, // updated_by
String,
i64,
i32,
);
let rows = sqlx::query_as::<_, Row>(&sql)
// 17 columns exceed sqlx's 16-element tuple `FromRow` limit,
// so we can't use `query_as::<_, (T1..T17)>` directly. Instead
// we fetch raw `PgRow`s and decode each column positionally
// into `FolderResourceRow` via `try_get_unchecked(idx)`. This
// matches Dio's positional shape (index decode, no per-row
// column-name HashMap) AND skips the intermediate tuple
// struct + row-to-struct move that a manual `FromRow` impl
// would introduce — one construction, one destination.
//
// Column indices below MUST match the outer `SELECT` list
// above (resource_type, id, name, folder_id, mime_type, size,
// created_at, modified_at, drive_id, blob_hash, created_by,
// updated_by, is_favorite, is_shared, sort_str, type_order,
// folder_first).
let rows = sqlx::query(&sql)
.bind(parent_id)
.bind(cursor_str)
.bind(cursor_int)
.bind(cursor_ts)
.bind(cursor_id)
.bind(limit as i64)
.bind(caller_id)
.fetch_all(self.pool())
.await
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("list_resources_paged: {e}"))
})?;
Ok(rows
.into_iter()
.map(|r| FolderResourceRow {
resource_type: r.0,
id: r.1,
name: r.2,
parent_id: r.3,
mime_type: r.4,
size: r.5,
created_at: r.6,
modified_at: r.7,
drive_id: r.8,
blob_hash: r.9,
created_by: r.10,
updated_by: r.11,
sort_str: r.12,
type_order: r.13,
folder_first: r.14,
use sqlx::Row as _;
let decode_err = |col: usize, e: sqlx::Error| -> DomainError {
DomainError::internal_error(
"FolderDb",
format!("list_resources_paged decode col {col}: {e}"),
)
};
rows.into_iter()
.map(|r| {
Ok(FolderResourceRow {
resource_type: r.try_get_unchecked(0).map_err(|e| decode_err(0, e))?,
id: r.try_get_unchecked(1).map_err(|e| decode_err(1, e))?,
name: r.try_get_unchecked(2).map_err(|e| decode_err(2, e))?,
parent_id: r.try_get_unchecked(3).map_err(|e| decode_err(3, e))?,
mime_type: r.try_get_unchecked(4).map_err(|e| decode_err(4, e))?,
size: r.try_get_unchecked(5).map_err(|e| decode_err(5, e))?,
created_at: r.try_get_unchecked(6).map_err(|e| decode_err(6, e))?,
modified_at: r.try_get_unchecked(7).map_err(|e| decode_err(7, e))?,
drive_id: r.try_get_unchecked(8).map_err(|e| decode_err(8, e))?,
blob_hash: r.try_get_unchecked(9).map_err(|e| decode_err(9, e))?,
created_by: r.try_get_unchecked(10).map_err(|e| decode_err(10, e))?,
updated_by: r.try_get_unchecked(11).map_err(|e| decode_err(11, e))?,
is_favorite: r.try_get_unchecked(12).map_err(|e| decode_err(12, e))?,
is_shared: r.try_get_unchecked(13).map_err(|e| decode_err(13, e))?,
sort_str: r.try_get_unchecked(14).map_err(|e| decode_err(14, e))?,
type_order: r.try_get_unchecked(15).map_err(|e| decode_err(15, e))?,
folder_first: r.try_get_unchecked(16).map_err(|e| decode_err(16, e))?,
})
})
.collect())
.collect()
}
}
@@ -245,6 +245,17 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
NULL::text AS blob_hash,
fld.created_by AS created_by,
fld.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $1::uuid
AND uf.item_id = fld.id::text
AND uf.item_type = 'folder'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = fld.id
AND g.resource_type = 'folder'
) AS is_shared,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
@@ -278,6 +289,17 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
f.blob_hash,
f.created_by AS created_by,
f.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $1::uuid
AND uf.item_id = f.id::text
AND uf.item_type = 'file'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = f.id
AND g.resource_type = 'file'
) AS is_shared,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
@@ -457,6 +479,7 @@ SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_favorite, r.is_shared,
r.is_owner, r.accessed_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r
@@ -536,6 +559,8 @@ LIMIT $6"
blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(),
is_favorite: row.try_get("is_favorite").unwrap_or(false),
is_shared: row.try_get("is_shared").unwrap_or(false),
is_owner: row.try_get("is_owner").unwrap_or(false),
accessed_at: row.get("accessed_at"),
path: row.try_get("resource_path").ok(),
@@ -333,9 +333,11 @@ impl TrashDbRepository {
/// Returns rows in caller-requested sort order. The caller is expected to
/// fetch `limit + 1` to detect end-of-results. Empty `drive_ids` returns
/// an empty page without hitting PG.
#[allow(clippy::too_many_arguments)]
pub async fn list_resources_paged(
&self,
drive_ids: &[Uuid],
caller_id: Uuid,
limit: usize,
cursor: Option<&TrashCursor>,
order_by: &str,
@@ -369,6 +371,17 @@ impl TrashDbRepository {
NULL::text AS blob_hash,
fld.created_by AS created_by,
fld.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $8::uuid
AND uf.item_id = fld.id::text
AND uf.item_type = 'folder'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = fld.id
AND g.resource_type = 'folder'
) AS is_shared,
fld.trashed_at AS trashed_at,
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
fld.path::text AS resource_path,
@@ -397,6 +410,17 @@ impl TrashDbRepository {
f.blob_hash,
f.created_by AS created_by,
f.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $8::uuid
AND uf.item_id = f.id::text
AND uf.item_type = 'file'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = f.id
AND g.resource_type = 'file'
) AS is_shared,
f.trashed_at AS trashed_at,
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
@@ -527,6 +551,7 @@ SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_favorite, r.is_shared,
r.trashed_at, r.deletion_date, r.resource_path,
r.sort_str, r.type_order, r.folder_first
FROM resources r
@@ -543,6 +568,7 @@ LIMIT $6"
.bind(cur_id) // $5
.bind(limit as i64) // $6
.bind(self.retention_days as i32) // $7
.bind(caller_id) // $8 — favorites EXISTS on the branch SELECTs
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
@@ -588,6 +614,8 @@ LIMIT $6"
blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(),
is_favorite: row.try_get("is_favorite").unwrap_or(false),
is_shared: row.try_get("is_shared").unwrap_or(false),
trashed_at,
deletion_date,
path: row.try_get("resource_path").ok(),
@@ -189,6 +189,14 @@ impl PathResolverService {
// reload through the repo.
created_by: None,
updated_by: None,
// Caller state flags not looked up here — the
// resolver is an internal utility that answers
// existence/type questions, not a wire emission
// path. Callers that emit to the SPA reload
// through the listing repo or the caller_flags
// helper.
is_favorite: false,
is_shared: false,
}))
}
_ => {
@@ -217,6 +225,10 @@ impl PathResolverService {
// §14 provenance not selected by this resolver path
created_by: None,
updated_by: None,
// Caller state flags not looked up here — see
// the folder branch above for rationale.
is_favorite: false,
is_shared: false,
}))
}
}
@@ -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,
@@ -565,6 +565,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);
@@ -462,6 +462,10 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
// §14 provenance not selected by the search result DTO.
created_by: None,
updated_by: None,
// NextCloud search doesn't render the SPA badges; safe
// defaults, dropped by the WebDAV/NC XML property serializer.
is_favorite: false,
is_shared: false,
}
}
@@ -495,6 +499,10 @@ fn folder_dto_from_search(
// §14 provenance not selected by search results.
created_by: None,
updated_by: None,
// See file_dto_from_search: NC/DAV property serializer drops
// these; safe default.
is_favorite: false,
is_shared: false,
}
}
@@ -2401,6 +2401,10 @@ mod tests {
// §14 provenance not relevant to path-mapper tests.
created_by: None,
updated_by: None,
// Caller state flags not read by the WebDAV path mapper;
// Nextcloud DAV surfaces don't render the SPA badges.
is_favorite: false,
is_shared: false,
}
}