feat(rooms): per-room Files tab in room info (LC-87) #151

Merged
nrupard merged 1 commit from feat/lc-87-room-files-tab into main 2026-05-19 17:45:18 +02:00
Owner

Closes LC-87. Adds a "Files" tab next to Docs and Pinned on /room/{id}/info. Lists every upload attached to a non-soft-deleted message in the room, newest first, paginated. Filter dropdown narrows to images / video / audio / PDF / other. Each card links to the parent message via /room/{id}#msg-{message_id}.

Schema

  • No new tables. Reuses file_uploads (migration 0012) joined to messages on file_uploads.message_id = messages.id; the JOIN naturally excludes soft-deleted messages and orphan uploads.

DB layer

  • db::uploads::FileKindFilter enum + query-string round trip.
  • list_room_files(pool, room_id, kind, before_id, limit): DESC-by-id with a before cursor for pagination. Mime predicates composed in Rust to keep one SQL plan per filter.

Route

  • GET /room/{id}/info?tab=files renders the page with the initial 40-row page.
  • GET /room/{id}/files?kind=X&before=Y&append=true|false is the HTMX fragment endpoint. append=false (filter change) replaces #files-grid; append=true (load-more) returns OOB rows targeting #files-grid-rows beforeend plus a fresh load-more button (hx-target="#files-load-more" hx-swap="outerHTML") that replaces the one the user clicked.
  • Both endpoints reuse is_room_accessible, so a user not in a private room cannot list its files (acceptance criterion).

View / template

  • RoomFileRow carries pre-resolved uploader label + an is_image flag + a one-character kind_icon (V/A/P/F) for non-image cards. No new icon assets.
  • room/info.html gains the Files tab nav entry, the filter dropdown, the initial 40-row grid, the load-more slot.
  • Cards factored into room/file_card.html keyed off a card_room_id local, so the page and the fragment endpoint render identical markup. files_fragment.html branches on append: replace mode emits a fresh #files-grid-rows wrapper; append mode emits OOB rows + a new load-more wrapper.

