Skip to content

Polymorphic recent-items bar + command center expansion#526

Merged
jordandrako merged 7 commits into
devfrom
feat/polymorphic-recent-items-bar
May 23, 2026
Merged

Polymorphic recent-items bar + command center expansion#526
jordandrako merged 7 commits into
devfrom
feat/polymorphic-recent-items-bar

Conversation

@jordandrako
Copy link
Copy Markdown
Member

Problem

The sticky tabs bar at the top of the app only surfaced recently opened projects. Users move between projects, documents, queues, and counter groups within a guild, so the bar (and the command center's "Suggested" group) was leaving most of that navigation flow on the table.

What changed

Backend — polymorphic recent_views

  • Replaces recent_project_views with a single recent_views(user_id, entity_type, entity_id, guild_id, last_viewed_at) table. A BEFORE INSERT/UPDATE trigger populates guild_id from the underlying entity (projects / documents / queues / counter_groups); a CHECK constraint pins the allowed entity types.
  • Standard guild-scoped RLS policies (SELECT/INSERT/UPDATE/DELETE) plus a RESTRICTIVE self-scope policy (user_id = current_user_id OR is_superadmin).
  • Alembic migration 20260523_0088_create_recent_views.py creates the table, copies existing project view rows over with entity_type='project', drops the legacy table, and includes a clean downgrade.
  • New GET /api/v1/recents returns the 20 most-recently-viewed items mixed by recency, enriched with the data needed to render the bar in one round trip (name, icon for projects, document_type/mime_type/original_filename for documents). Guild admins bypass DAC in enrichment for queues + counter groups so a view they successfully recorded doesn't get silently dropped on read.
  • New POST/DELETE /api/v1/{documents,queues,counter-groups}/{id}/view endpoints; the existing project endpoints now use the shared recent_views_service. GET /projects/recent is removed (only consumer was the bar).
  • Seed script (scripts/seed_dev_data.py) updated for the new model.

Frontend — RecentTabsBar + mixed recents

  • ProjectTabsBarRecentTabsBar, rendering an entity-specific icon per item: emoji for projects, getDocumentIcon/getDocumentIconColor for documents, GalleryHorizontalEnd for queues, Gauge for counter groups.
  • useRecents, useRecordRecentView(entityType), useClearRecentView replace the projects-only hooks. Detail pages for documents, queues, and counter groups now fire the record on load (mirroring ProjectDetailPage).
  • Helpers: lib/recentIcon.tsx (icon resolution) and lib/recentRoute.ts (guild-scoped routes + getActiveRecentKey for active-tab highlighting).

Command center

  • "Suggested" group now lists the top 5 mixed recents (instead of just projects), reusing the same icon + route helpers as the layout bar.
  • New "Queues" and "Counter Groups" groups participate in fuzzy search alongside Projects/Documents.

Tests run

cd backend && pytest app/api/v1/endpoints/ --no-cov   # 439 passed
cd backend && pytest app/api/v1/endpoints/recents_test.py --no-cov   # 5 passed
cd backend && ruff check app   # All checks passed!
cd frontend && pnpm typecheck   # 0
cd frontend && pnpm lint        # 2 pre-existing warnings, no new errors
cd frontend && pnpm test:run    # 373 passed (17 files)

Manually verified end-to-end via curl + the dev server: POST /queues/{id}/view records the view, GET /recents/ returns it at the top of the mixed list, the bar updates after a refresh, and the X on each tab clears the row.

Schema / migration notes

  • Run alembic upgrade head to apply 20260523_0088. The migration copies existing recent_project_views rows into recent_views with entity_type='project' before dropping the legacy table; downgrade restores recent_project_views and copies project rows back.
  • Breaking change to GET /api/v1/projects/recent (removed). The only consumer was the layout tabs bar, which now uses GET /api/v1/recents.

Screenshots

TODO before merging — header bar with mixed icons; command center Suggested group across types.

Replaces the projects-only recent_project_views table with a polymorphic
recent_views(user_id, entity_type, entity_id, guild_id, last_viewed_at)
table. A BEFORE-trigger populates guild_id from the underlying entity;
RLS policies scope SELECT/INSERT/UPDATE/DELETE to current_guild_id and a
RESTRICTIVE policy locks rows to their own user. Migration copies
existing project view rows over before dropping the legacy table.

New GET /api/v1/recents returns the user's 20 most-recently-viewed
guild-scoped items across projects, documents, queues, and counter
groups, ordered by last_viewed_at desc, enriched with the data needed to
render entity-specific icons in one round trip. Per-entity POST/DELETE
{id}/view endpoints are added to documents, queues, and counter groups,
and the existing projects endpoints now use the shared
recent_views_service (the projects-only GET /projects/recent is removed).

Guild admins bypass DAC during enrichment for queues and counter groups
to match the per-entity detail-page behaviour, so a row the admin
successfully recorded a view for isn't silently dropped on read.

Seed script (scripts/seed_dev_data.py) updated to use the new RecentView
model and composite key shape.
Renames ProjectTabsBar to RecentTabsBar and generalises it across
projects, documents, queues, and counter groups. Items come from the new
useRecents() hook backed by /api/v1/recents and render an entity-
specific icon: project emoji, getDocumentIcon/getDocumentIconColor for
documents (matching document lists), GalleryHorizontalEnd for queues,
Gauge for counter groups.

New lib/recentIcon.tsx and lib/recentRoute.ts encapsulate the per-type
icon rendering and route resolution (incl. getActiveRecentKey for
highlighting the active tab). Detail pages for documents, queues, and
counter groups now fire useRecordRecentView(entityType) on load, matching
the existing pattern in ProjectDetailPage.

useClearRecentView replaces the projects-only useClearProjectView and
dispatches to the right entity-type DELETE on the bar's close button.

Includes a RecentTabsBar.test.tsx covering mixed icon rendering, link
routing, and onClose dispatch, plus a recent.factory for builders.
Suggested group now lists the top 5 mixed recent items (projects,
documents, queues, counter groups) ordered by last_viewed_at, reusing
renderRecentIcon and recentRoute so icons + routes match the layout
tabs bar.

Adds Queues and Counter Groups groups to the command palette backed by
useQueuesList / useCounterGroupsList (page_size 100, 60s staleTime to
match the existing Projects/Documents fetches). Each item links to its
guild-scoped detail page with the matching lucide icon
(GalleryHorizontalEnd / Gauge).

i18n: adds groups.queues + groups.counterGroups to en/fr/es command
namespaces.
@jordandrako jordandrako requested a review from LeeJMorel as a code owner May 23, 2026 06:07
@jordandrako
Copy link
Copy Markdown
Member Author

@greptile

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 23, 2026

Greptile Summary

This PR replaces the projects-only sticky tabs bar and command-center "Suggested" group with a fully polymorphic implementation that surfaces recently viewed projects, documents, queues, and counter groups. A new recent_views table consolidates what was previously a single-entity table, backed by a PostgreSQL trigger for guild_id population, correct FORCE RLS policies, and an Alembic migration that copies existing project rows and provides a clean downgrade.

  • Backend: New GET /api/v1/recents endpoint enriches rows with entity-specific metadata in one round trip; new POST/DELETE /{entity}/{id}/view endpoints delegate to the shared recent_views_service; the legacy GET /projects/recent is removed with proper note.
  • Frontend: RecentTabsBar replaces ProjectTabsBar, useRecents/useRecordRecentView/useClearRecentView replace the projects-only hooks, and recentRoute/renderRecentIcon helpers centralise icon and routing logic; the command center gains Queues and Counter Groups fuzzy-search groups.

Confidence Score: 5/5

Safe to merge. The migration, RLS policies, permission checks, and service layer are all implemented correctly, with passing tests covering mixed ordering, guild scoping, and clear/record round-trips.

The core data-flow path — trigger-populated guild_id, RESTRICTIVE self-scope RLS, batch entity enrichment with per-entity permission checks — is correct. The two findings are edge-case routing and a gradual UX degradation when entities are deleted, neither of which blocks the feature from working correctly under normal use.

frontend/src/lib/recentRoute.ts for the activeGuildId vs item.guild_id routing concern; entity deletion handlers for the orphaned-row cleanup gap.

Important Files Changed

Filename Overview
backend/alembic/versions/20260523_0088_create_recent_views.py New migration creating the polymorphic recent_views table with trigger-populated guild_id, correct RLS policies (RESTRICTIVE self-scope + guild-scoped CRUD), data copy from legacy table, and clean downgrade.
backend/app/api/v1/endpoints/recents.py New GET /recents endpoint enriching polymorphic rows with per-entity metadata. Permission checks correctly narrow except HTTPException. Guild-admin bypass is intentional for queues/counter_groups.
backend/app/services/recent_views.py Service layer for upsert/clear/list with reapply_rls_context after each commit. Orphaned rows from deleted entities self-heal gradually as new views are recorded.
backend/app/api/v1/endpoints/recents_test.py Good integration test coverage for record, list, mixed ordering, clear, and guild scoping.
frontend/src/lib/recentRoute.ts Route helper uses activeGuildId from hook rather than item.guild_id, which can produce wrong-guild URLs during the stale cache window after a guild switch.
frontend/src/hooks/useRecents.ts Replaces the projects-only hook with polymorphic useRecents / useRecordRecentView / useClearRecentView. Dispatch-table pattern for entity types is clean.
frontend/src/components/recents/RecentTabsBar.tsx Clean polymorphic replacement for ProjectTabsBar. Uses closeItem i18n key (fixing the previously flagged accessibility issue).
frontend/src/components/CommandCenter.tsx Suggested group now uses mixed recents; new Queues and Counter Groups groups participate in fuzzy search.
backend/app/api/v1/endpoints/projects.py Successfully delegates view record/clear to recent_views_service. GET /projects/recent removed with note.
backend/app/models/recent_view.py New SQLModel for the polymorphic table. Composite PK on (user_id, entity_type, entity_id) matches the migration.

Sequence Diagram

sequenceDiagram
    participant Page as Detail Page
    participant FE as useRecordRecentView
    participant API as POST /{entity}/{id}/view
    participant Svc as recent_views_service
    participant DB as recent_views (PostgreSQL)
    participant Bar as RecentTabsBar
    participant Recents as GET /api/v1/recents

    Page->>FE: mutate(entityId) on load
    FE->>API: "POST /{entity}/{id}/view"
    API->>Svc: record_view(user_id, entity_type, entity_id)
    Svc->>DB: INSERT ON CONFLICT DO UPDATE last_viewed_at
    DB-->>DB: BEFORE trigger sets guild_id from entity table
    DB-->>DB: "RLS WITH CHECK guild_id = current_guild_id"
    Svc->>DB: SELECT row to fetch last_viewed_at
    Svc->>DB: Prune rows beyond offset 20
    API-->>FE: RecentViewWrite
    FE->>FE: invalidateRecents()
    Bar->>Recents: GET /api/v1/recents
    Recents->>DB: SELECT recent_views scoped by user + guild RLS
    Recents->>DB: Batch-fetch entities by type
    Recents->>Recents: Enrich + permission-filter each row
    Recents-->>Bar: RecentItemRead[] ordered by last_viewed_at DESC
    Bar-->>Page: Render tabs with entity-specific icons
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
frontend/src/lib/recentRoute.ts:23-27
The function accepts `activeGuildId` from the caller's hook, but `item.guild_id` is already present in every `RecentItemRead` payload. When React Query serves stale recents from guild A for up to 30 seconds after the user switches to guild B, `activeGuildId` will be guild B's ID while `item.guild_id` is still guild A's — producing routes like `/g/guildBId/projects/guildAProjectId` that will 404. Using `item.guild_id` directly makes the route self-contained and avoids the mismatch.

```suggestion
export function recentRoute(item: RecentItemRead, activeGuildId?: number | null): string {
  const segment = SEGMENT_BY_TYPE[item.entity_type];
  const path = `/${segment}/${item.entity_id}`;
  // Prefer the guild embedded in the item itself; fall back to the active guild
  // only if needed (e.g. items fetched outside a guild context).
  const guildId = item.guild_id ?? activeGuildId;
  return guildId ? guildPath(guildId, path) : path;
}
```

### Issue 2 of 2
backend/app/services/recent_views.py:57-79
**Orphaned rows from deleted entities silently consume the 20-item cap**

`recent_views` has no FK on `entity_id`, so deleting a project, document, queue, or counter group leaves its view rows in place. `recents.py` gracefully skips those orphaned rows on read, but they still occupy slots in the 20-item window returned by `list_recent_views`. A user who viewed 20 items and had most of them deleted would see a nearly empty bar even though the underlying limit wasn't yet hit. The rows self-heal — each new `record_view` call prunes the oldest entry — but the UX degradation persists until enough new views are recorded. Adding per-entity cleanup in the deletion handlers (e.g. `DELETE FROM recent_views WHERE entity_type = 'project' AND entity_id = ?`) would prevent the gap.

Reviews (2): Last reviewed commit: "Stop double-escaping recent-item names" | Re-trigger Greptile

Comment thread backend/app/api/v1/endpoints/recents.py
Comment thread frontend/src/components/recents/RecentTabsBar.tsx Outdated
Comment thread frontend/src/hooks/useRecents.ts Outdated
- Narrow recents enrichment to catch HTTPException only so latent
  permission-helper bugs (MissingGreenlet, AttributeError, …) surface
  as 500s instead of silently truncating the bar.
- Rename projects.tabsBar.closeProject → closeItem (en/fr/es) and
  generalise loadingRecent wording, since the bar now covers documents,
  queues, and counter groups too.
- Drop the unnecessary double-cast on listRecentsApiV1RecentsGet — the
  generated client already returns the correct type.
@jordandrako
Copy link
Copy Markdown
Member Author

@greptile

SanitizedBaseModel's _sanitize_strings validator runs nh3.clean() on
every str field when a model is constructed from a dict — which is what
happens with kwargs construction. Other read schemas avoid this because
FastAPI / endpoint helpers build them via model_validate(orm_instance),
where the validator sees a non-dict and returns early.

RecentItemRead was being built with kwargs in the /recents enrichment
loop, so a project named 'Session Zero & Planning' came out as
'Session Zero & Planning' and React rendered the literal '&'.

Switch to model_construct(), which skips all validators — appropriate
here since the data comes from trusted DB columns that were already
sanitized on input.
@jordandrako
Copy link
Copy Markdown
Member Author

Follow-up filed: #527 — finishes the typed text annotations from #464 (PlainTextStr / RawTextStr) and adds the DB backfill that never landed. That's the proper fix for the & corruption surfaced here; the model_construct change in this PR remains the right minimal fix for /recents regardless.

@jordandrako jordandrako merged commit e61406b into dev May 23, 2026
3 checks passed
@jordandrako jordandrako deleted the feat/polymorphic-recent-items-bar branch May 23, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant