Merge pull request #641 from EdouardVanbelle/refactor/front-resource-list
feat(fileDto, folderDto): add is_favorite + is_shared
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user