Acceptance criteria coverage

  • /room/{id}/files lists every upload (filename, uploader, date, thumbnail / icon).
  • Filter narrows the result set via HTMX.
  • Clicking a file navigates to the parent message (anchored #msg-{id}).
  • Pagination is cursor-based (id < before), so tens of thousands of uploads paginate cheaply.
  • Deleted uploads / parent messages do not appear (JOIN on messages + deleted_at IS NULL).
  • Room permissions respected (is_room_accessible is the gate).

Out of scope (open questions, deferred)

  • Uploader-typeahead and date-range filters (kind alone covers the common find-this-file case).
  • Bulk zip download.
  • Storage-usage display (LC-93 quotas).

Tests (routes_room_files.rs, 6/6 passing): empty notice; seeded files appear; kind=image excludes a PDF; soft-deleted parent hides the upload; outsider 403s on private room files; pagination advances the cursor correctly across the page-1 / page-2 boundary.

just check clean. just test passes outside the pre-existing routes_uploads concurrent-binary flake (CLAUDE.md-noted; passes alone).

Closes LC-87. Adds a "Files" tab next to Docs and Pinned on `/room/{id}/info`. Lists every upload attached to a non-soft-deleted message in the room, newest first, paginated. Filter dropdown narrows to images / video / audio / PDF / other. Each card links to the parent message via `/room/{id}#msg-{message_id}`. Schema - No new tables. Reuses `file_uploads` (migration 0012) joined to `messages` on `file_uploads.message_id = messages.id`; the JOIN naturally excludes soft-deleted messages and orphan uploads. DB layer - `db::uploads::FileKindFilter` enum + query-string round trip. - `list_room_files(pool, room_id, kind, before_id, limit)`: DESC-by-id with a `before` cursor for pagination. Mime predicates composed in Rust to keep one SQL plan per filter. Route - `GET /room/{id}/info?tab=files` renders the page with the initial 40-row page. - `GET /room/{id}/files?kind=X&before=Y&append=true|false` is the HTMX fragment endpoint. `append=false` (filter change) replaces `#files-grid`; `append=true` (load-more) returns OOB rows targeting `#files-grid-rows beforeend` plus a fresh load-more button (`hx-target="#files-load-more" hx-swap="outerHTML"`) that replaces the one the user clicked. - Both endpoints reuse `is_room_accessible`, so a user not in a private room cannot list its files (acceptance criterion). View / template - `RoomFileRow` carries pre-resolved uploader label + an `is_image` flag + a one-character `kind_icon` (V/A/P/F) for non-image cards. No new icon assets. - `room/info.html` gains the Files tab nav entry, the filter dropdown, the initial 40-row grid, the load-more slot. - Cards factored into `room/file_card.html` keyed off a `card_room_id` local, so the page and the fragment endpoint render identical markup. `files_fragment.html` branches on `append`: replace mode emits a fresh `#files-grid-rows` wrapper; append mode emits OOB rows + a new load-more wrapper. Acceptance criteria coverage - [x] `/room/{id}/files` lists every upload (filename, uploader, date, thumbnail / icon). - [x] Filter narrows the result set via HTMX. - [x] Clicking a file navigates to the parent message (anchored `#msg-{id}`). - [x] Pagination is cursor-based (id < before), so tens of thousands of uploads paginate cheaply. - [x] Deleted uploads / parent messages do not appear (JOIN on messages + `deleted_at IS NULL`). - [x] Room permissions respected (`is_room_accessible` is the gate). Out of scope (open questions, deferred) - Uploader-typeahead and date-range filters (kind alone covers the common find-this-file case). - Bulk zip download. - Storage-usage display (LC-93 quotas). Tests (`routes_room_files.rs`, 6/6 passing): empty notice; seeded files appear; `kind=image` excludes a PDF; soft-deleted parent hides the upload; outsider 403s on private room files; pagination advances the cursor correctly across the page-1 / page-2 boundary. `just check` clean. `just test` passes outside the pre-existing `routes_uploads` concurrent-binary flake (CLAUDE.md-noted; passes alone).
feat(rooms): per-room Files tab in room info page (LC-87)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 2m9s
319f2508b1
Adds a "Files" tab next to Docs and Pinned on `/room/{id}/info`. Lists every upload attached to a (non-soft-deleted) message in the room, newest first, paginated. Filter dropdown narrows to images / video / audio / pdf / other. Each card links back to the parent message via `/room/{id}#msg-{message_id}`.

Schema
- No new tables. Reuses `file_uploads` (added in migration 0012) joined to `messages` on `file_uploads.message_id = messages.id` so soft-deleted messages and orphan uploads (message_id IS NULL) are filtered out by the join. No new migration.

DB layer
- `db::uploads::FileKindFilter` enum + `from_query`/`as_query` round-trip.
- `db::uploads::list_room_files(pool, room_id, kind, before_id, limit)` returns uploads in DESC `id` order. The cursor is the smallest id on the current page; the route handler peeks one extra row past `limit` to decide whether to render the load-more button.

Route
- `GET /room/{id}/info?tab=files` renders the page with the initial 40-row page.
- `GET /room/{id}/files?kind=X&before=Y&append=true|false` is the HTMX fragment endpoint. `append=false` (filter change) replaces `#files-grid`; `append=true` (load-more click) returns the new rows as an OOB swap targeting `#files-grid-rows beforeend` plus a fresh load-more button that replaces the one the user clicked.
- Both endpoints reuse the existing `is_room_accessible` predicate, so a user not in a private room cannot list its files (acceptance criterion).

View / template
- `RoomFileRow` carries the pre-resolved uploader label + an `is_image` flag + a one-character `kind_icon` (V/A/P/F) for the non-image card. No new icon assets needed.
- `info.html` gains the Files tab nav entry, the filter dropdown, the initial 40-row grid, and the load-more slot. Cards are factored into `room/file_card.html` (a partial keyed off a `card_room_id` local) so the page and the fragment endpoint render identical markup.
- `files_fragment.html` branches on `append`: replace mode emits a fresh `#files-grid-rows` wrapper; append mode emits OOB rows + a new load-more wrapper (`hx-target="#files-load-more" hx-swap="outerHTML"`), so the page is HTML-valid and HTMX swaps land in the right slot.

Out of scope (open questions, deferred)
- Uploader-typeahead and date-range filters (the spec listed them; kind-filter alone covers the most common find-this-file case).
- Bulk zip download.
- Storage-usage display (waits for LC-93 quotas).

Tests (`routes_room_files.rs`, 6/6 passing)
- empty room shows the no-files notice;
- seeded files appear in the tab;
- `?kind=image` excludes a seeded PDF;
- a soft-deleted parent message hides the upload;
- outsider 403s on `/room/{private}/files`;
- pagination: page 1 returns the newest 40, the load-more cursor advances correctly, and page 2 contains the oldest 5.

`just check` clean. `just test` passes outside the pre-existing `routes_uploads` concurrent-binary flake (CLAUDE.md-noted; passes alone).
nrupard deleted branch feat/lc-87-room-files-tab 2026-05-19 17:45:18 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!151
No description provided.