Skip to content

Centralize RBAC, gate AI recommendations, and fix media-type, accessibility, and watch progress bugs#42

Open
Swastikdan wants to merge 1 commit into
masterfrom
codex/fix-validation-issues-in-codebase
Open

Centralize RBAC, gate AI recommendations, and fix media-type, accessibility, and watch progress bugs#42
Swastikdan wants to merge 1 commit into
masterfrom
codex/fix-validation-issues-in-codebase

Conversation

@Swastikdan

@Swastikdan Swastikdan commented Jun 1, 2026

Copy link
Copy Markdown
Owner

Motivation

  • Centralize RBAC roles/features so frontend and Convex logic cannot drift and enforce admin-only reads for role permissions.
  • Prevent AI recommendation endpoints and cached data from being read or written when the ai-recommendations feature is disabled and ensure feedback/dismissals respect media type.
  • Preserve admin/role behavior for legacy data and avoid misclassification by backfilling users.roles from the legacy role field.
  • Improve UX/accessibility and data correctness for playback state, continue-watching rows, share controls, custom-list deletion, and admin toggles.

Description

  • Added a Convex-safe shared RBAC module (shared/rbac.ts) and re-exported types/values from src/constants.ts, and updated convex/admin.ts to consume the shared definitions and keep defaults centralized.
  • Hardened admin endpoints by calling requireAdmin(ctx) in getRolePermissions, added migrateLegacyUserRoles admin mutation to backfill users.roles, and preserved the legacy role schema field with a TODO in convex/schema.ts and convex/users.ts to migrate existing docs.
  • Gate AI recommendation handlers with hasFeature(ctx, "ai-recommendations") and make recommendation feedback/dismissal/tracked IDs media-type-aware by using composite keys (e.g., ${mediaType}:${tmdbId}) and preserve listId when replaying list-based generations across convex/recommendations.ts, frontend hooks, and route handlers.
  • Return media-type-aware tracked IDs from convex/watchlist.getTrackedTmdbIds, make homepage recommendation liked/dismissal sets use composite keys, prevent tight generate-recs retry loops by tracking refresh failures, and filter out failed queries in the continue-watching row instead of dropping the whole row.
  • Namespace last_played localStorage keys by viewer id and add safeNextEpisode logic using season metadata to avoid out-of-range next-episode values; change playback-derived progress status to take precedence over stale statuses in convex/watchlist.ts and src/hooks/useWatchProgress.ts.
  • Accessibility and UX fixes: add aria-label to admin toggle switches and share button, add .catch error handling to role-permission mutation calls, set aria-pressed on list buttons, make custom-list deletion await the mutation and handle/report errors, and add a backward-compatible legacy progress status mapping in src/lib/utils.ts.

Testing

  • Ran npx biome check (targeted files) and the project linter fixed/organized imports where applicable and reported no new issues in the modified files; this check completed successfully for the changed files.
  • Ran npx tsc --noEmit which succeeded (typecheck passed for the edits).
  • Ran npm run check which still reports pre-existing Biome findings in unrelated files (e.g., media-poster-trailer-container.tsx, ui/icons.tsx, search-bar.tsx, video-player-modal.tsx, routes/index.tsx), so those unrelated lint findings remain unresolved.
  • Ran npm run build: client and SSR bundles were produced successfully, but prerender failed when fetching / with an Internal Server Error during prerendering (likely unrelated runtime/environment issue surfaced during prerender).

Codex Task

Summary by CodeRabbit

Release Notes

  • New Features

    • Per-user watch progress tracking for multi-account households
    • Admin role migration tool for legacy user accounts
    • Feature flag controls for AI recommendations
  • Improvements

    • Enhanced media type distinction in recommendation filtering to properly handle duplicates
    • Improved error handling and user feedback in admin and list management flows
    • Better TV show next-episode selection logic using accurate episode counts
    • Accessibility improvements with added aria-labels
  • Bug Fixes

    • Fixed progress status calculation to use inferred status when available

@vercel

