fix(resources): wire missing created_by and updated_by
This commit is contained in:
@@ -95,6 +95,8 @@ fn rows(n: usize) -> Vec<FolderResourceRow> {
|
|||||||
} else {
|
} else {
|
||||||
Some("a".repeat(64))
|
Some("a".repeat(64))
|
||||||
},
|
},
|
||||||
|
created_by: Some(Uuid::new_v4()),
|
||||||
|
updated_by: Some(Uuid::new_v4()),
|
||||||
sort_str: format!("row {i}"),
|
sort_str: format!("row {i}"),
|
||||||
type_order: 0,
|
type_order: 0,
|
||||||
folder_first: if is_folder { 0 } else { 1 },
|
folder_first: if is_folder { 0 } else { 1 },
|
||||||
@@ -262,6 +264,8 @@ fn fav_rows(n: usize) -> Vec<FavoriteResourceRow> {
|
|||||||
} else {
|
} else {
|
||||||
Some("a".repeat(64))
|
Some("a".repeat(64))
|
||||||
},
|
},
|
||||||
|
created_by: Some(Uuid::new_v4()),
|
||||||
|
updated_by: Some(Uuid::new_v4()),
|
||||||
is_owner: true,
|
is_owner: true,
|
||||||
favorited_at: ts,
|
favorited_at: ts,
|
||||||
path: Some(format!("Documents/Work/item-{i:05}")),
|
path: Some(format!("Documents/Work/item-{i:05}")),
|
||||||
|
|||||||
@@ -1570,6 +1570,7 @@
|
|||||||
.rl-ctx-item--disabled {
|
.rl-ctx-item--disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rl-ctx-item--disabled:hover {
|
.rl-ctx-item--disabled:hover {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,4 +74,3 @@ export async function probeFolderAccess(id: string): Promise<boolean> {
|
|||||||
inflight.set(id, p);
|
inflight.set(id, p);
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -366,8 +366,7 @@
|
|||||||
<Button
|
<Button
|
||||||
icon="star-outline"
|
icon="star-outline"
|
||||||
data-testid="favorites-batch-remove-btn"
|
data-testid="favorites-batch-remove-btn"
|
||||||
onclick={() => sel.forEach(unfavorite)}
|
onclick={() => sel.forEach(unfavorite)}>{t('files.unfavorite', 'Remove favorite')}</Button
|
||||||
>{t('files.unfavorite', 'Remove favorite')}</Button
|
|
||||||
>
|
>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ResourceList>
|
</ResourceList>
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFold
|
|||||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
||||||
|
|
||||||
import { fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites';
|
import { fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites';
|
||||||
import { deleteFile } from '$lib/api/endpoints/files';
|
|
||||||
import FavoritesPage from './+page.svelte';
|
import FavoritesPage from './+page.svelte';
|
||||||
|
|
||||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||||
|
|||||||
@@ -48,9 +48,12 @@
|
|||||||
let reversed = $state(false);
|
let reversed = $state(false);
|
||||||
const owners = useOwnerCache(resolveOwnerName);
|
const owners = useOwnerCache(resolveOwnerName);
|
||||||
|
|
||||||
// Envelope shape: `accessed_at` → `ctx.date`, `updated_by` → `ctx.ownerId`
|
// Envelope shape: `accessed_at` → `ctx.date`, `created_by` → `ctx.ownerId`.
|
||||||
// (Recent's provenance semantic — "who touched this recently" — differs
|
// Recent is a per-user view of items the caller accessed; the "who
|
||||||
// from Favorites'/Files' `created_by`).
|
// touched this last" (`updated_by`) semantic is real but adds noise
|
||||||
|
// (mostly the current user), so we align with Files / Favorites and
|
||||||
|
// show the original author instead. Cross-surface consistency wins
|
||||||
|
// over the finer-grained signal.
|
||||||
//
|
//
|
||||||
// Dotfile hiding is delegated to ResourceList via `showDotfileToggle`
|
// Dotfile hiding is delegated to ResourceList via `showDotfileToggle`
|
||||||
// — the component reads `preferences.hideDotfiles` and drops matching
|
// — the component reads `preferences.hideDotfiles` and drops matching
|
||||||
@@ -117,10 +120,10 @@
|
|||||||
raw = reset ? page.items : [...raw, ...page.items];
|
raw = reset ? page.items : [...raw, ...page.items];
|
||||||
primeContextPage(contextMap, reset, page.items, (it) => [
|
primeContextPage(contextMap, reset, page.items, (it) => [
|
||||||
it.resource.id,
|
it.resource.id,
|
||||||
{ date: it.accessed_at, ownerId: it.resource.updated_by ?? null }
|
{ date: it.accessed_at, ownerId: it.resource.created_by ?? null }
|
||||||
]);
|
]);
|
||||||
cursor = page.next_cursor;
|
cursor = page.next_cursor;
|
||||||
void owners.resolve(page.items.map((i) => i.resource.updated_by));
|
void owners.resolve(page.items.map((i) => i.resource.created_by));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('recent: load error', e);
|
console.error('recent: load error', e);
|
||||||
error = t('errors_loadFailed', 'Failed to load items');
|
error = t('errors_loadFailed', 'Failed to load items');
|
||||||
@@ -184,7 +187,7 @@
|
|||||||
raw = [...raw.slice(0, idx), snapshot, ...raw.slice(idx)];
|
raw = [...raw.slice(0, idx), snapshot, ...raw.slice(idx)];
|
||||||
contextMap.set(item.id, {
|
contextMap.set(item.id, {
|
||||||
date: snapshot.accessed_at,
|
date: snapshot.accessed_at,
|
||||||
ownerId: snapshot.resource.updated_by ?? null
|
ownerId: snapshot.resource.created_by ?? null
|
||||||
});
|
});
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFold
|
|||||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
||||||
|
|
||||||
import { fetchRecentPage, clearRecent, removeFromRecent } from '$lib/api/endpoints/recent';
|
import { fetchRecentPage, clearRecent, removeFromRecent } from '$lib/api/endpoints/recent';
|
||||||
import { deleteFile } from '$lib/api/endpoints/files';
|
|
||||||
import RecentPage from './+page.svelte';
|
import RecentPage from './+page.svelte';
|
||||||
|
|
||||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||||
|
|||||||
@@ -300,17 +300,14 @@
|
|||||||
standard action-bar sizing and reads consistently with
|
standard action-bar sizing and reads consistently with
|
||||||
`/recent` and `/favorites` batch clusters.
|
`/recent` and `/favorites` batch clusters.
|
||||||
-->
|
-->
|
||||||
<Button
|
<Button icon="undo" data-testid="trash-batch-restore-btn" onclick={() => sel.forEach(restore)}
|
||||||
icon="undo"
|
>{t('trash.restore', 'Restore')}</Button
|
||||||
data-testid="trash-batch-restore-btn"
|
|
||||||
onclick={() => sel.forEach(restore)}>{t('trash.restore', 'Restore')}</Button
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
icon="trash"
|
icon="trash"
|
||||||
data-testid="trash-batch-delete-btn"
|
data-testid="trash-batch-delete-btn"
|
||||||
onclick={() => sel.forEach(purge)}
|
onclick={() => sel.forEach(purge)}>{t('trash.delete', 'Delete permanently')}</Button
|
||||||
>{t('trash.delete', 'Delete permanently')}</Button
|
|
||||||
>
|
>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet rowBadge(_item, ctx)}
|
{#snippet rowBadge(_item, ctx)}
|
||||||
@@ -414,4 +411,17 @@
|
|||||||
:global(.files-grid-view .file-item .action-cell .btn-action--delete:hover) {
|
:global(.files-grid-view .file-item .action-cell .btn-action--delete:hover) {
|
||||||
color: var(--color-error-text);
|
color: var(--color-error-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* List view: hide the expiry chip that ResourceList paints inside
|
||||||
|
`.file-icon__badge`. In list mode the same info is already in
|
||||||
|
the "Expires at" column (`dateCell` snippet above) — showing
|
||||||
|
the chip on the tiny row icon crops it and duplicates the
|
||||||
|
signal. Grid view keeps the chip: no dedicated column exists
|
||||||
|
there and the badge is the ONLY expiration surface on the
|
||||||
|
card. Scoped to trash because trash is the only section
|
||||||
|
emitting a rowBadge today; if another section starts using it,
|
||||||
|
this rule stays inert for them. */
|
||||||
|
:global(.files-list-view .file-item .file-icon__badge) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -127,6 +127,11 @@ pub struct FavoriteResourceRow {
|
|||||||
/// folder rows. Routes into `FileDto::content_hash` and feeds
|
/// folder rows. Routes into `FileDto::content_hash` and feeds
|
||||||
/// `File::compute_etag` to populate `FileDto::etag`.
|
/// `File::compute_etag` to populate `FileDto::etag`.
|
||||||
pub blob_hash: Option<String>,
|
pub blob_hash: Option<String>,
|
||||||
|
/// §14 provenance — who created the row. `None` when the creator
|
||||||
|
/// was deleted (FK `ON DELETE SET NULL`).
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
/// §14 provenance — who last touched the row.
|
||||||
|
pub updated_by: Option<Uuid>,
|
||||||
/// `true` when `owner_id == requesting user_id`.
|
/// `true` when `owner_id == requesting user_id`.
|
||||||
pub is_owner: bool,
|
pub is_owner: bool,
|
||||||
pub favorited_at: DateTime<Utc>,
|
pub favorited_at: DateTime<Utc>,
|
||||||
|
|||||||
@@ -219,6 +219,13 @@ pub struct FolderResourceRow {
|
|||||||
/// on the REST `/api/folders/{id}/resources` listing so API
|
/// on the REST `/api/folders/{id}/resources` listing so API
|
||||||
/// consumers can issue conditional requests against listed files.
|
/// consumers can issue conditional requests against listed files.
|
||||||
pub blob_hash: Option<String>,
|
pub blob_hash: Option<String>,
|
||||||
|
/// §14 provenance — who created the row. `None` when the creator was
|
||||||
|
/// deleted (FK `ON DELETE SET NULL`). Populates
|
||||||
|
/// `FileDto::created_by` / `FolderDto::created_by` on the listing so
|
||||||
|
/// the UI can render the owner column without a follow-up query.
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
/// §14 provenance — who last touched the row.
|
||||||
|
pub updated_by: Option<Uuid>,
|
||||||
// Pre-computed sort fields — returned by the SQL for cursor construction.
|
// Pre-computed sort fields — returned by the SQL for cursor construction.
|
||||||
/// `LOWER(name)` used by `name`/`type` sorts.
|
/// `LOWER(name)` used by `name`/`type` sorts.
|
||||||
pub sort_str: String,
|
pub sort_str: String,
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ pub struct RecentResourceRow {
|
|||||||
/// folder rows. Feeds `File::compute_etag` so this listing's
|
/// folder rows. Feeds `File::compute_etag` so this listing's
|
||||||
/// `etag` matches GET/HEAD/PROPFIND for the same file.
|
/// `etag` matches GET/HEAD/PROPFIND for the same file.
|
||||||
pub blob_hash: Option<String>,
|
pub blob_hash: Option<String>,
|
||||||
|
/// §14 provenance — who created the row. `None` when the creator
|
||||||
|
/// was deleted (FK `ON DELETE SET NULL`). Powers the owner column
|
||||||
|
/// on the `/recent` UI (aligned with `/files` and `/favorites`
|
||||||
|
/// for cross-surface consistency, rather than the finer-grained
|
||||||
|
/// but noisier "who touched this last" signal).
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
/// §14 provenance — who last touched the row. Not currently
|
||||||
|
/// consumed by the UI but surfaced for API parity with the other
|
||||||
|
/// listing endpoints.
|
||||||
|
pub updated_by: Option<Uuid>,
|
||||||
/// `true` when `owner_id == requesting user_id`.
|
/// `true` when `owner_id == requesting user_id`.
|
||||||
pub is_owner: bool,
|
pub is_owner: bool,
|
||||||
pub accessed_at: DateTime<Utc>,
|
pub accessed_at: DateTime<Utc>,
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ pub struct TrashResourceRow {
|
|||||||
/// same file (restorable trash items are conditional-request
|
/// same file (restorable trash items are conditional-request
|
||||||
/// targets too).
|
/// targets too).
|
||||||
pub blob_hash: Option<String>,
|
pub blob_hash: Option<String>,
|
||||||
|
/// §14 provenance — who created the row. `None` when the creator
|
||||||
|
/// was deleted (FK `ON DELETE SET NULL`).
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
/// §14 provenance — who last touched the row (includes the trash
|
||||||
|
/// action itself, which stamps `updated_by = caller_id`).
|
||||||
|
pub updated_by: Option<Uuid>,
|
||||||
pub trashed_at: DateTime<Utc>,
|
pub trashed_at: DateTime<Utc>,
|
||||||
pub deletion_date: DateTime<Utc>,
|
pub deletion_date: DateTime<Utc>,
|
||||||
/// Original location path (for folders: `path`; for files: `parent.path || '/' || name`).
|
/// Original location path (for folders: `path`; for files: `parent.path || '/' || name`).
|
||||||
|
|||||||
@@ -888,9 +888,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
|||||||
icon_class: intern_display("fas fa-folder"),
|
icon_class: intern_display("fas fa-folder"),
|
||||||
icon_special_class: intern_display("folder-icon"),
|
icon_special_class: intern_display("folder-icon"),
|
||||||
category: intern_display("Folder"),
|
category: intern_display("Folder"),
|
||||||
// §14 provenance not selected by the trash listing query.
|
created_by: row.created_by,
|
||||||
created_by: None,
|
updated_by: row.updated_by,
|
||||||
updated_by: None,
|
|
||||||
};
|
};
|
||||||
TrashResourceItemDto {
|
TrashResourceItemDto {
|
||||||
resource_type: ResourceTypeDto::Folder,
|
resource_type: ResourceTypeDto::Folder,
|
||||||
@@ -932,9 +931,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
|||||||
sort_date: None,
|
sort_date: None,
|
||||||
content_hash,
|
content_hash,
|
||||||
etag,
|
etag,
|
||||||
// §14 provenance not selected by the trash listing query.
|
created_by: row.created_by,
|
||||||
created_by: None,
|
updated_by: row.updated_by,
|
||||||
updated_by: None,
|
|
||||||
};
|
};
|
||||||
TrashResourceItemDto {
|
TrashResourceItemDto {
|
||||||
resource_type: ResourceTypeDto::File,
|
resource_type: ResourceTypeDto::File,
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
|||||||
fld.drive_id AS drive_id,
|
fld.drive_id AS drive_id,
|
||||||
NULL::text AS blob_hash,
|
NULL::text AS blob_hash,
|
||||||
fld.created_by AS created_by,
|
fld.created_by AS created_by,
|
||||||
|
fld.updated_by AS updated_by,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM storage.role_grants g
|
SELECT 1 FROM storage.role_grants g
|
||||||
WHERE g.resource_type = 'drive'
|
WHERE g.resource_type = 'drive'
|
||||||
@@ -350,6 +351,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
|||||||
f.drive_id AS drive_id,
|
f.drive_id AS drive_id,
|
||||||
f.blob_hash,
|
f.blob_hash,
|
||||||
f.created_by AS created_by,
|
f.created_by AS created_by,
|
||||||
|
f.updated_by AS updated_by,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM storage.role_grants g
|
SELECT 1 FROM storage.role_grants g
|
||||||
WHERE g.resource_type = 'drive'
|
WHERE g.resource_type = 'drive'
|
||||||
@@ -529,7 +531,8 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
|||||||
SELECT
|
SELECT
|
||||||
r.resource_type, r.resource_id, r.name, r.parent_id,
|
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||||
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||||
r.drive_id, r.is_owner, r.favorited_at, r.resource_path,
|
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
|
||||||
|
r.is_owner, r.favorited_at, r.resource_path,
|
||||||
r.sort_str, r.type_order, r.folder_first{username_col}
|
r.sort_str, r.type_order, r.folder_first{username_col}
|
||||||
FROM resources r
|
FROM resources r
|
||||||
{user_join}
|
{user_join}
|
||||||
@@ -602,6 +605,8 @@ LIMIT $6"
|
|||||||
modified_at: row.get("modified_at"),
|
modified_at: row.get("modified_at"),
|
||||||
drive_id: row.get("drive_id"),
|
drive_id: row.get("drive_id"),
|
||||||
blob_hash: row.try_get("blob_hash").ok(),
|
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_owner: row.try_get("is_owner").unwrap_or(false),
|
is_owner: row.try_get("is_owner").unwrap_or(false),
|
||||||
favorited_at: row.get("favorited_at"),
|
favorited_at: row.get("favorited_at"),
|
||||||
path: row.try_get("resource_path").ok(),
|
path: row.try_get("resource_path").ok(),
|
||||||
|
|||||||
@@ -1446,6 +1446,8 @@ impl FolderDbRepository {
|
|||||||
f.updated_at AS modified_at,
|
f.updated_at AS modified_at,
|
||||||
f.drive_id,
|
f.drive_id,
|
||||||
NULL::text AS blob_hash,
|
NULL::text AS blob_hash,
|
||||||
|
f.created_by,
|
||||||
|
f.updated_by,
|
||||||
LOWER(f.name) AS sort_str,
|
LOWER(f.name) AS sort_str,
|
||||||
0::bigint AS type_order,
|
0::bigint AS type_order,
|
||||||
0::int AS folder_first
|
0::int AS folder_first
|
||||||
@@ -1465,6 +1467,8 @@ impl FolderDbRepository {
|
|||||||
fm.updated_at AS modified_at,
|
fm.updated_at AS modified_at,
|
||||||
fm.drive_id,
|
fm.drive_id,
|
||||||
fm.blob_hash,
|
fm.blob_hash,
|
||||||
|
fm.created_by,
|
||||||
|
fm.updated_by,
|
||||||
LOWER(fm.name) AS sort_str,
|
LOWER(fm.name) AS sort_str,
|
||||||
fm.category_order::bigint AS type_order,
|
fm.category_order::bigint AS type_order,
|
||||||
1::int AS folder_first
|
1::int AS folder_first
|
||||||
@@ -1655,6 +1659,7 @@ impl FolderDbRepository {
|
|||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT resource_type, id, name, folder_id, mime_type, size, \
|
"SELECT resource_type, id, name, folder_id, mime_type, size, \
|
||||||
created_at, modified_at, drive_id, blob_hash, \
|
created_at, modified_at, drive_id, blob_hash, \
|
||||||
|
created_by, updated_by, \
|
||||||
sort_str, type_order, folder_first \
|
sort_str, type_order, folder_first \
|
||||||
FROM ({inner}) r \
|
FROM ({inner}) r \
|
||||||
{outer_order} \
|
{outer_order} \
|
||||||
@@ -1663,6 +1668,7 @@ impl FolderDbRepository {
|
|||||||
|
|
||||||
// Row: (resource_type, id, name, folder_id, mime_type, size,
|
// Row: (resource_type, id, name, folder_id, mime_type, size,
|
||||||
// created_at, modified_at, drive_id, blob_hash,
|
// created_at, modified_at, drive_id, blob_hash,
|
||||||
|
// created_by, updated_by,
|
||||||
// sort_str, type_order, folder_first)
|
// sort_str, type_order, folder_first)
|
||||||
type Row = (
|
type Row = (
|
||||||
String,
|
String,
|
||||||
@@ -1675,6 +1681,8 @@ impl FolderDbRepository {
|
|||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
Uuid, // drive_id
|
Uuid, // drive_id
|
||||||
Option<String>,
|
Option<String>,
|
||||||
|
Option<Uuid>, // created_by
|
||||||
|
Option<Uuid>, // updated_by
|
||||||
String,
|
String,
|
||||||
i64,
|
i64,
|
||||||
i32,
|
i32,
|
||||||
@@ -1706,9 +1714,11 @@ impl FolderDbRepository {
|
|||||||
modified_at: r.7,
|
modified_at: r.7,
|
||||||
drive_id: r.8,
|
drive_id: r.8,
|
||||||
blob_hash: r.9,
|
blob_hash: r.9,
|
||||||
sort_str: r.10,
|
created_by: r.10,
|
||||||
type_order: r.11,
|
updated_by: r.11,
|
||||||
folder_first: r.12,
|
sort_str: r.12,
|
||||||
|
type_order: r.13,
|
||||||
|
folder_first: r.14,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
|||||||
fld.drive_id AS drive_id,
|
fld.drive_id AS drive_id,
|
||||||
NULL::text AS blob_hash,
|
NULL::text AS blob_hash,
|
||||||
fld.created_by AS created_by,
|
fld.created_by AS created_by,
|
||||||
|
fld.updated_by AS updated_by,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM storage.role_grants g
|
SELECT 1 FROM storage.role_grants g
|
||||||
WHERE g.resource_type = 'drive'
|
WHERE g.resource_type = 'drive'
|
||||||
@@ -276,6 +277,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
|||||||
f.drive_id AS drive_id,
|
f.drive_id AS drive_id,
|
||||||
f.blob_hash,
|
f.blob_hash,
|
||||||
f.created_by AS created_by,
|
f.created_by AS created_by,
|
||||||
|
f.updated_by AS updated_by,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM storage.role_grants g
|
SELECT 1 FROM storage.role_grants g
|
||||||
WHERE g.resource_type = 'drive'
|
WHERE g.resource_type = 'drive'
|
||||||
@@ -454,7 +456,8 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
|||||||
SELECT
|
SELECT
|
||||||
r.resource_type, r.resource_id, r.name, r.parent_id,
|
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||||
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||||
r.drive_id, r.is_owner, r.accessed_at, r.resource_path,
|
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
|
||||||
|
r.is_owner, r.accessed_at, r.resource_path,
|
||||||
r.sort_str, r.type_order, r.folder_first{username_col}
|
r.sort_str, r.type_order, r.folder_first{username_col}
|
||||||
FROM resources r
|
FROM resources r
|
||||||
{user_join}
|
{user_join}
|
||||||
@@ -531,6 +534,8 @@ LIMIT $6"
|
|||||||
modified_at: row.get("modified_at"),
|
modified_at: row.get("modified_at"),
|
||||||
drive_id: row.get("drive_id"),
|
drive_id: row.get("drive_id"),
|
||||||
blob_hash: row.try_get("blob_hash").ok(),
|
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_owner: row.try_get("is_owner").unwrap_or(false),
|
is_owner: row.try_get("is_owner").unwrap_or(false),
|
||||||
accessed_at: row.get("accessed_at"),
|
accessed_at: row.get("accessed_at"),
|
||||||
path: row.try_get("resource_path").ok(),
|
path: row.try_get("resource_path").ok(),
|
||||||
|
|||||||
@@ -367,6 +367,8 @@ impl TrashDbRepository {
|
|||||||
fld.updated_at AS modified_at,
|
fld.updated_at AS modified_at,
|
||||||
fld.drive_id AS drive_id,
|
fld.drive_id AS drive_id,
|
||||||
NULL::text AS blob_hash,
|
NULL::text AS blob_hash,
|
||||||
|
fld.created_by AS created_by,
|
||||||
|
fld.updated_by AS updated_by,
|
||||||
fld.trashed_at AS trashed_at,
|
fld.trashed_at AS trashed_at,
|
||||||
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
|
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
|
||||||
fld.path::text AS resource_path,
|
fld.path::text AS resource_path,
|
||||||
@@ -393,6 +395,8 @@ impl TrashDbRepository {
|
|||||||
f.updated_at AS modified_at,
|
f.updated_at AS modified_at,
|
||||||
f.drive_id AS drive_id,
|
f.drive_id AS drive_id,
|
||||||
f.blob_hash,
|
f.blob_hash,
|
||||||
|
f.created_by AS created_by,
|
||||||
|
f.updated_by AS updated_by,
|
||||||
f.trashed_at AS trashed_at,
|
f.trashed_at AS trashed_at,
|
||||||
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
|
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
|
||||||
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
|
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
|
||||||
@@ -522,7 +526,8 @@ impl TrashDbRepository {
|
|||||||
SELECT
|
SELECT
|
||||||
r.resource_type, r.resource_id, r.name, r.parent_id,
|
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||||
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||||
r.drive_id, r.trashed_at, r.deletion_date, r.resource_path,
|
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
|
||||||
|
r.trashed_at, r.deletion_date, r.resource_path,
|
||||||
r.sort_str, r.type_order, r.folder_first
|
r.sort_str, r.type_order, r.folder_first
|
||||||
FROM resources r
|
FROM resources r
|
||||||
{keyset}
|
{keyset}
|
||||||
@@ -581,6 +586,8 @@ LIMIT $6"
|
|||||||
modified_at: row.get("modified_at"),
|
modified_at: row.get("modified_at"),
|
||||||
drive_id: row.get("drive_id"),
|
drive_id: row.get("drive_id"),
|
||||||
blob_hash: row.try_get("blob_hash").ok(),
|
blob_hash: row.try_get("blob_hash").ok(),
|
||||||
|
created_by: row.try_get("created_by").ok(),
|
||||||
|
updated_by: row.try_get("updated_by").ok(),
|
||||||
trashed_at,
|
trashed_at,
|
||||||
deletion_date,
|
deletion_date,
|
||||||
path: row.try_get("resource_path").ok(),
|
path: row.try_get("resource_path").ok(),
|
||||||
|
|||||||
@@ -217,9 +217,8 @@ pub async fn list_favorites_resources(
|
|||||||
icon_class: intern_display("fas fa-folder"),
|
icon_class: intern_display("fas fa-folder"),
|
||||||
icon_special_class: intern_display("folder-icon"),
|
icon_special_class: intern_display("folder-icon"),
|
||||||
category: intern_display("Folder"),
|
category: intern_display("Folder"),
|
||||||
// §14 provenance not selected by the favorites query.
|
created_by: row.created_by,
|
||||||
created_by: None,
|
updated_by: row.updated_by,
|
||||||
updated_by: None,
|
|
||||||
};
|
};
|
||||||
FavoritesResourceItemDto {
|
FavoritesResourceItemDto {
|
||||||
resource_type: ResourceTypeDto::Folder,
|
resource_type: ResourceTypeDto::Folder,
|
||||||
@@ -266,9 +265,8 @@ pub async fn list_favorites_resources(
|
|||||||
sort_date: None,
|
sort_date: None,
|
||||||
content_hash,
|
content_hash,
|
||||||
etag,
|
etag,
|
||||||
// §14 provenance not selected by the favorites query.
|
created_by: row.created_by,
|
||||||
created_by: None,
|
updated_by: row.updated_by,
|
||||||
updated_by: None,
|
|
||||||
};
|
};
|
||||||
FavoritesResourceItemDto {
|
FavoritesResourceItemDto {
|
||||||
resource_type: ResourceTypeDto::File,
|
resource_type: ResourceTypeDto::File,
|
||||||
|
|||||||
@@ -487,9 +487,8 @@ pub async fn list_folder_resources(
|
|||||||
icon_class: intern_display("fas fa-folder"),
|
icon_class: intern_display("fas fa-folder"),
|
||||||
icon_special_class: intern_display("folder-icon"),
|
icon_special_class: intern_display("folder-icon"),
|
||||||
category: intern_display("Folder"),
|
category: intern_display("Folder"),
|
||||||
// §14 provenance not selected by the resources query.
|
created_by: row.created_by,
|
||||||
created_by: None,
|
updated_by: row.updated_by,
|
||||||
updated_by: None,
|
|
||||||
};
|
};
|
||||||
FolderResourceItemDto {
|
FolderResourceItemDto {
|
||||||
resource_type: ResourceTypeDto::Folder,
|
resource_type: ResourceTypeDto::Folder,
|
||||||
@@ -539,9 +538,8 @@ pub async fn list_folder_resources(
|
|||||||
sort_date: None,
|
sort_date: None,
|
||||||
content_hash,
|
content_hash,
|
||||||
etag,
|
etag,
|
||||||
// §14 provenance not selected by the resources query.
|
created_by: row.created_by,
|
||||||
created_by: None,
|
updated_by: row.updated_by,
|
||||||
updated_by: None,
|
|
||||||
};
|
};
|
||||||
FolderResourceItemDto {
|
FolderResourceItemDto {
|
||||||
resource_type: ResourceTypeDto::File,
|
resource_type: ResourceTypeDto::File,
|
||||||
|
|||||||
@@ -237,9 +237,8 @@ pub async fn list_recent_resources(
|
|||||||
icon_class: intern_display("fas fa-folder"),
|
icon_class: intern_display("fas fa-folder"),
|
||||||
icon_special_class: intern_display("folder-icon"),
|
icon_special_class: intern_display("folder-icon"),
|
||||||
category: intern_display("Folder"),
|
category: intern_display("Folder"),
|
||||||
// §14 provenance not selected by the recents query.
|
created_by: row.created_by,
|
||||||
created_by: None,
|
updated_by: row.updated_by,
|
||||||
updated_by: None,
|
|
||||||
};
|
};
|
||||||
RecentResourceItemDto {
|
RecentResourceItemDto {
|
||||||
resource_type: ResourceTypeDto::Folder,
|
resource_type: ResourceTypeDto::Folder,
|
||||||
@@ -284,9 +283,8 @@ pub async fn list_recent_resources(
|
|||||||
sort_date: None,
|
sort_date: None,
|
||||||
content_hash,
|
content_hash,
|
||||||
etag,
|
etag,
|
||||||
// §14 provenance not selected by the recents query.
|
created_by: row.created_by,
|
||||||
created_by: None,
|
updated_by: row.updated_by,
|
||||||
updated_by: None,
|
|
||||||
};
|
};
|
||||||
RecentResourceItemDto {
|
RecentResourceItemDto {
|
||||||
resource_type: ResourceTypeDto::File,
|
resource_type: ResourceTypeDto::File,
|
||||||
|
|||||||
@@ -707,6 +707,38 @@ HTTP 200
|
|||||||
jsonpath "$.created_by" == "{{alice_user_id}}"
|
jsonpath "$.created_by" == "{{alice_user_id}}"
|
||||||
jsonpath "$.updated_by" == "{{adam_user_id}}"
|
jsonpath "$.updated_by" == "{{adam_user_id}}"
|
||||||
|
|
||||||
|
# ── D0 §14 provenance survives on the LISTING endpoint too ──
|
||||||
|
# The rename-response asserts above cover the mutation DTO, but
|
||||||
|
# /api/folders/{id}/resources has its own DTO-build path that
|
||||||
|
# used to hardcode created_by/updated_by = None (silent bug —
|
||||||
|
# owner column rendered "—" on /files for everyone). Hit the
|
||||||
|
# listing and re-assert both the untouched folder (both = alice)
|
||||||
|
# AND the Adam-renamed file (created_by=alice, updated_by=adam)
|
||||||
|
# on the same page — two shapes, one round-trip.
|
||||||
|
#
|
||||||
|
# Fixed indices are safe because at this point perm_folder_id
|
||||||
|
# holds exactly two rows and the default order_by=name puts
|
||||||
|
# 'perm-test-child' (folder) at [0] and 'adam-renamed-logo.jpg'
|
||||||
|
# (file) at [1]. Anything appended to this folder later in the
|
||||||
|
# scenario would break these indices — hence the assertion runs
|
||||||
|
# BEFORE the subsequent thumbnail/create/upload steps.
|
||||||
|
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
|
||||||
|
Authorization: Bearer {{alice_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.items" count == 2
|
||||||
|
# [0] — untouched folder inherits Alice on both fields.
|
||||||
|
jsonpath "$.items[0].resource.name" == "perm-test-child"
|
||||||
|
jsonpath "$.items[0].resource.created_by" == "{{alice_user_id}}"
|
||||||
|
jsonpath "$.items[0].resource.updated_by" == "{{alice_user_id}}"
|
||||||
|
# [1] — file Adam renamed. created_by stays alice (original
|
||||||
|
# uploader), updated_by is adam (last mutator). Canonical
|
||||||
|
# listing-side cross-user split.
|
||||||
|
jsonpath "$.items[1].resource.name" == "adam-renamed-logo.jpg"
|
||||||
|
jsonpath "$.items[1].resource.created_by" == "{{alice_user_id}}"
|
||||||
|
jsonpath "$.items[1].resource.updated_by" == "{{adam_user_id}}"
|
||||||
|
|
||||||
# ── Thumbnail push (Update) succeeds ────────────────────────
|
# ── Thumbnail push (Update) succeeds ────────────────────────
|
||||||
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview
|
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview
|
||||||
Authorization: Bearer {{adam_token}}
|
Authorization: Bearer {{adam_token}}
|
||||||
|
|||||||
Reference in New Issue
Block a user