vercel Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pebbly Ready Ready Preview, Comment Jun 1, 2026 6:13am

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements RBAC configuration consolidation, legacy user role migration, AI recommendation feature flagging, media-type-aware tracking across watchlist and recommendations, per-user watch progress scoping, and accessibility improvements. The changes unify RBAC constants in a shared module, add gating for recommendation features, introduce composite media type keys for tracking, and improve error handling and UI accessibility throughout the app.

Changes

RBAC Centralization, Feature Flagging, and Media Tracking

Layer / File(s) Summary
RBAC Configuration Centralization
shared/rbac.ts, convex/admin.ts, src/constants.ts
RBAC role, permission, and feature definitions are consolidated into shared/rbac.ts with role/feature types and constants. convex/admin.ts imports these to validate role-feature pairs and compute permissions. src/constants.ts re-exports the types and constants for client code.
Legacy User Role Migration
convex/schema.ts, convex/admin.ts, convex/users.ts
Add optional legacy role field to users schema. Implement migrateLegacyUserRoles mutation to backfill legacy role into deduped roles array. Update store mutation to conditionally migrate on user updates.
Admin Permission UI
src/components/admin/admin-permission-toggles.tsx, src/hooks/usePermissions.ts
Require aria-labels on toggle switches and pass role/feature-specific labels. Add error handling with alert feedback for permission mutations. Update loading state logic for explicit truthiness.
AI Recommendations Feature Flagging
convex/recommendations.ts
Gate recommendation retrieval, feedback submission, and retrieval behind ai-recommendations feature flag. Add optional listId to recommendation storage and wiring. Update not-interested filtering to use mediaType:tmdbId keys.
Media Type-aware Tracking
convex/watchlist.ts, src/routes/recommendations.tsx
Change getTrackedTmdbIds to return {tmdbId, mediaType} objects. Update recommendation filtering and exclusion to use composite mediaType:tmdbId string keys instead of bare tmdbId. Split tracked sets for filtering vs. generation exclusion.
Watch Progress Improvements
src/hooks/useWatchProgress.ts, convex/watchlist.ts, src/lib/utils.ts
Implement per-user "last played" localStorage keys and fetch TV season episode counts. Use safe episode advancement logic capped at season boundaries. Introduce legacy progress status mapping. Change precedence to favor inferred over current status.
Recommendation UI and Homepage
src/components/homepage-recommendations.tsx, src/components/homepage-media.tsx, src/hooks/useRecommendations.ts
Update recommendation feedback to track by mediaType:tmdbId keys. Add failure state to prevent repeated generation. Refactor homepage media with typed movie/TV branching. Extend recommendation history with optional listId field.
Error Handling and Accessibility
src/routes/watchlist.tsx, src/components/media/watchlist-status-menu.tsx, src/components/share-button.tsx, src/routes/keyword.$id.tsx, src/styles.css
Add async try/catch error handling for custom list deletion with user alerts. Enhance accessibility with aria-pressed and aria-label attributes. Improve optional chaining for safer data access.

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main changes: RBAC centralization, AI recommendation gating, and multiple bug fixes (media-type handling, accessibility, watch progress).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/fix-validation-issues-in-codebase

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
convex/admin.ts (1)

123-142: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fallback to user.role until the migration is complete.

hasFeature only evaluates user.roles, so any account that still has only the legacy user.role field will read as having no features and fail gates like the recommendation endpoints. Normalize roles from both fields until the legacy column is fully removed, and reuse that same helper in getUserFeatures/listUsers as well.

Suggested fix
+function getEffectiveRoles(user: { roles?: string[]; role?: string }) {
+  return Array.from(
+    new Set([...(user.roles ?? []), ...(user.role ? [user.role] : [])]),
+  ).filter((role): role is DynamicRbacRole =>
+    DYNAMIC_ROLES.includes(role as DynamicRbacRole),
+  );
+}
+
 export async function hasFeature(
   ctx: QueryCtx | MutationCtx,
   feature: RbacFeature,
 ): Promise<boolean> {
@@
-  for (const role of user.roles ?? []) {
-    if (!DYNAMIC_ROLES.includes(role as DynamicRbacRole)) continue;
+  for (const role of getEffectiveRoles(user)) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@convex/admin.ts` around lines 123 - 142, hasFeature currently only reads
user.roles and thus misses legacy user.role values; update the logic to
normalize roles from both fields (legacy user.role and new user.roles) before
checking DYNAMIC_ROLES. Reuse or extract the same normalization helper used by
getUserFeatures and listUsers (or create a shared normalizeUserRoles(user)
helper) and iterate over the combined/normalized role list in the block that
queries role_permissions (the code around getUserByToken, isClerkAdmin and the
for-loop over user.roles) so legacy single-role accounts are evaluated the same
as migrated accounts.
src/routes/keyword.$id.tsx (1)

35-42: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep the keyword meta title null-safe
head() still reads loaderData?.keyword.name for the <title>; if keyword is missing it can throw, so align it with the already-null-safe description.

Proposed fix
 head: ({ loaderData }) => ({
 	meta: [
 		{
-			title: `${loaderData?.keyword.name || "Keyword"} - Movies | Pebbly`,
+			title: `${loaderData?.keyword?.name ?? "Keyword"} - Movies | Pebbly`,
 		},
 		{
 			name: "description",
 			content: `Explore movies tagged with ${loaderData?.keyword?.name ?? "this keyword"} on Pebbly.`,
 		},
 	],
 }),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/keyword`.$id.tsx around lines 35 - 42, The title meta in the
head() function uses loaderData?.keyword.name which can throw if keyword is
undefined; change it to the same null-safe pattern as the description (use
loaderData?.keyword?.name ?? "Keyword" or similar) so the title fallback mirrors
the description; update the head() meta title entry that references
loaderData?.keyword.name to loaderData?.keyword?.name with optional chaining and
a default.
src/components/share-button.tsx (1)

13-18: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle navigator.share() rejections (user-cancel AbortError)

src/components/share-button.tsx awaits navigator.share(...) without a try/catch, while the click handler intentionally discards the returned promise via onClick={() => void handleShare()}. When the user dismisses the native share sheet, the Web Share API rejects with an AbortError, which can surface as an unhandled promise rejection. Catch/suppress AbortError locally.

Proposed fix
 async function handleShare() {
 	if (navigator.share) {
-		await navigator.share({
-			title: props.title,
-			url: window.location.href,
-		});
+		try {
+			await navigator.share({
+				title: props.title,
+				url: window.location.href,
+			});
+		} catch (error) {
+			if (error instanceof DOMException && error.name === "AbortError") {
+				return;
+			}
+			alert("Unable to share this page");
+		}
+		return;
 	} else {
 		const textToCopy = `${props.title} ${window.location.href}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/share-button.tsx` around lines 13 - 18, The handleShare
function awaits navigator.share(...) without handling rejections which can lead
to unhandled promise rejections when the user cancels (AbortError); wrap the
await in a try/catch inside handleShare, detect and silently ignore an
AbortError (e.g., error.name === 'AbortError' or DOMException code), and only
log or rethrow other errors to preserve real failures; keep the click site using
onClick={() => void handleShare()} unchanged so the promise remains
intentionally discarded.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@convex/admin.ts`:
- Around line 302-305: The code currently calls ctx.db.query("users").collect()
which loads the entire users table into memory and then stops after
args.batchSize—replace the full-table collect with a paginated query that only
fetches at most args.batchSize per run; iterate pages (e.g., repeatedly calling
the users query with a limit/take and a cursor/lastId) and process each page
until you hit migrated >= (args.batchSize ?? 100) or no more rows. Update the
logic around the users variable and the for loop to fetch one page at a time
(using the query name "users" and the args.batchSize value) so you never load
the full table into memory.
- Around line 307-313: The migration currently skips users that already have any
roles because of the condition if (!legacyRole || (user.roles?.length ?? 0) > 0)
continue; — change the logic to only skip when legacyRole is falsy and, when
legacyRole exists, always call ctx.db.patch(user._id, ...) to merge legacyRole
into user.roles (Array.from(new Set([...(user.roles ?? []), legacyRole]))) and
set role: undefined so the legacy field is cleared; update the condition to if
(!legacyRole) continue; and keep the existing ctx.db.patch call (merging and
clearing) to ensure backfill and cleanup occur even when user.roles is
populated.

In `@convex/users.ts`:
- Around line 37-41: The current fallback only migrates legacy existing.role
when existing.roles is empty; change the logic in convex/users.ts so that
whenever existing.role is present you merge it into patch.roles and clear
patch.role. Specifically, detect existing.role (regardless of existing.roles
length), set patch.roles = Array.from(new Set([...(existing.roles ?? []),
existing.role])) and then set patch.role = undefined to ensure the legacy field
is always consumed/cleared.

---

Outside diff comments:
In `@convex/admin.ts`:
- Around line 123-142: hasFeature currently only reads user.roles and thus
misses legacy user.role values; update the logic to normalize roles from both
fields (legacy user.role and new user.roles) before checking DYNAMIC_ROLES.
Reuse or extract the same normalization helper used by getUserFeatures and
listUsers (or create a shared normalizeUserRoles(user) helper) and iterate over
the combined/normalized role list in the block that queries role_permissions
(the code around getUserByToken, isClerkAdmin and the for-loop over user.roles)
so legacy single-role accounts are evaluated the same as migrated accounts.

In `@src/components/share-button.tsx`:
- Around line 13-18: The handleShare function awaits navigator.share(...)
without handling rejections which can lead to unhandled promise rejections when
the user cancels (AbortError); wrap the await in a try/catch inside handleShare,
detect and silently ignore an AbortError (e.g., error.name === 'AbortError' or
DOMException code), and only log or rethrow other errors to preserve real
failures; keep the click site using onClick={() => void handleShare()} unchanged
so the promise remains intentionally discarded.

In `@src/routes/keyword`.$id.tsx:
- Around line 35-42: The title meta in the head() function uses
loaderData?.keyword.name which can throw if keyword is undefined; change it to
the same null-safe pattern as the description (use loaderData?.keyword?.name ??
"Keyword" or similar) so the title fallback mirrors the description; update the
head() meta title entry that references loaderData?.keyword.name to
loaderData?.keyword?.name with optional chaining and a default.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dd7bc0ce-ee19-4f43-b574-1828f589cbc6

📥 Commits

Reviewing files that changed from the base of the PR and between 4c957c3 and 17ceb5f.

📒 Files selected for processing (20)
  • convex/admin.ts
  • convex/recommendations.ts
  • convex/schema.ts
  • convex/users.ts
  • convex/watchlist.ts
  • shared/rbac.ts
  • src/components/admin/admin-permission-toggles.tsx
  • src/components/homepage-media.tsx
  • src/components/homepage-recommendations.tsx
  • src/components/media/watchlist-status-menu.tsx
  • src/components/share-button.tsx
  • src/constants.ts
  • src/hooks/usePermissions.ts
  • src/hooks/useRecommendations.ts
  • src/hooks/useWatchProgress.ts
  • src/lib/utils.ts
  • src/routes/keyword.$id.tsx
  • src/routes/recommendations.tsx
  • src/routes/watchlist.tsx
  • src/styles.css

Comment thread convex/admin.ts
Comment on lines +302 to +305
const users = await ctx.db.query("users").collect();
let migrated = 0;
for (const user of users) {
if (migrated >= (args.batchSize ?? 100)) break;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

batchSize is defeated by collecting the full users table first.

Line 302 loads every user into memory before the loop stops at batchSize, so each run still scans the whole table to migrate at most 100 records. On a large production dataset this can hit Convex execution limits and makes the batch size mostly cosmetic. Page through users instead of calling collect() on the full table.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@convex/admin.ts` around lines 302 - 305, The code currently calls
ctx.db.query("users").collect() which loads the entire users table into memory
and then stops after args.batchSize—replace the full-table collect with a
paginated query that only fetches at most args.batchSize per run; iterate pages
(e.g., repeatedly calling the users query with a limit/take and a cursor/lastId)
and process each page until you hit migrated >= (args.batchSize ?? 100) or no
more rows. Update the logic around the users variable and the for loop to fetch
one page at a time (using the query name "users" and the args.batchSize value)
so you never load the full table into memory.

Comment thread convex/admin.ts
Comment on lines +307 to +313
const legacyRole = user.role;
if (!legacyRole || (user.roles?.length ?? 0) > 0) continue;

await ctx.db.patch(user._id, {
roles: Array.from(new Set([...(user.roles ?? []), legacyRole])),
role: undefined,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Merge and clear the legacy role even when roles is already populated.

Line 308 skips every user that already has any roles, so a record like roles=["video-player"] plus legacy role="ai-integrations" never gets backfilled or cleaned up. The migration should merge user.role into user.roles whenever user.role exists, then clear user.role.

Suggested fix
-      const legacyRole = user.role;
-      if (!legacyRole || (user.roles?.length ?? 0) > 0) continue;
+      const legacyRole = user.role;
+      if (!legacyRole) continue;
+
+      const nextRoles = Array.from(new Set([...(user.roles ?? []), legacyRole]));

       await ctx.db.patch(user._id, {
-        roles: Array.from(new Set([...(user.roles ?? []), legacyRole])),
+        roles: nextRoles,
         role: undefined,
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const legacyRole = user.role;
if (!legacyRole || (user.roles?.length ?? 0) > 0) continue;
await ctx.db.patch(user._id, {
roles: Array.from(new Set([...(user.roles ?? []), legacyRole])),
role: undefined,
});
const legacyRole = user.role;
if (!legacyRole) continue;
const nextRoles = Array.from(new Set([...(user.roles ?? []), legacyRole]));
await ctx.db.patch(user._id, {
roles: nextRoles,
role: undefined,
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@convex/admin.ts` around lines 307 - 313, The migration currently skips users
that already have any roles because of the condition if (!legacyRole ||
(user.roles?.length ?? 0) > 0) continue; — change the logic to only skip when
legacyRole is falsy and, when legacyRole exists, always call
ctx.db.patch(user._id, ...) to merge legacyRole into user.roles (Array.from(new
Set([...(user.roles ?? []), legacyRole]))) and set role: undefined so the legacy
field is cleared; update the condition to if (!legacyRole) continue; and keep
the existing ctx.db.patch call (merging and clearing) to ensure backfill and
cleanup occur even when user.roles is populated.

Comment thread convex/users.ts
Comment on lines +37 to +41
// TODO: Remove this legacy-role fallback after migrateLegacyUserRoles has run in production.
if ((existing.roles?.length ?? 0) === 0 && existing.role) {
patch.roles = Array.from(new Set([existing.role]));
patch.role = undefined;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't leave existing.role behind when roles already exists.

This fallback only runs when existing.roles is empty, so any user who already has roles plus a leftover legacy role never gets merged or cleaned up on sign-in. Treat existing.role as additive whenever it is present, then clear it.

Suggested fix
-      if ((existing.roles?.length ?? 0) === 0 && existing.role) {
-        patch.roles = Array.from(new Set([existing.role]));
+      if (existing.role) {
+        patch.roles = Array.from(
+          new Set([...(existing.roles ?? []), existing.role]),
+        );
         patch.role = undefined;
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// TODO: Remove this legacy-role fallback after migrateLegacyUserRoles has run in production.
if ((existing.roles?.length ?? 0) === 0 && existing.role) {
patch.roles = Array.from(new Set([existing.role]));
patch.role = undefined;
}
// TODO: Remove this legacy-role fallback after migrateLegacyUserRoles has run in production.
if (existing.role) {
patch.roles = Array.from(
new Set([...(existing.roles ?? []), existing.role]),
);
patch.role = undefined;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@convex/users.ts` around lines 37 - 41, The current fallback only migrates
legacy existing.role when existing.roles is empty; change the logic in
convex/users.ts so that whenever existing.role is present you merge it into
patch.roles and clear patch.role. Specifically, detect existing.role (regardless
of existing.roles length), set patch.roles = Array.from(new
Set([...(existing.roles ?? []), existing.role])) and then set patch.role =
undefined to ensure the legacy field is always consumed/cleared.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant