Skip to content

Conversation

@ayushjrathod-dd
Copy link

@ayushjrathod-dd ayushjrathod-dd commented Dec 9, 2025

CodeAnt-AI Description

Add feature opt-in and management UI with opt-in banners

What Changed

  • Users can enable or disable features from Settings: new pages for My account, Team, and Organization show feature toggles and persist changes.
  • Team and Organization pages respect PBAC read permissions; unauthorized users see a clear "no permission to view" message.
  • A dismissible opt-in banner appears when a feature slug is provided via URL (?feature=slug); users can opt in from the banner or dismiss it (dismissals are stored in localStorage).
  • Backend and client APIs added to list features, set feature state for user/team/org, and opt into allowlisted features; the system treats a database row's existence as "enabled".
  • "bookings-v3" is added to the opt-in allowlist and new UI text strings added for features and banner actions.

Impact

✅ Manage team and organization feature toggles from Settings
✅ Can opt into new features from a URL-driven banner
✅ Dismissed opt-in banners persist across sessions

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

Summary by CodeRabbit

  • New Features
    • Added "Features" settings section across account levels (personal, organization, and team) to enable users to discover and toggle features on or off.
    • Introduced feature opt-in banners allowing users to easily adopt new capabilities with a single action.
    • Added permission controls for administrators to manage feature access and availability within their organizations and teams.

✏️ Tip: You can customize this high-level summary in your review settings.

devin-ai-integration bot and others added 2 commits December 8, 2025 16:01
- Add enabled column to UserFeatures and TeamFeatures tables
- Add Feature resource to PBAC registry with CRUD actions
- Update FeaturesRepository with methods for enabled column
- Create feature-management package with config, service, and tRPC router
- Add user, team, and organization features settings pages
- Add feature opt-in banner component and hook
- Update settings navigation to include features pages
- Add i18n strings for all new UI elements

Co-Authored-By: [email protected] <[email protected]>
- Remove enabled column from UserFeatures and TeamFeatures schema
- Delete migration that added the enabled column
- Update FeaturesRepository to use row-existence pattern:
  - Row exists = feature enabled
  - Delete row to disable feature
- Update FeatureManagementService to check row existence
- Update tRPC router to use ctx.prisma instead of importing prisma directly

Co-Authored-By: [email protected] <[email protected]>
@codeant-ai
Copy link

codeant-ai bot commented Dec 9, 2025

CodeAnt AI is reviewing your PR.


Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@gemini-code-assist
Copy link

Summary of Changes

Hello @ayushjrathod-dd, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a comprehensive feature opt-in system, empowering users, teams, and organizations to manage their feature preferences through dedicated settings interfaces. It includes a user-friendly banner mechanism to highlight and allow opting into new functionalities, such as 'bookings-v3'. The system is built on robust backend services and tRPC endpoints for managing feature states and is seamlessly integrated with the existing Permission-Based Access Control (PBAC) system to ensure secure and authorized access to feature configurations.

Highlights

  • Feature Management UI: Introduced new dedicated settings pages and navigation tabs for managing features at the user, team, and organization levels, providing a centralized interface for feature control.
  • Feature Opt-In System: Implemented a new banner-based opt-in system that allows users to discover and enable new features, such as 'bookings-v3', with options to 'Try it now' or 'Dismiss'.
  • Backend Services and API: Developed a comprehensive backend service and tRPC endpoints for listing, enabling, and disabling features for users, teams, and organizations, ensuring robust data management.
  • Permission-Based Access Control (PBAC): Integrated feature management with the existing PBAC system, defining specific CRUD permissions for the 'Feature' resource to control who can view and modify feature flags.
  • Internationalization Support: Added new internationalization keys to support the new feature management UI and descriptions, making the system ready for multiple languages.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@codeant-ai codeant-ai bot added the size:XXL This PR changes 1000+ lines, ignoring generated files label Dec 9, 2025
@coderabbitai
Copy link

coderabbitai bot commented Dec 9, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

This pull request introduces a comprehensive feature management system, adding navigation tabs and settings pages for feature preferences across user, organization, and team scopes. It includes UI components for displaying feature toggles, a TRPC-backed service layer for feature operations, a configuration-driven opt-in banner system with localStorage persistence, permission registry extensions, and repository methods for feature flag persistence.

Changes

Cohort / File(s) Change Summary
Settings Navigation & Pages
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx, apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx, apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx, apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx
Added "features" navigation tabs to my-account, organization, and team settings hierarchies. Created three new Next.js page components with metadata generation; pages perform authentication/authorization checks (session validation, PBAC permission checks for organization) before rendering corresponding feature view components.
Feature View Components
apps/web/modules/settings/my-account/features-view.tsx, apps/web/modules/settings/organizations/organization-features-view.tsx, apps/web/modules/settings/teams/team-features-view.tsx
Introduced three client-side React components that fetch per-scope features via TRPC, render SettingsToggle controls for globally-enabled features, handle toggle mutations with success/error toasts, and manage loading states with skeleton loaders. Each component filters for globally-enabled features and persists user state changes.
Feature Management Service & TRPC
packages/features/feature-management/services/FeatureManagementService.ts, packages/features/feature-management/trpc/router.ts
Created FeatureManagementService encapsulating feature flag operations (list/set for user/team/organization scopes, opt-in eligibility checks). Exposed TRPC router with authenticated endpoints for listing features, toggling enablement, and managing opt-in operations; includes PBAC enforcement for team/organization mutations.
Feature Configuration & Hooks
packages/features/feature-management/config/feature-management.config.ts, packages/features/feature-management/hooks/useFeatureOptInBanner.ts
Added OptInFeatureConfig interface and OPT_IN_FEATURES allowlist with configuration utilities. Implemented useFeatureOptInBanner hook that manages URL-driven opt-in banner display, dismissed state via localStorage, and feature eligibility tracking.
Opt-in Banner Component & Utilities
packages/features/feature-management/components/FeatureOptInBanner.tsx
Introduced FeatureOptInBanner component rendering a TopBanner with opt-in action and dismiss functionality. Exported helper functions (getDismissedBanners, dismissBanner, isBannerDismissed) for managing dismissed features in localStorage with DISMISSED_BANNERS_KEY constant.
Repository Extensions
packages/features/flags/features.repository.interface.ts, packages/features/flags/features.repository.ts
Extended IFeaturesRepository interface and FeaturesRepository implementation with methods for per-user and per-team feature enablement (setUserFeatureEnabled, setTeamFeatureEnabled), row-existence-based feature retrieval (getUserFeature, getTeamFeature, getUserFeatures, getTeamFeaturesWithDetails), and non-hierarchical user feature checks. Updated team feature enablement to persist assignedBy metadata.
Permissions & Public Exports
packages/features/pbac/domain/types/permission-registry.ts, packages/features/feature-management/index.ts, packages/trpc/server/routers/viewer/_router.tsx
Added Feature resource to PBAC permission registry with CRUD actions and i18n keys. Created feature-management barrel export exposing router, service, config, components, hooks, and types. Integrated featureManagementRouter into main viewer TRPC router.
Localization
apps/web/public/static/locales/en/common.json
Added 15 new localization strings for feature management UI, PBAC permissions descriptions, and feature-specific titles including features_description, team_features_description, organization_features_description, no_features_available, PBAC resource and permission descriptions, and bookings-v3 opt-in feature metadata.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Service logic: Review FeatureManagementService methods for correct computation of enabled state, eligible opt-in features, and row-existence semantics across user/team/organization scopes
  • PBAC integration: Verify permission registry entries, permission checks in TRPC router endpoints, and scope/dependency definitions
  • TRPC router: Validate input schemas (zod), error handling for non-allowlisted opt-in attempts, and cache invalidation patterns
  • Opt-in banner hook: Examine localStorage access guards (non-browser environments), useCallback dependencies, and feature eligibility filtering logic
  • Repository methods: Confirm feature retrieval queries include related metadata (feature.slug, feature.enabled, feature.description) and row upserts correctly persist assignedBy field
  • Component patterns: Verify consistency across three feature view components in mutation handling, loading states, and permission checks

Pre-merge checks and finishing touches

❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title is vague and does not clearly convey the main purpose of the changes. It uses a generic branch naming convention with an ID rather than describing the actual feature being added. Consider using a more descriptive title that clearly explains the feature, such as 'Add feature opt-in management system with UI controls and permissions' or similar.
✅ Passed checks (2 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch devin/feature-opt-in-system-1765208720

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.

@refacto-visz
Copy link

refacto-visz bot commented Dec 9, 2025

Refacto PR Summary

Implemented comprehensive feature management system enabling users, teams, and organizations to control feature flags through settings UI and opt-in banner system. The architecture uses row-existence semantics where database records indicate feature enablement, with hierarchical permission controls and TRPC endpoints for real-time feature toggling.

Key Changes:

  • Added feature management service layer with FeatureManagementService handling user/team/org feature controls
  • Extended FeaturesRepository with CRUD operations using row-existence pattern (create row = enable, delete row = disable)
  • Implemented opt-in banner system with localStorage dismissal tracking and URL parameter triggering
  • Created settings pages for user (/settings/my-account/features), team, and organization feature management
  • Added PBAC permissions for feature resource with create/read/update/delete scopes
  • Integrated TRPC router with endpoints for listing, enabling/disabling, and opt-in functionality

Change Highlights

Click to expand
  • packages/features/feature-management/services/FeatureManagementService.ts: Core business logic for feature enablement using row-existence semantics
  • packages/features/flags/features.repository.ts: Extended repository with setUserFeatureEnabled/setTeamFeatureEnabled methods
  • packages/features/feature-management/trpc/router.ts: TRPC endpoints for feature management operations
  • apps/web/modules/settings/my-account/features-view.tsx: User feature settings UI with toggle controls
  • packages/features/feature-management/components/FeatureOptInBanner.tsx: Banner component for feature opt-in with localStorage tracking
  • packages/features/feature-management/hooks/useFeatureOptInBanner.ts: Hook managing banner display logic and URL parameter detection
  • packages/features/pbac/domain/types/permission-registry.ts: Added Feature resource with CRUD permissions
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx: Navigation integration for feature settings

Sequence Diagram

sequenceDiagram
    participant U as User
    participant UI as Settings UI
    participant TRPC as TRPC Router
    participant FMS as FeatureManagementService
    participant FR as FeaturesRepository
    participant DB as Database
    
    U->>UI: Toggle feature switch
    UI->>TRPC: setUserFeatureEnabled(slug, enabled)
    TRPC->>FMS: setUserFeatureEnabled()
    FMS->>FR: setUserFeatureEnabled()
    alt Enable Feature
        FR->>DB: INSERT UserFeatures row
    else Disable Feature
        FR->>DB: DELETE UserFeatures row
    end
    DB-->>FR: Operation result
    FR-->>FMS: Success
    FMS-->>TRPC: Success response
    TRPC-->>UI: Feature updated
    UI->>UI: Refresh feature list
Loading

Testing Guide

Click to expand
  1. Feature Settings Access: Navigate to /settings/my-account/features, verify feature list displays with toggle switches for globally enabled features
  2. Feature Toggle: Enable/disable a feature via toggle switch, confirm database UserFeatures record created/deleted and UI updates immediately
  3. Opt-in Banner: Visit page with ?feature=bookings-v3 parameter, verify banner appears for eligible features and disappears after opt-in or dismissal
  4. Team Feature Management: Access /settings/teams/{id}/features as team admin, verify team-level feature controls work independently from user settings
  5. Organization Permissions: Test organization feature settings with different user roles (member/admin/owner), confirm PBAC restrictions apply correctly

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (12)
apps/web/modules/settings/organizations/organization-features-view.tsx (1)

30-32: Consider adding error state handling for the query.

If the query fails, users see no feedback. The component will either stay in loading state forever or show "no features available" without indicating an error occurred.

-  const { data: features, isLoading } = trpc.viewer.featureManagement.listForOrganization.useQuery({
+  const { data: features, isLoading, isError } = trpc.viewer.featureManagement.listForOrganization.useQuery({
     organizationId,
   });

Then handle isError in the render logic to show an appropriate error message.

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx (1)

26-39: Unused canUpdate permission is computed but never used.

The getResourcePermissions call computes both canRead and canUpdate (via fallbackRoles), but only canRead is destructured and used. If canUpdate is intended for future use, consider documenting this; otherwise, remove the update fallback to avoid unnecessary computation.

-  const { canRead } = await getResourcePermissions({
+  const { canRead } = await getResourcePermissions({
     userId: session.user.id,
     teamId: session.user.profile.organizationId,
     resource: Resource.Feature,
     userRole: session.user.org.role,
     fallbackRoles: {
       read: {
         roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
       },
-      update: {
-        roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
-      },
     },
   });
packages/features/feature-management/hooks/useFeatureOptInBanner.ts (1)

49-62: Potential race: dismissed features may not be loaded when eligibility check runs.

The localStorage effect and the eligibility effect can race. If eligibleFeatures resolves before the localStorage read completes (both happen on mount), dismissedFeatures will still be the initial empty array, potentially showing a dismissed banner briefly.

Consider initializing dismissedFeatures synchronously or combining the effects.

-  const [dismissedFeatures, setDismissedFeatures] = useState<string[]>([]);
+  const [dismissedFeatures, setDismissedFeatures] = useState<string[]>(() => {
+    if (typeof window !== "undefined") {
+      try {
+        const stored = localStorage.getItem("cal_feature_banners_dismissed");
+        return stored ? JSON.parse(stored) : [];
+      } catch {
+        return [];
+      }
+    }
+    return [];
+  });

This initializes state synchronously on the client, avoiding the race.

apps/web/modules/settings/my-account/features-view.tsx (1)

10-20: Consider extracting SkeletonLoader to a shared location.

This SkeletonLoader component is duplicated verbatim in team-features-view.tsx. Consider extracting it to a shared module (e.g., @calcom/ui/components/skeleton or a local shared module) to reduce duplication.

packages/features/feature-management/trpc/router.ts (1)

146-161: Use a TRPC-specific error type for invalid opt-in requests

optInToFeature throws a plain Error("Feature is not available for opt-in"). In TRPC, this will surface as an internal error rather than a clear client error code. Prefer throwing a TRPCError with a BAD_REQUEST (or similar) code so callers can distinguish invalid opt-in attempts from server failures.

Example:

-      if (!service.isFeatureInOptInAllowlist(input.featureSlug)) {
-        throw new Error("Feature is not available for opt-in");
-      }
+      if (!service.isFeatureInOptInAllowlist(input.featureSlug)) {
+        throw new TRPCError({
+          code: "BAD_REQUEST",
+          message: "Feature is not available for opt-in",
+        });
+      }

(You’d need to import TRPCError from your TRPC server package.)

packages/features/feature-management/components/FeatureOptInBanner.tsx (2)

11-36: Local dismissal storage is robust enough; consider a light shape check

The dismissal helpers correctly guard against window being undefined and swallow localStorage errors, which is appropriate for this banner use case. If you ever expect other code to write to DISMISSED_BANNERS_KEY, you may want to defensively ensure JSON.parse returns an array before calling .includes, but it’s not required for current usage.


48-63: Consider basic error feedback on failed opt-in mutations

On opt-in failure, the banner silently stays in place with no feedback. Depending on how critical these opt-ins are, you might want to at least log or surface a minimal error state (e.g., toast, disabled retry button) in the mutation onError callback.

packages/features/flags/features.repository.ts (3)

300-400: Row-existence setters for user/team features match the documented semantics

setUserFeatureEnabled and setTeamFeatureEnabled correctly implement the “row-existence = enabled” model by upserting when enabled === true and deleting when enabled === false. Unique compound keys (userId_featureId / teamId_featureId) are used appropriately.

The two methods are nearly identical; if this pattern grows further, you may consider a small internal helper to reduce duplication, but that’s optional.


402-430: Clarify return typing and JSDoc for getUserFeature / getTeamFeature

Both methods return the raw Prisma model instances via findFirst, but:

  • The interface signatures use Promise<unknown>.
  • The JSDoc for getTeamFeature says Promise<TeamFeatures | null>, where TeamFeatures is also used as a map type from ./config.

This isn’t a runtime bug, but the naming and typing are a bit confusing. Consider explicitly typing these to the Prisma model types (e.g., Prisma.UserFeatures | null, Prisma.TeamFeatures | null) and tightening the interface signatures accordingly, or adjusting the JSDoc to avoid clashing with the config TeamFeatures type.


495-515: Cache invalidation after user/team toggles is conservative but potentially unnecessary

getTeamsWithFeatureEnabled depends on TeamFeatures rows and the global feature map, but setUserFeatureEnabled/setTeamFeatureEnabled only mutate UserFeatures/TeamFeatures. Clearing the global featuresCache in these setters is safe but may cause extra DB reads for Feature definitions that haven’t changed.

If you want to shave a bit of overhead, you could skip this.clearCache() in the user/team setters and only clear it where Feature rows themselves are modified.

packages/features/flags/features.repository.interface.ts (1)

3-14: Interface extensions match implementation; consider tightening return types

The new methods (checkIfUserHasFeatureNonHierarchical, setters, and getFeature APIs) align with the repository implementation, but the signatures for the getters all return unknown/unknown[]. Since the concrete class returns Prisma model shapes, you could improve type safety by exporting those model types and using them here instead of unknown, especially for getUserFeatures / getTeamFeaturesWithDetails which are used by the feature-management service.

packages/features/feature-management/services/FeatureManagementService.ts (1)

32-76: LGTM! Row-existence semantics are consistently applied.

The list methods correctly implement the feature-enabled logic based on row existence. The delegation from listFeaturesForOrganization to listFeaturesForTeam is appropriate given the organizational structure.

Consider adding input validation for userId, teamId, and organizationId (e.g., checking for positive integers) to fail fast on invalid inputs.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f350542 and 3c54b1f.

📒 Files selected for processing (18)
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx (3 hunks)
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx (1 hunks)
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx (1 hunks)
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx (1 hunks)
  • apps/web/modules/settings/my-account/features-view.tsx (1 hunks)
  • apps/web/modules/settings/organizations/organization-features-view.tsx (1 hunks)
  • apps/web/modules/settings/teams/team-features-view.tsx (1 hunks)
  • apps/web/public/static/locales/en/common.json (1 hunks)
  • packages/features/feature-management/components/FeatureOptInBanner.tsx (1 hunks)
  • packages/features/feature-management/config/feature-management.config.ts (1 hunks)
  • packages/features/feature-management/hooks/useFeatureOptInBanner.ts (1 hunks)
  • packages/features/feature-management/index.ts (1 hunks)
  • packages/features/feature-management/services/FeatureManagementService.ts (1 hunks)
  • packages/features/feature-management/trpc/router.ts (1 hunks)
  • packages/features/flags/features.repository.interface.ts (1 hunks)
  • packages/features/flags/features.repository.ts (9 hunks)
  • packages/features/pbac/domain/types/permission-registry.ts (2 hunks)
  • packages/trpc/server/routers/viewer/_router.tsx (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (11)
apps/web/modules/settings/organizations/organization-features-view.tsx (1)
packages/trpc/react/trpc.ts (1)
  • trpc (50-134)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx (3)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx (1)
  • generateMetadata (11-18)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx (1)
  • generateMetadata (12-19)
apps/web/app/_utils.tsx (1)
  • _generateMetadata (53-81)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx (5)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx (1)
  • generateMetadata (11-18)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx (1)
  • generateMetadata (5-12)
apps/web/app/_utils.tsx (2)
  • _generateMetadata (53-81)
  • getTranslate (15-19)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.tsx (1)
  • validateUserHasOrg (19-30)
packages/platform/libraries/index.ts (1)
  • MembershipRole (32-32)
packages/features/pbac/domain/types/permission-registry.ts (1)
packages/platform/libraries/pbac.ts (1)
  • Scope (2-2)
packages/features/feature-management/config/feature-management.config.ts (2)
packages/features/feature-management/index.ts (5)
  • OptInFeatureConfig (10-10)
  • OPT_IN_FEATURES (5-5)
  • getOptInFeatureConfig (6-6)
  • isFeatureInOptInAllowlist (7-7)
  • getOptInFeatureSlugs (8-8)
packages/features/feature-management/services/FeatureManagementService.ts (2)
  • getOptInFeatureConfig (163-165)
  • isFeatureInOptInAllowlist (156-158)
packages/features/flags/features.repository.interface.ts (1)
packages/features/flags/config.ts (1)
  • AppFlags (5-37)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx (1)
packages/ui/components/navigation/tabs/VerticalTabs.tsx (1)
  • VerticalTabItem (6-6)
packages/features/feature-management/trpc/router.ts (2)
packages/features/flags/features.repository.ts (1)
  • FeaturesRepository (18-558)
packages/features/feature-management/services/FeatureManagementService.ts (1)
  • FeatureManagementService (25-175)
packages/trpc/server/routers/viewer/_router.tsx (2)
packages/features/feature-management/trpc/router.ts (1)
  • featureManagementRouter (15-162)
packages/features/feature-management/index.ts (1)
  • featureManagementRouter (1-1)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx (4)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx (1)
  • generateMetadata (12-19)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx (1)
  • generateMetadata (5-12)
apps/web/app/_utils.tsx (1)
  • _generateMetadata (53-81)
apps/web/lib/buildLegacyCtx.ts (1)
  • buildLegacyRequest (47-49)
apps/web/modules/settings/teams/team-features-view.tsx (1)
packages/trpc/react/trpc.ts (1)
  • trpc (50-134)
🔇 Additional comments (24)
packages/features/pbac/domain/types/permission-registry.ts (2)

16-16: LGTM!

The new Feature resource enum value follows the established naming convention.


754-787: LGTM!

The permission registry entry for Feature is well-structured:

  • Follows the same pattern as other resources (e.g., Watchlist)
  • Create and Delete are appropriately scoped to Organization only
  • Read and Update are available in both scopes
  • All write operations correctly depend on feature.read
  • i18n keys follow the established naming convention
packages/trpc/server/routers/viewer/_router.tsx (1)

2-2: LGTM!

The featureManagementRouter is correctly imported and mounted. The naming distinction between features (existing flag router) and featureManagement (new management router) is clear.

Also applies to: 79-79

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx (3)

47-47: LGTM!

The features tab for my_account follows the established pattern with appropriate tracking metadata.


95-99: LGTM!

The features tab for organizations is correctly placed and follows the existing pattern.


540-547: LGTM!

The team features navigation item follows the same pattern as other team settings tabs (appearance, billing, settings) and includes appropriate tracking metadata with the team ID.

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx (2)

20-30: LGTM!

The page correctly implements server-side authentication with proper redirect handling. Since this is for "my-account", the FeaturesView component appropriately uses the session user's context rather than requiring an explicit user ID prop.


11-18: LGTM!

The metadata generation follows the established pattern used by other feature pages.

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx (1)

14-16: The original review comment is incorrect. No changes needed.

TeamFeaturesView is a client-side component that uses the useParams() hook to access the team ID from route parameters (line 26-27 in team-features-view.tsx). It does not accept teamId as a prop. The Page component correctly renders <TeamFeaturesView /> without passing props, allowing the client component to retrieve the ID from the URL params itself.

Likely an incorrect or invalid review comment.

apps/web/modules/settings/organizations/organization-features-view.tsx (1)

68-72: The review comment is incorrect. The FeatureWithStatus interface returned by listFeaturesForOrganization only contains the fields slug, enabled, globallyEnabled, description, and type—there is no name or title field available. The suggested refactor using feature.name || feature.slug would fail at runtime. Using feature.slug as the title is the appropriate approach given the available data structure.

Likely an incorrect or invalid review comment.

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx (2)

12-19: LGTM!

The generateMetadata implementation follows the established pattern used in other feature pages (my-account, teams), correctly passing the organization-specific description key and path.


41-54: LGTM!

The permission-based conditional rendering is well-implemented. The no-permission fallback provides clear user feedback, and the happy path correctly passes the organizationId to the view component.

apps/web/modules/settings/teams/team-features-view.tsx (2)

35-43: LGTM!

The mutation setup correctly invalidates the cache on success and provides user feedback via toasts. Disabling toggles during isPending prevents race conditions from rapid toggling.


60-90: LGTM!

The feature list rendering logic is solid—filtering by globallyEnabled, handling empty state, and properly keying list items by feature.slug.

packages/features/feature-management/hooks/useFeatureOptInBanner.ts (1)

64-70: LGTM!

The dismissCurrentFeature callback properly persists the dismissal, updates local state, and clears the displayed feature in a single action.

apps/web/modules/settings/my-account/features-view.tsx (2)

22-47: LGTM!

The query and mutation setup is correct. Cache invalidation on success and error feedback via toasts follow established patterns.


49-78: LGTM!

The feature list rendering correctly filters by globallyEnabled, handles empty states gracefully, and disables toggles during pending mutations to prevent race conditions.

packages/features/feature-management/config/feature-management.config.ts (2)

32-48: LGTM!

The utility functions are clean, well-documented, and appropriate for the current allowlist size. The implementations are straightforward and idiomatic.


19-26: The learnMoreUrl pointing to https://cal.com/docs/features/bookings-v3 is valid and publicly accessible.

packages/features/feature-management/index.ts (1)

1-20: Barrel exports look consistent and cohesive

The index cleanly re-exports the router, service, config helpers, banner, and hook, giving consumers a single entrypoint for feature-management concerns. No issues spotted.

apps/web/public/static/locales/en/common.json (1)

1256-1269: New feature-management and PBAC strings are consistent and descriptive

The added keys for feature pages, opt-in CTA, and feature PBAC descriptions read well and align with the new UI and permission concepts. No localization or naming issues from what’s visible here.

packages/features/flags/features.repository.ts (2)

274-294: Updating assignedBy on team re-assignment is correct

The enableFeatureForTeam upsert now updates assignedBy on existing rows, which keeps audit metadata current while preserving row-existence semantics. This change is sound.


517-557: Detail fetchers for user/team features align with feature-management needs

getUserFeatures and getTeamFeaturesWithDetails include the related feature with slug, enabled, description, and type, which is exactly what the feature-management service needs to build rich views. The queries look correct and match the row-existence model.

packages/features/feature-management/services/FeatureManagementService.ts (1)

156-174: LGTM! Helper methods are straightforward and correct.

The helper methods follow consistent patterns:

  • Proper delegation to configuration utilities
  • Correct row-existence semantics in hasUserOptedIn

Comment on lines +26 to +33
const params = useParams<{ id: string }>();
const teamId = params?.id ? parseInt(params.id, 10) : null;
const utils = trpc.useUtils();

const { data: features, isLoading } = trpc.viewer.featureManagement.listForTeam.useQuery(
{ teamId: teamId! },
{ enabled: !!teamId }
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Handle potential NaN from parseInt.

If params.id contains a non-numeric string, parseInt returns NaN, which is truthy. This would cause the query to execute with NaN as teamId. Consider adding explicit validation.

-  const teamId = params?.id ? parseInt(params.id, 10) : null;
+  const parsedId = params?.id ? parseInt(params.id, 10) : NaN;
+  const teamId = Number.isNaN(parsedId) ? null : parsedId;
📝 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 params = useParams<{ id: string }>();
const teamId = params?.id ? parseInt(params.id, 10) : null;
const utils = trpc.useUtils();
const { data: features, isLoading } = trpc.viewer.featureManagement.listForTeam.useQuery(
{ teamId: teamId! },
{ enabled: !!teamId }
);
const params = useParams<{ id: string }>();
const parsedId = params?.id ? parseInt(params.id, 10) : NaN;
const teamId = Number.isNaN(parsedId) ? null : parsedId;
const utils = trpc.useUtils();
const { data: features, isLoading } = trpc.viewer.featureManagement.listForTeam.useQuery(
{ teamId: teamId! },
{ enabled: !!teamId }
);
🤖 Prompt for AI Agents
In apps/web/modules/settings/teams/team-features-view.tsx around lines 26 to 33,
parseInt(params.id, 10) can return NaN which is truthy and causes the query to
run with an invalid teamId; validate the parsed value explicitly (e.g. const
parsedId = parseInt(params.id, 10); const teamId = Number.isInteger(parsedId) ?
parsedId : null) and change the query enabled flag to check teamId !== null (or
Boolean(teamId) if zero is impossible) so the hook never runs with NaN.

Comment on lines +42 to +47
useEffect(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem("cal_feature_banners_dismissed");
setDismissedFeatures(stored ? JSON.parse(stored) : []);
}
}, []);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Unsafe JSON.parse can throw on corrupted localStorage data.

If localStorage contains malformed JSON, JSON.parse will throw and break the component. Wrap in try/catch with a fallback.

   useEffect(() => {
     if (typeof window !== "undefined") {
-      const stored = localStorage.getItem("cal_feature_banners_dismissed");
-      setDismissedFeatures(stored ? JSON.parse(stored) : []);
+      try {
+        const stored = localStorage.getItem("cal_feature_banners_dismissed");
+        setDismissedFeatures(stored ? JSON.parse(stored) : []);
+      } catch {
+        setDismissedFeatures([]);
+      }
     }
   }, []);
📝 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
useEffect(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem("cal_feature_banners_dismissed");
setDismissedFeatures(stored ? JSON.parse(stored) : []);
}
}, []);
useEffect(() => {
if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem("cal_feature_banners_dismissed");
setDismissedFeatures(stored ? JSON.parse(stored) : []);
} catch {
setDismissedFeatures([]);
}
}
}, []);
🤖 Prompt for AI Agents
In packages/features/feature-management/hooks/useFeatureOptInBanner.ts around
lines 42 to 47, the code calls JSON.parse on localStorage data which can throw
on malformed JSON; wrap the parse in a try/catch, default to an empty array on
error (and optionally when parsed value is not an array), and then call
setDismissedFeatures with the safe fallback so the component won’t crash if
localStorage is corrupted.

Comment on lines +81 to +113
async setUserFeatureEnabled(
userId: number,
featureSlug: string,
enabled: boolean,
assignedBy: string
): Promise<void> {
await this.featuresRepository.setUserFeatureEnabled(userId, featureSlug, enabled, assignedBy);
}

/**
* Set the enabled status of a feature for a team.
*/
async setTeamFeatureEnabled(
teamId: number,
featureSlug: string,
enabled: boolean,
assignedBy: string
): Promise<void> {
await this.featuresRepository.setTeamFeatureEnabled(teamId, featureSlug, enabled, assignedBy);
}

/**
* Set the enabled status of a feature for an organization.
* Organizations are teams, so we use the same method.
*/
async setOrganizationFeatureEnabled(
organizationId: number,
featureSlug: string,
enabled: boolean,
assignedBy: string
): Promise<void> {
await this.featuresRepository.setTeamFeatureEnabled(organizationId, featureSlug, enabled, assignedBy);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find TRPC router files related to feature management
fd -e ts -e tsx 'router|trpc' packages/features/feature-management/ | grep -E '(router|trpc)' | head -20

Repository: coderabbit-test/cal.com

Length of output: 118


🏁 Script executed:

#!/bin/bash
# Search for the feature management router and the set methods
rg -l 'setUserFeatureEnabled|setTeamFeatureEnabled|setOrganizationFeatureEnabled' packages/features/feature-management/ --type=ts -g '*.ts'

Repository: coderabbit-test/cal.com

Length of output: 192


🏁 Script executed:

#!/bin/bash
# Read the TRPC router file to examine authorization
cat -n packages/features/feature-management/trpc/router.ts

Repository: coderabbit-test/cal.com

Length of output: 5887


Add authorization checks before allowing feature modifications for teams and organizations.

The setTeamFeatureEnabled and setOrganizationFeatureEnabled mutations use authedProcedure only, which enforces authentication but not authorization. Any authenticated user can enable/disable features for any team or organization. The router comments claim "Requires appropriate PBAC permissions" but the code does not enforce these checks. Add permission validation (e.g., verifying the user is a team admin or has org management rights) before delegating to the service layer.

🤖 Prompt for AI Agents
packages/features/feature-management/services/FeatureManagementService.ts lines
81-113: The issue is that setTeamFeatureEnabled and
setOrganizationFeatureEnabled perform only authentication but no authorization,
allowing any authenticated user to modify any team/org feature; before calling
the repository, validate the caller's permissions (e.g., require team admin or
org management rights) using the request context or an injected authorization
service/ PBAC check, and if the check fails throw a Forbidden/Unauthorized
error; for organization treat organizationId as a team id and reuse the same
permission check; only call this.featuresRepository.setTeamFeatureEnabled(...)
after the permission check succeeds.

Comment on lines +122 to +151
async getEligibleOptInFeatures(userId: number): Promise<EligibleOptInFeature[]> {
const eligibleFeatures: EligibleOptInFeature[] = [];
const optInSlugs = getOptInFeatureSlugs();

for (const slug of optInSlugs) {
const config = getOptInFeatureConfig(slug);
if (!config) continue;

const userFeature = await this.featuresRepository.getUserFeature(userId, slug);

// Row exists = user has already opted in
if (userFeature) {
continue;
}

const isGloballyEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally(
slug as Parameters<typeof this.featuresRepository.checkIfFeatureIsEnabledGlobally>[0]
);
if (!isGloballyEnabled) continue;

eligibleFeatures.push({
slug: config.slug,
titleI18nKey: config.titleI18nKey,
descriptionI18nKey: config.descriptionI18nKey,
learnMoreUrl: config.learnMoreUrl,
});
}

return eligibleFeatures;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

N+1 query performance issue in opt-in feature eligibility check.

The method makes two database calls per opt-in feature (lines 130 and 137), resulting in an N+1 query problem. If there are 10 opt-in features, this generates 20+ database queries.

Refactor to batch the queries:

 async getEligibleOptInFeatures(userId: number): Promise<EligibleOptInFeature[]> {
   const eligibleFeatures: EligibleOptInFeature[] = [];
   const optInSlugs = getOptInFeatureSlugs();
+  
+  // Batch fetch user features for all opt-in slugs
+  const userFeatures = await this.featuresRepository.getUserFeatures(userId);
+  const userFeatureSlugs = new Set(userFeatures.map(uf => uf.feature.slug));
+  
+  // Batch fetch globally enabled features
+  const allFeatures = await this.featuresRepository.getAllFeatures();
+  const globallyEnabledSlugs = new Set(
+    allFeatures.filter(f => f.enabled).map(f => f.slug)
+  );

   for (const slug of optInSlugs) {
     const config = getOptInFeatureConfig(slug);
     if (!config) continue;

-    const userFeature = await this.featuresRepository.getUserFeature(userId, slug);
-
-    // Row exists = user has already opted in
-    if (userFeature) {
+    // Row exists = user has already opted in
+    if (userFeatureSlugs.has(slug)) {
       continue;
     }

-    const isGloballyEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally(
-      slug as Parameters<typeof this.featuresRepository.checkIfFeatureIsEnabledGlobally>[0]
-    );
-    if (!isGloballyEnabled) continue;
+    if (!globallyEnabledSlugs.has(slug)) continue;

     eligibleFeatures.push({
       slug: config.slug,
       titleI18nKey: config.titleI18nKey,
       descriptionI18nKey: config.descriptionI18nKey,
       learnMoreUrl: config.learnMoreUrl,
     });
   }

   return eligibleFeatures;
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/features/feature-management/services/FeatureManagementService.ts
around lines 122 to 151, the loop issues N+1 DB queries by calling
getUserFeature and checkIfFeatureIsEnabledGlobally for each slug; instead, fetch
data in batches up-front: collect optInSlugs, call the repository once to get
all user features for userId and those slugs, and call the repository once to
get global-enabled statuses for all slugs (or a single query returning enabled
slugs), then iterate optInSlugs synchronously using the in-memory maps/sets to
skip already opted-in or globally-disabled features and push eligibleFeatures;
remove per-iteration awaits to eliminate the N+1 queries.

Comment on lines +137 to +139
const isGloballyEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally(
slug as Parameters<typeof this.featuresRepository.checkIfFeatureIsEnabledGlobally>[0]
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, find the file and examine the context around lines 137-139
cat -n packages/features/feature-management/services/FeatureManagementService.ts | sed -n '130,150p'

Repository: coderabbit-test/cal.com

Length of output: 883


🏁 Script executed:

#!/bin/bash
# Find the checkIfFeatureIsEnabledGlobally method signature
rg -n -A10 'checkIfFeatureIsEnabledGlobally' packages/features/feature-management/services/FeatureManagementService.ts

Repository: coderabbit-test/cal.com

Length of output: 567


🏁 Script executed:

#!/bin/bash
# Find the repository interface to see parameter type
fd -e ts -e interface | xargs rg -l 'checkIfFeatureIsEnabledGlobally' 2>/dev/null | head -5

Repository: coderabbit-test/cal.com

Length of output: 364


🏁 Script executed:

#!/bin/bash
# Look for the features repository interface
find packages -name '*.interface.ts' -o -name '*.repository.ts' | xargs rg -l 'checkIfFeatureIsEnabledGlobally' 2>/dev/null

Repository: coderabbit-test/cal.com

Length of output: 241


🏁 Script executed:

#!/bin/bash
# Find getOptInFeatureSlugs method
rg -n -B2 -A5 'getOptInFeatureSlugs' packages/features/feature-management/services/FeatureManagementService.ts

Repository: coderabbit-test/cal.com

Length of output: 738


🏁 Script executed:

#!/bin/bash
# Check the repository interface for checkIfFeatureIsEnabledGlobally signature
cat -n packages/features/flags/features.repository.interface.ts | grep -A5 'checkIfFeatureIsEnabledGlobally'

Repository: coderabbit-test/cal.com

Length of output: 609


🏁 Script executed:

#!/bin/bash
# Find and check the feature-management.config file
fd -e ts 'feature-management.config' packages/features/feature-management/

Repository: coderabbit-test/cal.com

Length of output: 139


🏁 Script executed:

#!/bin/bash
# Find the return type of getOptInFeatureSlugs
rg -n -A10 'export.*getOptInFeatureSlugs' packages/features/feature-management/

Repository: coderabbit-test/cal.com

Length of output: 404


🏁 Script executed:

#!/bin/bash
# Check the implementation of checkIfFeatureIsEnabledGlobally in the repository
rg -n -B2 -A8 'checkIfFeatureIsEnabledGlobally' packages/features/flags/features.repository.ts | head -30

Repository: coderabbit-test/cal.com

Length of output: 954


🏁 Script executed:

#!/bin/bash
# Find AppFlags type definition
rg -n 'type AppFlags\|interface AppFlags' packages/features/ | head -5

Repository: coderabbit-test/cal.com

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Check OPT_IN_FEATURES definition
rg -n -B2 -A20 'const OPT_IN_FEATURES' packages/features/feature-management/config/feature-management.config.ts

Repository: coderabbit-test/cal.com

Length of output: 899


🏁 Script executed:

#!/bin/bash
# Check if there's a type that defines the relationship between OPT_IN_FEATURES and AppFlags
rg -n 'AppFlags' packages/features/feature-management/config/feature-management.config.ts

Repository: coderabbit-test/cal.com

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Find AppFlags type definition - search more broadly
rg -n 'export.*AppFlags|type AppFlags|interface AppFlags' packages/features/flags/

Repository: coderabbit-test/cal.com

Length of output: 538


🏁 Script executed:

#!/bin/bash
# Search the entire features package for AppFlags
rg -n 'type AppFlags\s*=' packages/

Repository: coderabbit-test/cal.com

Length of output: 127


🏁 Script executed:

#!/bin/bash
# Look for where AppFlags might be defined
fd -e ts packages/features/ | xargs rg -l 'AppFlags' | head -10

Repository: coderabbit-test/cal.com

Length of output: 976


🏁 Script executed:

#!/bin/bash
# Read the AppFlags type definition
cat -n packages/features/flags/config.ts | head -50

Repository: coderabbit-test/cal.com

Length of output: 1511


🏁 Script executed:

#!/bin/bash
# Check if "bookings-v3" is in AppFlags
rg -A30 'export type AppFlags' packages/features/flags/config.ts

Repository: coderabbit-test/cal.com

Length of output: 1032


Type assertion indicates loose typing that could cause future bugs.

The type assertion on line 138 bypasses type safety because getOptInFeatureSlugs() returns untyped string[], while checkIfFeatureIsEnabledGlobally() expects keyof AppFlags. Currently this works since "bookings-v3" is a valid AppFlags key, but if future opt-in features are added without updating AppFlags, the type mismatch won't be caught by TypeScript.

Consider refining OPT_IN_FEATURES to explicitly use keyof AppFlags for slug values, eliminating the need for the type assertion and providing compile-time guarantees.

Comment on lines +78 to +118
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),

/**
* Set the enabled status of a feature for an organization.
* Requires appropriate PBAC permissions.
*/
setOrganizationFeatureEnabled: authedProcedure
.input(
z.object({
organizationId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setOrganizationFeatureEnabled(
input.organizationId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's understand the file structure and locate key files
find . -type f -name "router.ts" | grep -E "feature-management|trpc" | head -20

Repository: coderabbit-test/cal.com

Length of output: 120


🏁 Script executed:

# Look for authedProcedure definition
rg -l "authedProcedure" --type ts --type tsx | head -10

Repository: coderabbit-test/cal.com

Length of output: 94


🏁 Script executed:

# Search for FeatureManagementService implementation
rg -l "FeatureManagementService" --type ts --type tsx | head -20

Repository: coderabbit-test/cal.com

Length of output: 94


🏁 Script executed:

# Search for authedProcedure in ts files
rg "authedProcedure\s*=" --type ts | head -20

Repository: coderabbit-test/cal.com

Length of output: 217


🏁 Script executed:

# Search for FeatureManagementService class or export
rg "class FeatureManagementService|export.*FeatureManagementService" --type ts | head -20

Repository: coderabbit-test/cal.com

Length of output: 452


🏁 Script executed:

# Also look at the router file itself to see imports
head -50 ./packages/features/feature-management/trpc/router.ts

Repository: coderabbit-test/cal.com

Length of output: 1661


🏁 Script executed:

# Get the FeatureManagementService file
cat ./packages/features/feature-management/services/FeatureManagementService.ts

Repository: coderabbit-test/cal.com

Length of output: 5772


🏁 Script executed:

# Also check the setTeamFeatureEnabled and setOrganizationFeatureEnabled methods specifically
rg "setTeamFeatureEnabled|setOrganizationFeatureEnabled" ./packages/features/feature-management/services/FeatureManagementService.ts -A 20

Repository: coderabbit-test/cal.com

Length of output: 1414


🏁 Script executed:

# Find FeaturesRepository implementation
rg -l "class FeaturesRepository" --type ts

Repository: coderabbit-test/cal.com

Length of output: 181


🏁 Script executed:

# Get the setTeamFeatureEnabled method from FeaturesRepository
rg "setTeamFeatureEnabled" --type ts -A 15 ./packages/features/flags/

Repository: coderabbit-test/cal.com

Length of output: 1994


🏁 Script executed:

# Search for PBAC or permission-related patterns in the codebase
rg "PBAC|permission|authorize|can.*team|can.*organization" --type ts ./packages/features/feature-management/ | head -20

Repository: coderabbit-test/cal.com

Length of output: 389


🏁 Script executed:

# Get full FeaturesRepository file to understand setTeamFeatureEnabled completely
cat ./packages/features/flags/features.repository.ts | head -150

Repository: coderabbit-test/cal.com

Length of output: 5113


🏁 Script executed:

# Get more of the FeaturesRepository file, particularly the setTeamFeatureEnabled method
sed -n '1,300p' ./packages/features/flags/features.repository.ts

Repository: coderabbit-test/cal.com

Length of output: 10046


🏁 Script executed:

# Also check the router.ts comments context around the PBAC statements
sed -n '70,130p' ./packages/features/feature-management/trpc/router.ts

Repository: coderabbit-test/cal.com

Length of output: 1674


🏁 Script executed:

# Let me verify the complete setTeamFeatureEnabled in FeaturesRepository to be thorough
rg "async setTeamFeatureEnabled" --type ts -A 25 ./packages/features/flags/features.repository.ts

Repository: coderabbit-test/cal.com

Length of output: 659


Add PBAC permission checks before modifying team/organization features

The setTeamFeatureEnabled and setOrganizationFeatureEnabled procedures declare "Requires appropriate PBAC permissions" but do not enforce any. The call chain is:

  • Router: uses authedProcedure (authentication only via isAuthed middleware)
  • Service: delegates directly to repository without permission validation
  • Repository: performs direct upsert on teamFeatures table

An authenticated user can toggle features for any teamId or organizationId they don't own. Add explicit permission checks in the router or service layer to verify the user has permission to modify the specified team/organization before calling the service.

🤖 Prompt for AI Agents
In packages/features/feature-management/trpc/router.ts around lines 78–118 the
procedures claim "Requires appropriate PBAC permissions" but perform no
authorization; before calling the service methods, verify the caller has
permission to modify the given teamId/organizationId (either by calling an
existing PBAC helper on ctx, e.g. ctx.pbac.canModifyTeam(ctx.user.id,
input.teamId) / ctx.pbac.canModifyOrg(ctx.user.id, input.organizationId), or by
querying the database to confirm membership/role), and if the check fails throw
an unauthorized error (e.g. TRPCError with code "FORBIDDEN"); alternatively move
these checks into the service layer and call them there before performing the
upsert. Ensure the permission check happens synchronously prior to
service.setTeamFeatureEnabled and service.setOrganizationFeatureEnabled.

category: "feature",
i18nKey: "pbac_action_read",
descriptionI18nKey: "pbac_desc_view_features",
},
Copy link

Choose a reason for hiding this comment

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

Suggestion: The read permission for feature flags is not scoped to organizations, unlike create and delete, so it will be exposed and considered valid at team scope where feature management is likely not supported, leading to inconsistent permission behavior across scopes. [logic error]

Severity Level: Minor ⚠️

Suggested change
},
scope: [Scope.Organization],
Why it matters? ⭐

In the PR's current PermissionRegistry the Feature resource has Create and Delete actions explicitly scoped to Scope.Organization, but Read is missing a scope entry. That makes "feature.read" implicitly available at Team scope (or at least not excluded), which is inconsistent with the other CRUD actions and likely a logic bug: feature flag management is clearly treated as an org-level concern elsewhere in the same resource. Adding scope: [Scope.Organization] for read fixes a real permission/visibility inconsistency, not a mere stylistic change.

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/features/pbac/domain/types/permission-registry.ts
**Line:** 771:771
**Comment:**
	*Logic Error: The read permission for feature flags is not scoped to organizations, unlike create and delete, so it will be exposed and considered valid at team scope where feature management is likely not supported, leading to inconsistent permission behavior across scopes.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.

category: "feature",
i18nKey: "pbac_action_update",
descriptionI18nKey: "pbac_desc_update_features",
dependsOn: ["feature.read"],
Copy link

Choose a reason for hiding this comment

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

Suggestion: The update permission for feature flags is also missing an organization scope, so it will be treated as valid for team scope even though feature flag management is configured as an organization-level concern for the other actions on this resource, causing scope-inconsistent permission checks and UI. [logic error]

Severity Level: Minor ⚠️

Suggested change
dependsOn: ["feature.read"],
scope: [Scope.Organization],
Why it matters? ⭐

Similar to the read action, Update for Feature lacks an explicit scope while Create/Delete declare Scope.Organization. This results in inconsistent permission model where update could be considered valid at Team scope. Adding scope: [Scope.Organization] corrects the registry semantics and prevents accidental exposure of feature update capabilities at the wrong scope — a real logic/authorization fix.

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/features/pbac/domain/types/permission-registry.ts
**Line:** 777:777
**Comment:**
	*Logic Error: The update permission for feature flags is also missing an organization scope, so it will be treated as valid for team scope even though feature flag management is configured as an organization-level concern for the other actions on this resource, causing scope-inconsistent permission checks and UI.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.

Comment on lines +44 to +45
const stored = localStorage.getItem("cal_feature_banners_dismissed");
setDismissedFeatures(stored ? JSON.parse(stored) : []);
Copy link

Choose a reason for hiding this comment

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

Suggestion: The JSON.parse call on the value from localStorage is not guarded, so if the stored value is malformed (e.g. manually edited, corrupted, or written by an older version), this hook will throw at runtime and break rendering of any component using it; wrapping the access and parse in a try/catch and falling back to an empty list prevents the banner system from crashing in these cases. [possible bug]

Severity Level: Critical 🚨

Suggested change
const stored = localStorage.getItem("cal_feature_banners_dismissed");
setDismissedFeatures(stored ? JSON.parse(stored) : []);
try {
const stored = localStorage.getItem("cal_feature_banners_dismissed");
setDismissedFeatures(stored ? JSON.parse(stored) : []);
} catch {
setDismissedFeatures([]);
}
Why it matters? ⭐

The existing code calls JSON.parse on a localStorage value without guarding against malformed JSON.
If the stored value is corrupted or manually edited, JSON.parse will throw and that exception will bubble up:
breaking rendering for components using this hook. Wrapping the read/parse in try/catch and falling back
to an empty array is a small, low-risk defensive change that prevents a runtime crash and preserves UX.

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/features/feature-management/hooks/useFeatureOptInBanner.ts
**Line:** 44:45
**Comment:**
	*Possible Bug: The `JSON.parse` call on the value from `localStorage` is not guarded, so if the stored value is malformed (e.g. manually edited, corrupted, or written by an older version), this hook will throw at runtime and break rendering of any component using it; wrapping the access and parse in a try/catch and falling back to an empty list prevents the banner system from crashing in these cases.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.

Comment on lines +5 to +101
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import { router } from "@calcom/trpc/server/trpc";

import { FeatureManagementService } from "../services/FeatureManagementService";

const getFeatureManagementService = (prisma: PrismaClient) => {
const featuresRepository = new FeaturesRepository(prisma);
return new FeatureManagementService(featuresRepository);
};

export const featureManagementRouter = router({
/**
* List all features for the current user with their enabled status.
*/
listForUser: authedProcedure.query(async ({ ctx }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForUser(ctx.user.id);
}),

/**
* List all features for a team with their enabled status.
*/
listForTeam: authedProcedure
.input(
z.object({
teamId: z.number(),
})
)
.query(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForTeam(input.teamId);
}),

/**
* List all features for an organization with their enabled status.
*/
listForOrganization: authedProcedure
.input(
z.object({
organizationId: z.number(),
})
)
.query(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForOrganization(input.organizationId);
}),

/**
* Set the enabled status of a feature for the current user.
* Users can always control their own features - no PBAC check needed.
*/
setUserFeatureEnabled: authedProcedure
.input(
z.object({
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setUserFeatureEnabled(
ctx.user.id,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),

/**
* Set the enabled status of a feature for a team.
* Requires appropriate PBAC permissions.
*/
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),

/**
* Set the enabled status of a feature for an organization.
* Requires appropriate PBAC permissions.
*/
setOrganizationFeatureEnabled: authedProcedure
Copy link

Choose a reason for hiding this comment

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

Suggestion: Team and organization feature endpoints currently use the generic authenticated procedure, meaning any logged-in user can list or mutate feature flags for arbitrary teams or organizations by ID, which violates the comments' PBAC requirement and creates an authorization vulnerability; switch these routes to the appropriate admin/org-admin procedures to enforce proper permission checks. [security]

Severity Level: Critical 🚨

Suggested change
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { FeatureManagementService } from "../services/FeatureManagementService";
const getFeatureManagementService = (prisma: PrismaClient) => {
const featuresRepository = new FeaturesRepository(prisma);
return new FeatureManagementService(featuresRepository);
};
export const featureManagementRouter = router({
/**
* List all features for the current user with their enabled status.
*/
listForUser: authedProcedure.query(async ({ ctx }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForUser(ctx.user.id);
}),
/**
* List all features for a team with their enabled status.
*/
listForTeam: authedProcedure
.input(
z.object({
teamId: z.number(),
})
)
.query(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForTeam(input.teamId);
}),
/**
* List all features for an organization with their enabled status.
*/
listForOrganization: authedProcedure
.input(
z.object({
organizationId: z.number(),
})
)
.query(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForOrganization(input.organizationId);
}),
/**
* Set the enabled status of a feature for the current user.
* Users can always control their own features - no PBAC check needed.
*/
setUserFeatureEnabled: authedProcedure
.input(
z.object({
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setUserFeatureEnabled(
ctx.user.id,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
/**
* Set the enabled status of a feature for a team.
* Requires appropriate PBAC permissions.
*/
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
/**
* Set the enabled status of a feature for an organization.
* Requires appropriate PBAC permissions.
*/
setOrganizationFeatureEnabled: authedProcedure
import authedProcedure, {
authedAdminProcedure,
authedOrgAdminProcedure,
} from "@calcom/trpc/server/procedures/authedProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { FeatureManagementService } from "../services/FeatureManagementService";
const getFeatureManagementService = (prisma: PrismaClient) => {
const featuresRepository = new FeaturesRepository(prisma);
return new FeatureManagementService(featuresRepository);
};
export const featureManagementRouter = router({
/**
* List all features for the current user with their enabled status.
*/
listForUser: authedProcedure.query(async ({ ctx }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForUser(ctx.user.id);
}),
/**
* List all features for a team with their enabled status.
* Requires appropriate PBAC permissions.
*/
listForTeam: authedAdminProcedure
.input(
z.object({
teamId: z.number(),
})
)
.query(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForTeam(input.teamId);
}),
/**
* List all features for an organization with their enabled status.
* Requires appropriate PBAC permissions.
*/
listForOrganization: authedOrgAdminProcedure
.input(
z.object({
organizationId: z.number(),
})
)
.query(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForOrganization(input.organizationId);
}),
/**
* Set the enabled status of a feature for the current user.
* Users can always control their own features - no PBAC check needed.
*/
setUserFeatureEnabled: authedProcedure
.input(
z.object({
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setUserFeatureEnabled(
ctx.user.id,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
/**
* Set the enabled status of a feature for a team.
* Requires appropriate PBAC permissions.
*/
setTeamFeatureEnabled: authedAdminProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
/**
* Set the enabled status of a feature for an organization.
* Requires appropriate PBAC permissions.
*/
setOrganizationFeatureEnabled: authedOrgAdminProcedure
Why it matters? ⭐

The PR currently uses authedProcedure for team- and organization-scoped endpoints while the comments explicitly say PBAC checks are required. That means any authenticated user can call those routes for arbitrary team/org IDs — a real authorization surface area issue. Replacing those handlers with stricter procedures (e.g., admin/org-admin wrappers that perform PBAC) addresses a genuine security/authorization bug rather than a cosmetic preference.

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/features/feature-management/trpc/router.ts
**Line:** 5:101
**Comment:**
	*Security: Team and organization feature endpoints currently use the generic authenticated procedure, meaning any logged-in user can list or mutate feature flags for arbitrary teams or organizations by ID, which violates the comments' PBAC requirement and creates an authorization vulnerability; switch these routes to the appropriate admin/org-admin procedures to enforce proper permission checks.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a comprehensive feature opt-in system, including new settings pages for users, organizations, and teams, along with a banner for eligible features. It integrates new TRPC endpoints and extends the FeaturesRepository to manage feature states for different entities. New translation keys have been added to support the new UI elements. The overall structure is well-organized, and the use of TRPC, PBAC, and localStorage for banner dismissal is implemented correctly.

<SettingsToggle
key={feature.slug}
toggleSwitchAtTheEnd={true}
title={feature.slug}

Choose a reason for hiding this comment

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

medium

The title prop for SettingsToggle currently uses feature.slug. While slug is a unique identifier, it's often a technical string and might not be user-friendly or translatable for display purposes. For consistency with FeatureOptInBanner, which uses feature.titleI18nKey, consider introducing an i18n key for the feature title in the Feature model or mapping the slug to a more descriptive, translatable title.

key={feature.slug}
toggleSwitchAtTheEnd={true}
title={feature.slug}
description={feature.description || t("no_description_available")}

Choose a reason for hiding this comment

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

medium

The description prop for SettingsToggle uses feature.description. If feature.description is intended to be a user-facing string, it should ideally be an i18n key to support localization. Currently, it appears to be a direct string from the database, which might not be translatable. For better internationalization, consider using an i18n key for feature descriptions, similar to feature.descriptionI18nKey used in EligibleOptInFeature for the opt-in banner.

<SettingsToggle
key={feature.slug}
toggleSwitchAtTheEnd={true}
title={feature.slug}

Choose a reason for hiding this comment

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

medium

Similar to features-view.tsx, using feature.slug directly as the title for SettingsToggle might not be user-friendly or translatable. It's recommended to use an i18n key for the feature title to ensure consistency and proper localization across the application.

key={feature.slug}
toggleSwitchAtTheEnd={true}
title={feature.slug}
description={feature.description || t("no_description_available")}

Choose a reason for hiding this comment

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

medium

The description prop for SettingsToggle uses feature.description. For improved localization, consider using an i18n key for feature descriptions. This would align with the approach taken in FeatureOptInBanner and provide a more consistent user experience.

<SettingsToggle
key={feature.slug}
toggleSwitchAtTheEnd={true}
title={feature.slug}

Choose a reason for hiding this comment

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

medium

Using feature.slug directly as the title for SettingsToggle can lead to a less user-friendly interface, as slugs are typically technical identifiers. Please consider mapping this to a translatable i18n key for better user experience and consistency with other feature display components.

key={feature.slug}
toggleSwitchAtTheEnd={true}
title={feature.slug}
description={feature.description || t("no_description_available")}

Choose a reason for hiding this comment

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

medium

The description prop for SettingsToggle uses feature.description. To ensure that feature descriptions are localizable, it's best practice to use an i18n key rather than a direct string from the database. This would provide a more consistent and internationalized experience.

Comment on lines +40 to +45
slug: feature.slug,
enabled: !!userFeature, // Row exists = enabled
globallyEnabled: feature.enabled,
description: feature.description,
type: feature.type,
};

Choose a reason for hiding this comment

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

medium

The FeatureWithStatus interface includes slug and description as direct strings. However, the EligibleOptInFeature interface (used for the banner) uses titleI18nKey and descriptionI18nKey for translatable strings. This creates an inconsistency in how feature titles and descriptions are handled across the application's UI components. For a unified and localizable user experience, consider either adding titleI18nKey and descriptionI18nKey to the Feature Prisma model or providing a mapping in the service to derive these i18n keys for FeatureWithStatus.

Comment on lines +59 to +64
const teamFeature = teamFeatures.find((tf) => tf.feature.slug === feature.slug);
return {
slug: feature.slug,
enabled: !!teamFeature, // Row exists = enabled
globallyEnabled: feature.enabled,
description: feature.description,

Choose a reason for hiding this comment

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

medium

The FeatureWithStatus interface includes slug and description as direct strings. However, the EligibleOptInFeature interface (used for the banner) uses titleI18nKey and descriptionI18nKey for translatable strings. This creates an inconsistency in how feature titles and descriptions are handled across the application's UI components. For a unified and localizable user experience, consider either adding titleI18nKey and descriptionI18nKey to the Feature Prisma model or providing a mapping in the service to derive these i18n keys for FeatureWithStatus.

@codeant-ai
Copy link

codeant-ai bot commented Dec 9, 2025

Nitpicks 🔍

🔒 No security issues identified
⚡ Recommended areas for review

  • Missing authorization guard
    Comments state team/organization mutations "Require appropriate PBAC permissions", but the router does not enforce PBAC before calling service methods. Confirm that PBAC checks are enforced either inside the service or add explicit checks here (and return FORBIDDEN when unauthorized).

  • Scope granularity
    The new feature resource's read/update permissions have no scope set. Other resources that are organization-scoped include scope: [Scope.Organization] for create/delete (and sometimes read). Leaving read/update without scope may unintentionally expose feature permissions at the team level. Confirm the intended visibility and align scope for feature.read/feature.update to avoid overbroad access.

  • Unhandled permission API errors
    getResourcePermissions can throw (e.g., PBAC failures). The call is not wrapped in try/catch, so an exception will bubble up and cause a 500 instead of gracefully falling back to role-based permissions.

  • Possible undefined access
    The code reads session.user.org.role without ensuring session.user.org exists. validateUserHasOrg only guarantees that either an org or organizationId exists, so session.user.org may be undefined and accessing .role will throw at runtime.

  • Use TRPCError
    The opt-in mutation throws a plain Error ("Feature is not available for opt-in"). tRPC expects TRPCError for proper HTTP status mapping and safe error shapes. Throwing a generic Error can leak internals or yield an untyped 500. Replace with a TRPCError with an appropriate code (e.g., BAD_REQUEST or FORBIDDEN).

  • Permission gating
    The new "features" entry for organizations is added unconditionally in getTabs. Later in useTabs organization children are filtered based on some permissions, but there's no explicit guard for the "features" child. Confirm which roles should see the organization-level features page and ensure links are hidden or disabled consistently for users without the required permissions.

  • Possible circular import
    This barrel re-exports runtime values (router, service) that can create circular import issues if any of the re-exported modules import from this barrel or from modules that in turn import the barrel. Verify there are no import cycles involving the router or FeatureManagementService.

  • Unsafe localStorage parsing
    The code reads JSON from localStorage and immediately calls JSON.parse without try/catch or validating the parsed value. If the stored value is malformed or not an array, this can throw or cause incorrect behavior.

@codeant-ai
Copy link

codeant-ai bot commented Dec 9, 2025

CodeAnt AI finished reviewing your PR.

@ayushjrathod-dd
Copy link
Author

@refacto-visz

1 similar comment
@ayushjrathod-dd
Copy link
Author

@refacto-visz

@refacto-visz
Copy link

refacto-visz bot commented Dec 9, 2025

Refacto is reviewing this PR. Please wait for the review comments to be posted.

@ayushjrathod-dd
Copy link
Author

@refacto-visz

@refacto-visz
Copy link

refacto-visz bot commented Dec 9, 2025

Refacto is reviewing this PR. Please wait for the review comments to be posted.

@refacto-visz
Copy link

refacto-visz bot commented Dec 9, 2025

Code Review: Feature Management System

PR Confidence Score: 🟥 3 / 5

👍 Well Done
Robust Error Handling

The repository layer and TRPC router include solid error handling, improving backend service reliability.

Proper Permission Controls

PBAC integration prevents unauthorized access to feature management settings.

Good UX Loading States

Skeleton loaders in new views improve perceived performance during data fetching.

📁 Selected files for review (18)
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx
  • packages/features/feature-management/hooks/useFeatureOptInBanner.ts
  • packages/features/feature-management/index.ts
  • packages/features/feature-management/services/FeatureManagementService.ts
  • packages/features/feature-management/trpc/router.ts
  • packages/features/flags/features.repository.interface.ts
  • packages/features/flags/features.repository.ts
  • packages/features/pbac/domain/types/permission-registry.ts
  • packages/trpc/server/routers/viewer/_router.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx
  • apps/web/modules/settings/my-account/features-view.tsx
  • apps/web/modules/settings/organizations/organization-features-view.tsx
  • apps/web/modules/settings/teams/team-features-view.tsx
  • apps/web/public/static/locales/en/common.json
  • packages/features/feature-management/components/FeatureOptInBanner.tsx
  • packages/features/feature-management/config/feature-management.config.ts
📝 Additional Comments
apps/web/modules/settings/my-account/features-view.tsx (1)
Duplicated UI View Logic

The three new view components (FeaturesView, OrganizationFeaturesView, and TeamFeaturesView) are nearly identical. They share the same structure for skeleton loading, data fetching patterns, and rendering feature toggles. This duplication violates the DRY principle, which will increase future maintenance effort and the risk of introducing inconsistencies. A single, reusable presentational component should be extracted to handle the common UI, reducing code repetition and improving maintainability.

Standards:

  • Clean-Code-DRY
packages/features/feature-management/components/FeatureOptInBanner.tsx (1)
Client-Side Storage Security Risk

Banner dismissal state stored in client-side localStorage without validation or size limits. Malicious scripts could manipulate dismissal state or cause storage exhaustion through localStorage pollution. While low impact, this represents a client-side data integrity concern that could affect user experience.

Standards:

  • CWE-20
  • OWASP-A04
  • NIST-SSDF-PW.1
packages/features/feature-management/hooks/useFeatureOptInBanner.ts (1)
URL Parameter Validation Gap

Feature parameter extracted from URL without input validation or sanitization before processing. While the allowlist provides some protection, malicious feature strings could still be processed through the system. Consider adding input validation for feature parameter format and length limits.

Standards:

  • CWE-20
  • OWASP-A03
  • NIST-SSDF-PW.1

Comment on lines +78 to +118
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),

/**
* Set the enabled status of a feature for an organization.
* Requires appropriate PBAC permissions.
*/
setOrganizationFeatureEnabled: authedProcedure
.input(
z.object({
organizationId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setOrganizationFeatureEnabled(
input.organizationId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
Copy link

Choose a reason for hiding this comment

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

Missing Backend Permission Checks

The setTeamFeatureEnabled and setOrganizationFeatureEnabled tRPC mutations only use authedProcedure, which verifies user authentication but does not check for authorization. This allows any authenticated user to enable or disable features for any team or organization if they know the ID, bypassing role-based access control. The backend must enforce that the user has the appropriate role (e.g., ADMIN or OWNER) for the target team/organization before allowing the modification, as noted in the comments but not implemented.

  setTeamFeatureEnabled: authedProcedure
    .input(
      z.object({
        teamId: z.number(),
        featureSlug: z.string(),
        enabled: z.boolean(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Check if user has permission to manage team features
      const hasPermission = await ctx.prisma.membership.findFirst({
        where: {
          userId: ctx.user.id,
          teamId: input.teamId,
          role: { in: ['ADMIN', 'OWNER'] }
        }
      });
      
      if (!hasPermission) {
        throw new Error('Insufficient permissions to manage team features');
      }
      
      const service = getFeatureManagementService(ctx.prisma);
      await service.setTeamFeatureEnabled(
        input.teamId,
        input.featureSlug,
        input.enabled,
        `user:${ctx.user.id}`
      );
      return { success: true };
    }),

  /**
   * Set the enabled status of a feature for an organization.
   * Requires appropriate PBAC permissions.
   */
  setOrganizationFeatureEnabled: authedProcedure
    .input(
      z.object({
        organizationId: z.number(),
        featureSlug: z.string(),
        enabled: z.boolean(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Check if user has permission to manage organization features
      const hasPermission = await ctx.prisma.membership.findFirst({
        where: {
          userId: ctx.user.id,
          team: { parentId: input.organizationId },
          role: { in: ['ADMIN', 'OWNER'] }
        }
      });
      
      if (!hasPermission) {
        throw new Error('Insufficient permissions to manage organization features');
      }
      
      const service = getFeatureManagementService(ctx.prisma);
      await service.setOrganizationFeatureEnabled(
        input.organizationId,
        input.featureSlug,
        input.enabled,
        `user:${ctx.user.id}`
      );
      return { success: true };
    }),
Commitable Suggestion
Suggested change
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
/**
* Set the enabled status of a feature for an organization.
* Requires appropriate PBAC permissions.
*/
setOrganizationFeatureEnabled: authedProcedure
.input(
z.object({
organizationId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setOrganizationFeatureEnabled(
input.organizationId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
// Check if user has permission to manage team features
const hasPermission = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: input.teamId,
role: { in: ['ADMIN', 'OWNER'] }
}
});
if (!hasPermission) {
throw new Error('Insufficient permissions to manage team features');
}
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
/**
* Set the enabled status of a feature for an organization.
* Requires appropriate PBAC permissions.
*/
setOrganizationFeatureEnabled: authedProcedure
.input(
z.object({
organizationId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
// Check if user has permission to manage organization features
const hasPermission = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
team: { parentId: input.organizationId },
role: { in: ['ADMIN', 'OWNER'] }
}
});
if (!hasPermission) {
throw new Error('Insufficient permissions to manage organization features');
}
const service = getFeatureManagementService(ctx.prisma);
await service.setOrganizationFeatureEnabled(
input.organizationId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
Standards
  • Business-Rule-Input-Validation
  • Logic-Verification-Data-Integrity

Comment on lines +42 to +47
useEffect(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem("cal_feature_banners_dismissed");
setDismissedFeatures(stored ? JSON.parse(stored) : []);
}
}, []);
Copy link

Choose a reason for hiding this comment

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

Unhandled JSON Parse Exception

The code parses a JSON string from localStorage without error handling. If the stored data is corrupted or invalid JSON, JSON.parse will throw an exception. An unhandled exception within a useEffect hook can crash the component and potentially the entire application if no error boundary is present above it.

Standards
  • ISO-IEC-25010-Reliability-Fault-Tolerance
  • SRE-Error-Handling

Comment on lines +126 to +148
for (const slug of optInSlugs) {
const config = getOptInFeatureConfig(slug);
if (!config) continue;

const userFeature = await this.featuresRepository.getUserFeature(userId, slug);

// Row exists = user has already opted in
if (userFeature) {
continue;
}

const isGloballyEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally(
slug as Parameters<typeof this.featuresRepository.checkIfFeatureIsEnabledGlobally>[0]
);
if (!isGloballyEnabled) continue;

eligibleFeatures.push({
slug: config.slug,
titleI18nKey: config.titleI18nKey,
descriptionI18nKey: config.descriptionI18nKey,
learnMoreUrl: config.learnMoreUrl,
});
}
Copy link

Choose a reason for hiding this comment

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

Sequential Awaits In Loop

The method sequentially awaits two database queries inside a for loop for each feature slug. This pattern leads to poor performance and scalability, as the total execution time increases linearly with the number of opt-in features. This is an N+1 query problem that will cause significant latency as more features are added.

Standards
  • ISO-IEC-25010-Performance-Efficiency-Time-Behavior
  • Optimization-Pattern-Batch-Processing
  • Algorithmic-Complexity-Linear-Optimization

Comment on lines +155 to +156
if (!service.isFeatureInOptInAllowlist(input.featureSlug)) {
throw new Error("Feature is not available for opt-in");
Copy link

Choose a reason for hiding this comment

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

Missing Feature Slug Validation

Feature slug validation only checks allowlist membership without input sanitization. Malicious feature slugs could be processed through the system before validation occurs. This could lead to injection attacks or system manipulation through crafted feature identifiers.

Standards
  • CWE-20
  • OWASP-A03
  • NIST-SSDF-PW.1

@refacto-visz
Copy link

refacto-visz bot commented Dec 9, 2025

Refacto is reviewing this PR. Please wait for the review comments to be posted.

@refacto-visz
Copy link

refacto-visz bot commented Dec 9, 2025

Code Review: Feature Management System

PR Confidence Score: 🟥 2 / 5

👍 Well Done
Clean System Architecture

The feature management system demonstrates excellent separation of concerns with distinct repository, service, API, and UI layers promoting maintainability and reliability.

Robust State Management

Using row-existence semantics with atomic database operations for feature flags provides a simple and robust state management mechanism.

Consistent UI Patterns

New settings pages consistently use skeleton loaders and query invalidation, ensuring a reliable and responsive user experience without stale data.

📁 Selected files for review (18)
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx
  • packages/features/feature-management/hooks/useFeatureOptInBanner.ts
  • packages/features/feature-management/index.ts
  • packages/features/feature-management/services/FeatureManagementService.ts
  • packages/features/feature-management/trpc/router.ts
  • packages/features/flags/features.repository.interface.ts
  • packages/features/flags/features.repository.ts
  • packages/features/pbac/domain/types/permission-registry.ts
  • packages/trpc/server/routers/viewer/_router.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx
  • apps/web/modules/settings/my-account/features-view.tsx
  • apps/web/modules/settings/organizations/organization-features-view.tsx
  • apps/web/modules/settings/teams/team-features-view.tsx
  • apps/web/public/static/locales/en/common.json
  • packages/features/feature-management/components/FeatureOptInBanner.tsx
  • packages/features/feature-management/config/feature-management.config.ts
📝 Additional Comments
packages/features/feature-management/services/FeatureManagementService.ts (1)
Inefficient Data Fetching

This method fetches all features and all of a user's features in two separate database queries, then merges them in application code. This is inefficient as it transfers more data than necessary and performs the join operation in the application layer. A single database query using a LEFT JOIN would be more performant, offloading the work to the database.

Standards:

  • ISO-IEC-25010-Performance-Efficiency-Resource-Utilization
  • Optimization-Pattern-Data-Colocation
apps/web/modules/settings/teams/team-features-view.tsx (1)
Risky Non-Null Assertion

The code uses a non-null assertion on teamId. While a guard clause currently prevents this from being null, this pattern is brittle and reduces type safety. Future refactoring that alters the guard clause could introduce a runtime error, causing a client-side crash when settings are updated.

Standards:

  • ISO-IEC-25010-Reliability-Maturity
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx (1)
Missing PBAC Read Check

The new page for team features does not include a Policy-Based Access Control check to verify if the user has feature.read permissions. This is inconsistent with the organization features page, which correctly performs this check. While the underlying API is likely protected, showing the UI to users who cannot view the content results in a poor user experience.

Standards:

  • Business-Rule-Validation
apps/web/modules/settings/my-account/features-view.tsx (1)
Client-Side Permission Bypass

Client-side filtering of globally enabled features relies on server response integrity without additional validation. If server response is compromised or manipulated, users could access disabled features through client manipulation. Consider server-side enforcement of feature availability in addition to client filtering.

Standards:

  • CWE-602
  • OWASP-A04
  • NIST-SSDF-PS.1
packages/features/feature-management/hooks/useFeatureOptInBanner.ts (1)
URL Parameter Injection Risk

Feature parameter extracted from URL without input sanitization before processing. While validated against allowlist later, unsanitized URL parameters could be logged or processed in ways that expose XSS vectors. Consider sanitizing URL parameters before use in application logic.

Standards:

  • CWE-79
  • OWASP-A03
  • NIST-SSDF-PW.1

Comment on lines +78 to +95
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
Copy link

Choose a reason for hiding this comment

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

Missing Authorization Validation

Team and organization feature modification endpoints lack permission validation allowing any authenticated user to modify features for any team or organization. Attackers could enable dangerous features or disable security features across organizations they don't belong to. This enables privilege escalation and unauthorized feature manipulation across tenant boundaries.

  setTeamFeatureEnabled: authedProcedure
    .input(
      z.object({
        teamId: z.number(),
        featureSlug: z.string(),
        enabled: z.boolean(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify user has admin permissions for the team
      const membership = await ctx.prisma.membership.findFirst({
        where: {
          userId: ctx.user.id,
          teamId: input.teamId,
          role: { in: ['ADMIN', 'OWNER'] }
        }
      });
      
      if (!membership) {
        throw new Error('Unauthorized: insufficient permissions for team');
      }
      
      const service = getFeatureManagementService(ctx.prisma);
      await service.setTeamFeatureEnabled(
        input.teamId,
        input.featureSlug,
        input.enabled,
        `user:${ctx.user.id}`
      );
      return { success: true };
    }),
    
  setOrganizationFeatureEnabled: authedProcedure
    .input(
      z.object({
        organizationId: z.number(),
        featureSlug: z.string(),
        enabled: z.boolean(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify user has admin permissions for the organization
      const membership = await ctx.prisma.membership.findFirst({
        where: {
          userId: ctx.user.id,
          teamId: input.organizationId,
          role: { in: ['ADMIN', 'OWNER'] }
        }
      });
      
      if (!membership) {
        throw new Error('Unauthorized: insufficient permissions for organization');
      }
      
      const service = getFeatureManagementService(ctx.prisma);
      await service.setOrganizationFeatureEnabled(
        input.organizationId,
        input.featureSlug,
        input.enabled,
        `user:${ctx.user.id}`
      );
      return { success: true };
    }),
Commitable Suggestion
Suggested change
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify user has admin permissions for the team
const membership = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: input.teamId,
role: { in: ['ADMIN', 'OWNER'] }
}
});
if (!membership) {
throw new Error('Unauthorized: insufficient permissions for team');
}
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
setOrganizationFeatureEnabled: authedProcedure
.input(
z.object({
organizationId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify user has admin permissions for the organization
const membership = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: input.organizationId,
role: { in: ['ADMIN', 'OWNER'] }
}
});
if (!membership) {
throw new Error('Unauthorized: insufficient permissions for organization');
}
const service = getFeatureManagementService(ctx.prisma);
await service.setOrganizationFeatureEnabled(
input.organizationId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
Standards
  • CWE-862
  • OWASP-A01
  • NIST-SSDF-PW.1

Comment on lines +126 to +140
for (const slug of optInSlugs) {
const config = getOptInFeatureConfig(slug);
if (!config) continue;

const userFeature = await this.featuresRepository.getUserFeature(userId, slug);

// Row exists = user has already opted in
if (userFeature) {
continue;
}

const isGloballyEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally(
slug as Parameters<typeof this.featuresRepository.checkIfFeatureIsEnabledGlobally>[0]
);
if (!isGloballyEnabled) continue;
Copy link

Choose a reason for hiding this comment

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

N+1 Query Anti-Pattern

The for loop iterates through optInSlugs and executes two database queries inside the loop: getUserFeature and checkIfFeatureIsEnabledGlobally. This creates an N+1 query problem where N is the number of opt-in features. As this list grows, the number of database calls will increase linearly, causing significant performance degradation and database load.

    // Batch fetch all user features and globally enabled features
    const userFeatures = await this.featuresRepository.getUserFeatures(userId);
    const allFeatures = await this.featuresRepository.getAllFeatures();
    
    const userFeatureMap = new Map(userFeatures.map(uf => [uf.feature.slug, uf]));
    const globalFeatureMap = new Map(allFeatures.map(f => [f.slug, f.enabled]));
    
    for (const slug of optInSlugs) {
      const config = getOptInFeatureConfig(slug);
      if (!config) continue;
      
      // Row exists = user has already opted in
      if (userFeatureMap.has(slug)) {
        continue;
      }
      
      const isGloballyEnabled = globalFeatureMap.get(slug);
      if (!isGloballyEnabled) continue;
Commitable Suggestion
Suggested change
for (const slug of optInSlugs) {
const config = getOptInFeatureConfig(slug);
if (!config) continue;
const userFeature = await this.featuresRepository.getUserFeature(userId, slug);
// Row exists = user has already opted in
if (userFeature) {
continue;
}
const isGloballyEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally(
slug as Parameters<typeof this.featuresRepository.checkIfFeatureIsEnabledGlobally>[0]
);
if (!isGloballyEnabled) continue;
// Batch fetch all user features and globally enabled features
const userFeatures = await this.featuresRepository.getUserFeatures(userId);
const allFeatures = await this.featuresRepository.getAllFeatures();
const userFeatureMap = new Map(userFeatures.map(uf => [uf.feature.slug, uf]));
const globalFeatureMap = new Map(allFeatures.map(f => [f.slug, f.enabled]));
for (const slug of optInSlugs) {
const config = getOptInFeatureConfig(slug);
if (!config) continue;
// Row exists = user has already opted in
if (userFeatureMap.has(slug)) {
continue;
}
const isGloballyEnabled = globalFeatureMap.get(slug);
if (!isGloballyEnabled) continue;
Standards
  • ISO-IEC-25010-Performance-Efficiency-Time-Behavior
  • Optimization-Pattern-Batch-Processing

Comment on lines +56 to +72
setUserFeatureEnabled: authedProcedure
.input(
z.object({
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setUserFeatureEnabled(
ctx.user.id,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
Copy link

Choose a reason for hiding this comment

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

Feature Slug Injection

Feature slug parameter accepts arbitrary strings without validation against allowed features list. Malicious users could enable non-existent or restricted features by injecting arbitrary feature identifiers. This could bypass feature access controls and enable unauthorized functionality or create database pollution with invalid feature references.

Standards
  • CWE-20
  • OWASP-A03
  • NIST-SSDF-PW.1

Comment on lines +81 to +88
async setUserFeatureEnabled(
userId: number,
featureSlug: string,
enabled: boolean,
assignedBy: string
): Promise<void> {
await this.featuresRepository.setUserFeatureEnabled(userId, featureSlug, enabled, assignedBy);
}
Copy link

Choose a reason for hiding this comment

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

Missing Feature Existence Validation

Service methods directly pass feature slugs to repository without validating feature exists or is globally enabled. Attackers could create arbitrary feature assignments for non-existent features leading to database inconsistency. This bypasses feature governance and could enable unauthorized feature access through invalid feature references.

Standards
  • CWE-20
  • OWASP-A03
  • NIST-SSDF-PW.1

Comment on lines +22 to +79
const FeaturesView = () => {
const { t } = useLocale();
const utils = trpc.useUtils();

const { data: features, isLoading } = trpc.viewer.featureManagement.listForUser.useQuery();

const setFeatureEnabledMutation = trpc.viewer.featureManagement.setUserFeatureEnabled.useMutation({
onSuccess: () => {
utils.viewer.featureManagement.listForUser.invalidate();
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
});

if (isLoading) {
return (
<SettingsHeader
title={t("features")}
description={t("features_description")}
borderInShellHeader={true}>
<SkeletonLoader />
</SettingsHeader>
);
}

const userControlledFeatures = features?.filter((f) => f.globallyEnabled) ?? [];

return (
<SettingsHeader title={t("features")} description={t("features_description")} borderInShellHeader={true}>
<div className="border-subtle border-x px-4 py-8 sm:px-6">
{userControlledFeatures.length === 0 ? (
<p className="text-subtle text-sm">{t("no_features_available")}</p>
) : (
<div className="space-y-6">
{userControlledFeatures.map((feature) => (
<SettingsToggle
key={feature.slug}
toggleSwitchAtTheEnd={true}
title={feature.slug}
description={feature.description || t("no_description_available")}
disabled={setFeatureEnabledMutation.isPending}
checked={feature.enabled}
onCheckedChange={(checked) => {
setFeatureEnabledMutation.mutate({
featureSlug: feature.slug,
enabled: checked,
});
}}
/>
))}
</div>
)}
</div>
</SettingsHeader>
);
};
Copy link

Choose a reason for hiding this comment

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

Duplicated UI View Logic

The three new view components contain nearly identical logic for fetching data, handling loading states, and rendering feature toggles. This duplication violates the DRY principle and increases future maintenance effort, as any change will need to be applied in three places. A single, reusable component should be created that can be parameterized with the appropriate tRPC hooks and entity ID to handle all three use cases.

Standards
  • Clean-Code-DRY

@ayushjrathod-dd
Copy link
Author

@refacto-visz

Comment on lines +27 to +36
listForTeam: authedProcedure
.input(
z.object({
teamId: z.number(),
})
)
.query(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForTeam(input.teamId);
}),
Copy link

Choose a reason for hiding this comment

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

Missing Team Membership

Team feature listing endpoint allows any authenticated user to view feature configurations for any team without membership validation. Attackers could enumerate team feature settings and discover enabled security features across organizations. This constitutes information disclosure that could aid in targeted attacks against specific teams.

  listForTeam: authedProcedure
    .input(
      z.object({
        teamId: z.number(),
      })
    )
    .query(async ({ ctx, input }) => {
      // Validate team membership
      const membership = await ctx.prisma.membership.findFirst({
        where: {
          userId: ctx.user.id,
          teamId: input.teamId,
          accepted: true
        }
      });

      if (!membership) {
        throw new Error('Access denied: Not a member of this team');
      }

      const service = getFeatureManagementService(ctx.prisma);
      return service.listFeaturesForTeam(input.teamId);
    }),
Commitable Suggestion
Suggested change
listForTeam: authedProcedure
.input(
z.object({
teamId: z.number(),
})
)
.query(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForTeam(input.teamId);
}),
listForTeam: authedProcedure
.input(
z.object({
teamId: z.number(),
})
)
.query(async ({ ctx, input }) => {
// Validate team membership
const membership = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: input.teamId,
accepted: true
}
});
if (!membership) {
throw new Error('Access denied: Not a member of this team');
}
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForTeam(input.teamId);
}),
Standards
  • CWE-862
  • OWASP-A01
  • NIST-SSDF-PW.1

Comment on lines +41 to +50
listForOrganization: authedProcedure
.input(
z.object({
organizationId: z.number(),
})
)
.query(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForOrganization(input.organizationId);
}),
Copy link

Choose a reason for hiding this comment

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

Organization Access Control

Organization feature listing endpoint lacks organization membership validation allowing unauthorized users to view organizational feature configurations. Attackers could discover security feature settings across multiple organizations revealing security posture information. This enables reconnaissance for targeted attacks and potential compliance violations.

  listForOrganization: authedProcedure
    .input(
      z.object({
        organizationId: z.number(),
      })
    )
    .query(async ({ ctx, input }) => {
      // Validate organization membership
      const membership = await ctx.prisma.membership.findFirst({
        where: {
          userId: ctx.user.id,
          teamId: input.organizationId,
          accepted: true
        }
      });

      if (!membership) {
        throw new Error('Access denied: Not a member of this organization');
      }

      const service = getFeatureManagementService(ctx.prisma);
      return service.listFeaturesForOrganization(input.organizationId);
    }),
Commitable Suggestion
Suggested change
listForOrganization: authedProcedure
.input(
z.object({
organizationId: z.number(),
})
)
.query(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForOrganization(input.organizationId);
}),
listForOrganization: authedProcedure
.input(
z.object({
organizationId: z.number(),
})
)
.query(async ({ ctx, input }) => {
// Validate organization membership
const membership = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: input.organizationId,
accepted: true
}
});
if (!membership) {
throw new Error('Access denied: Not a member of this organization');
}
const service = getFeatureManagementService(ctx.prisma);
return service.listFeaturesForOrganization(input.organizationId);
}),
Standards
  • CWE-862
  • OWASP-A01
  • NIST-SSDF-PW.1

Comment on lines +126 to +148
for (const slug of optInSlugs) {
const config = getOptInFeatureConfig(slug);
if (!config) continue;

const userFeature = await this.featuresRepository.getUserFeature(userId, slug);

// Row exists = user has already opted in
if (userFeature) {
continue;
}

const isGloballyEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally(
slug as Parameters<typeof this.featuresRepository.checkIfFeatureIsEnabledGlobally>[0]
);
if (!isGloballyEnabled) continue;

eligibleFeatures.push({
slug: config.slug,
titleI18nKey: config.titleI18nKey,
descriptionI18nKey: config.descriptionI18nKey,
learnMoreUrl: config.learnMoreUrl,
});
}
Copy link

Choose a reason for hiding this comment

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

N+1 Query Pattern

The getEligibleOptInFeatures method iterates through optInSlugs and executes database queries (getUserFeature and checkIfFeatureIsEnabledGlobally) inside the loop. This creates an N+1 query problem, where N is the number of opt-in features. As this list grows, the number of database queries will increase linearly, causing significant performance degradation and increased database load.

Standards
  • ISO-IEC-25010-Performance-Efficiency-Time-Behavior
  • Optimization-Pattern-Batch-Processing
  • Algorithmic-Complexity-Linear-Optimization

Comment on lines +14 to +16
const Page = async () => {
return <TeamFeaturesView />;
};
Copy link

Choose a reason for hiding this comment

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

Missing Permission Check

The new Team Features page does not perform a specific permission check for the Feature resource, unlike the corresponding Organization Features page which explicitly checks for canRead permissions. This inconsistency introduces a potential security vulnerability, as it might allow users to access feature management settings for a team without the required authorization. An explicit PBAC check should be added to ensure consistent and secure access control.

Standards
  • Business-Rule-Input-Validation

Comment on lines +42 to +62
useEffect(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem("cal_feature_banners_dismissed");
setDismissedFeatures(stored ? JSON.parse(stored) : []);
}
}, []);

useEffect(() => {
if (!featureParam || !eligibleFeatures) {
setFeatureToShow(null);
return;
}

if (dismissedFeatures.includes(featureParam)) {
setFeatureToShow(null);
return;
}

const feature = eligibleFeatures.find((f) => f.slug === featureParam);
setFeatureToShow(feature || null);
}, [featureParam, eligibleFeatures, dismissedFeatures]);
Copy link

Choose a reason for hiding this comment

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

Race Condition Causes Flicker

The hook uses two separate useEffect calls, one to read dismissed banner states from localStorage and another to decide whether to show a banner. This creates a race condition where the banner might be shown for a feature that is already dismissed, causing a UI flicker. The effect checking for eligible features can complete and render the banner before the effect reading localStorage has updated the component's state.

Standards
  • Logic-Verification-Data-Flow

Comment on lines +146 to +160
optInToFeature: authedProcedure
.input(
z.object({
featureSlug: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);

if (!service.isFeatureInOptInAllowlist(input.featureSlug)) {
throw new Error("Feature is not available for opt-in");
}

await service.setUserFeatureEnabled(ctx.user.id, input.featureSlug, true, `user:${ctx.user.id}`);
return { success: true };
Copy link

Choose a reason for hiding this comment

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

Feature Slug Injection

Feature slug parameter accepts arbitrary strings without validation before database operations. While allowlist validation exists, malformed slugs could cause database errors or injection attacks if slug is used in dynamic queries. Input validation should sanitize feature slug format to prevent potential injection vectors through malicious slug values.

Standards
  • CWE-20
  • OWASP-A03
  • NIST-SSDF-PW.1

Comment on lines +22 to +36
const FeaturesView = () => {
const { t } = useLocale();
const utils = trpc.useUtils();

const { data: features, isLoading } = trpc.viewer.featureManagement.listForUser.useQuery();

const setFeatureEnabledMutation = trpc.viewer.featureManagement.setUserFeatureEnabled.useMutation({
onSuccess: () => {
utils.viewer.featureManagement.listForUser.invalidate();
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
});
Copy link

Choose a reason for hiding this comment

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

Duplicated Feature Management UI

The FeaturesView component is nearly identical to OrganizationFeaturesView and TeamFeaturesView. All three components share the same structure, skeleton loader, and rendering logic, leading to significant code duplication. This violates the DRY principle and increases maintenance overhead, as future UI changes will need to be replicated in three places. A single generic component should be created that accepts the context (e.g., 'user', 'team', or 'organization') and an optional ID as props to dynamically use the appropriate tRPC procedures.

Standards
  • Clean-Code-DRY
  • Maintainability-Quality-Duplication

@refacto-visz
Copy link

refacto-visz bot commented Dec 10, 2025

PR already reviewed at the latest commit: 3c54b1f.
Please try again with new changes.

@ayushjrathod-dd
Copy link
Author

@refacto-visz

@refacto-visz
Copy link

refacto-visz bot commented Dec 10, 2025

Refacto is reviewing this PR. Please wait for the review comments to be posted.

@refacto-visz
Copy link

refacto-visz bot commented Dec 10, 2025

Code Review: Feature Management System

PR Confidence Score: 🟥 2 / 5

👍 Well Done
Clean Architecture

The new feature-management package has excellent separation of concerns with distinct service, repository, and API layers.

Robust Page-Level Permissions

The organization features page correctly implements PBAC checks, preventing unauthorized users from viewing sensitive settings.

Efficient Query Enabling

Conditional tRPC query fetching (enabled: !!param) is correctly used, preventing unnecessary API calls and improving client-side performance.

📁 Selected files for review (18)
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx
  • packages/features/feature-management/hooks/useFeatureOptInBanner.ts
  • packages/features/feature-management/index.ts
  • packages/features/feature-management/services/FeatureManagementService.ts
  • packages/features/feature-management/trpc/router.ts
  • packages/features/flags/features.repository.interface.ts
  • packages/features/flags/features.repository.ts
  • packages/features/pbac/domain/types/permission-registry.ts
  • packages/trpc/server/routers/viewer/_router.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx
  • apps/web/modules/settings/my-account/features-view.tsx
  • apps/web/modules/settings/organizations/organization-features-view.tsx
  • apps/web/modules/settings/teams/team-features-view.tsx
  • apps/web/public/static/locales/en/common.json
  • packages/features/feature-management/components/FeatureOptInBanner.tsx
  • packages/features/feature-management/config/feature-management.config.ts
📝 Additional Comments
packages/features/feature-management/hooks/useFeatureOptInBanner.ts (1)
URL Parameter Injection

URL parameter 'feature' is directly used without sanitization or validation against allowlisted values. While filtered later through eligibleFeatures, the raw parameter could be used for client-side attacks or logging injection if passed to other components. Malicious feature parameters could bypass intended security controls or cause unexpected behavior.

Standards:

  • CWE-20
  • OWASP-A03
  • NIST-SSDF-PW.1
apps/web/modules/settings/organizations/organization-features-view.tsx (1)
UI Should Reflect Permissions

The feature toggles are only disabled while a mutation is pending. However, the UI does not account for user permissions. A user with read-only access will see enabled toggles, even though their update attempts would (or should) be rejected by the server. For better UX and defense-in-depth, the disabled state should also reflect the user's update permissions, disabling the control for users who are not authorized to make changes.

Standards:

  • ISO-IEC-25010-Functional-Correctness-Appropriateness
packages/features/feature-management/trpc/router.ts (1)
Feature Enumeration Risk

Feature listing endpoints expose all available features to users, potentially revealing internal feature names and development roadmap information. While not directly exploitable, this information disclosure could aid attackers in understanding system capabilities and planning targeted attacks against specific features.

Standards:

  • CWE-200
  • OWASP-A01
  • NIST-SSDF-PW.1

Comment on lines +78 to +95
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
Copy link

Choose a reason for hiding this comment

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

Missing Team Authorization

Team and organization feature management endpoints lack authorization checks to verify user permissions for the specified team/organization. Attackers could manipulate features for teams they don't belong to by providing arbitrary teamId/organizationId values. This enables privilege escalation and unauthorized feature control across organizational boundaries.

  setTeamFeatureEnabled: authedProcedure
    .input(
      z.object({
        teamId: z.number(),
        featureSlug: z.string(),
        enabled: z.boolean(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify user has admin permissions for the team
      const teamMembership = await ctx.prisma.membership.findFirst({
        where: {
          userId: ctx.user.id,
          teamId: input.teamId,
          role: { in: ['ADMIN', 'OWNER'] }
        }
      });
      
      if (!teamMembership) {
        throw new Error('Unauthorized: insufficient permissions for team feature management');
      }
      
      const service = getFeatureManagementService(ctx.prisma);
      await service.setTeamFeatureEnabled(
        input.teamId,
        input.featureSlug,
        input.enabled,
        `user:${ctx.user.id}`
      );
      return { success: true };
    }),

  setOrganizationFeatureEnabled: authedProcedure
    .input(
      z.object({
        organizationId: z.number(),
        featureSlug: z.string(),
        enabled: z.boolean(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify user has admin permissions for the organization
      const orgMembership = await ctx.prisma.membership.findFirst({
        where: {
          userId: ctx.user.id,
          team: {
            isOrganization: true,
            id: input.organizationId
          },
          role: { in: ['ADMIN', 'OWNER'] }
        }
      });
      
      if (!orgMembership) {
        throw new Error('Unauthorized: insufficient permissions for organization feature management');
      }
      
      const service = getFeatureManagementService(ctx.prisma);
      await service.setOrganizationFeatureEnabled(
        input.organizationId,
        input.featureSlug,
        input.enabled,
        `user:${ctx.user.id}`
      );
      return { success: true };
    }),
Commitable Suggestion
Suggested change
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify user has admin permissions for the team
const teamMembership = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: input.teamId,
role: { in: ['ADMIN', 'OWNER'] }
}
});
if (!teamMembership) {
throw new Error('Unauthorized: insufficient permissions for team feature management');
}
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
setOrganizationFeatureEnabled: authedProcedure
.input(
z.object({
organizationId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify user has admin permissions for the organization
const orgMembership = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
team: {
isOrganization: true,
id: input.organizationId
},
role: { in: ['ADMIN', 'OWNER'] }
}
});
if (!orgMembership) {
throw new Error('Unauthorized: insufficient permissions for organization feature management');
}
const service = getFeatureManagementService(ctx.prisma);
await service.setOrganizationFeatureEnabled(
input.organizationId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
Standards
  • CWE-862
  • OWASP-A01
  • NIST-SSDF-PW.1

Comment on lines +36 to +46
return allFeatures.map((feature) => {
// Row existence = feature enabled for user
const userFeature = userFeatures.find((uf) => uf.feature.slug === feature.slug);
return {
slug: feature.slug,
enabled: !!userFeature, // Row exists = enabled
globallyEnabled: feature.enabled,
description: feature.description,
type: feature.type,
};
});
Copy link

Choose a reason for hiding this comment

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

Inefficient Feature Status Lookup

The code iterates through all system features and performs a find operation on the userFeatures array for each one. This results in O(M*N) complexity, which will degrade performance as the number of features or user-enabled features grows. This can be optimized to O(M+N) by converting the userFeatures list into a Set for O(1) lookups.

Standards
  • ISO-IEC-25010-Performance-Efficiency-Time-Behavior
  • Algorithmic-Complexity-Linear-Optimization

Comment on lines +126 to +141
for (const slug of optInSlugs) {
const config = getOptInFeatureConfig(slug);
if (!config) continue;

const userFeature = await this.featuresRepository.getUserFeature(userId, slug);

// Row exists = user has already opted in
if (userFeature) {
continue;
}

const isGloballyEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally(
slug as Parameters<typeof this.featuresRepository.checkIfFeatureIsEnabledGlobally>[0]
);
if (!isGloballyEnabled) continue;

Copy link

Choose a reason for hiding this comment

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

N+1 Query in Loop

The getEligibleOptInFeatures method executes two database queries (getUserFeature and checkIfFeatureIsEnabledGlobally) inside a for loop. This creates an N+1 query anti-pattern, where N is the number of opt-in features. As this list grows, the number of database round-trips will increase linearly, degrading API response time.

Standards
  • ISO-IEC-25010-Performance-Efficiency-Time-Behavior
  • Optimization-Pattern-Batch-Processing

Comment on lines +56 to +72
setUserFeatureEnabled: authedProcedure
.input(
z.object({
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const service = getFeatureManagementService(ctx.prisma);
await service.setUserFeatureEnabled(
ctx.user.id,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
Copy link

Choose a reason for hiding this comment

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

Feature Slug Validation

Feature slug input accepts arbitrary strings without validation against existing features or allowlists. Attackers could create database entries for non-existent features or bypass intended feature restrictions. This could lead to database pollution and potential security control bypass if feature validation logic relies on database state.

Standards
  • CWE-20
  • OWASP-A03
  • NIST-SSDF-PW.1

Comment on lines +22 to +79
const FeaturesView = () => {
const { t } = useLocale();
const utils = trpc.useUtils();

const { data: features, isLoading } = trpc.viewer.featureManagement.listForUser.useQuery();

const setFeatureEnabledMutation = trpc.viewer.featureManagement.setUserFeatureEnabled.useMutation({
onSuccess: () => {
utils.viewer.featureManagement.listForUser.invalidate();
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
});

if (isLoading) {
return (
<SettingsHeader
title={t("features")}
description={t("features_description")}
borderInShellHeader={true}>
<SkeletonLoader />
</SettingsHeader>
);
}

const userControlledFeatures = features?.filter((f) => f.globallyEnabled) ?? [];

return (
<SettingsHeader title={t("features")} description={t("features_description")} borderInShellHeader={true}>
<div className="border-subtle border-x px-4 py-8 sm:px-6">
{userControlledFeatures.length === 0 ? (
<p className="text-subtle text-sm">{t("no_features_available")}</p>
) : (
<div className="space-y-6">
{userControlledFeatures.map((feature) => (
<SettingsToggle
key={feature.slug}
toggleSwitchAtTheEnd={true}
title={feature.slug}
description={feature.description || t("no_description_available")}
disabled={setFeatureEnabledMutation.isPending}
checked={feature.enabled}
onCheckedChange={(checked) => {
setFeatureEnabledMutation.mutate({
featureSlug: feature.slug,
enabled: checked,
});
}}
/>
))}
</div>
)}
</div>
</SettingsHeader>
);
};
Copy link

Choose a reason for hiding this comment

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

Duplicated UI View Logic

The three new feature view components (FeaturesView, OrganizationFeaturesView, TeamFeaturesView) share nearly identical structure and logic, including skeleton loaders, headers, and toggle rendering. This duplication violates the DRY principle, increasing maintenance effort. A single, reusable component should be created to encapsulate the common UI and accept entity-specific data-fetching logic (e.g., tRPC hooks) and identifiers as props.

Standards
  • Clean-Code-DRY
  • Maintainability-Quality-Reusability

@ayushjrathod-dd
Copy link
Author

@refacto-visz

@refacto-visz
Copy link

refacto-visz bot commented Dec 11, 2025

PR already reviewed at the latest commit: 3c54b1f.
Please try again with new changes.

@ayushjrathod-dd
Copy link
Author

@refacto-visz

@refacto-visz
Copy link

refacto-visz bot commented Dec 11, 2025

Refacto is reviewing this PR. Please wait for the review comments to be posted.

@refacto-visz
Copy link

refacto-visz bot commented Dec 11, 2025

Code Review: Feature Management System

PR Confidence Score: 🟥 2 / 5

👍 Well Done
Excellent Architectural Separation

The feature is well-designed with clear separation between data (repository), logic (service), API (TRPC), and UI layers, enhancing maintainability and testability.

Permission-Based Access Control

Organization features page properly implements PBAC with role-based access validation.

Efficient Data Invalidation

UI components correctly invalidate TRPC queries after mutations, ensuring data consistency without manual state management.

📁 Selected files for review (18)
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx
  • packages/features/feature-management/hooks/useFeatureOptInBanner.ts
  • packages/features/feature-management/index.ts
  • packages/features/feature-management/services/FeatureManagementService.ts
  • packages/features/feature-management/trpc/router.ts
  • packages/features/flags/features.repository.interface.ts
  • packages/features/flags/features.repository.ts
  • packages/features/pbac/domain/types/permission-registry.ts
  • packages/trpc/server/routers/viewer/_router.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx
  • apps/web/modules/settings/my-account/features-view.tsx
  • apps/web/modules/settings/organizations/organization-features-view.tsx
  • apps/web/modules/settings/teams/team-features-view.tsx
  • apps/web/public/static/locales/en/common.json
  • packages/features/feature-management/components/FeatureOptInBanner.tsx
  • packages/features/feature-management/config/feature-management.config.ts
📝 Additional Comments
packages/features/feature-management/services/FeatureManagementService.ts (1)
Inefficient In-Memory Join

This function fetches all features and all user-specific features in two separate database queries, then joins them in application memory. This is less efficient than a single database query using a LEFT JOIN. This pattern leads to extra network latency and can increase memory pressure on the application server, especially if the number of total features grows.

Standards:

  • ISO-IEC-25010-Performance-Efficiency-Resource-Utilization
  • Optimization-Pattern-Data-Joining
packages/features/feature-management/trpc/router.ts (1)
Feature Enumeration Risk

Feature listing endpoint exposes all features including disabled ones to authenticated users. This provides information about system capabilities and development features that may not be intended for user visibility. Consider filtering response to only show user-controllable or enabled features to reduce information disclosure.

Standards:

  • CWE-200
  • OWASP-A01
  • NIST-SSDF-PW.1
apps/web/modules/settings/my-account/features-view.tsx (1)
Duplicated UI Logic

The UI components for managing features for users, teams, and organizations (FeaturesView, TeamFeaturesView, OrganizationFeaturesView) share nearly identical structure and logic. This includes data fetching, mutation handling, loading skeletons, and rendering the list of toggles. This duplication increases maintenance overhead, as changes to the UI or logic must be replicated in three separate files.

Standards:

  • Clean-Code-DRY

* Set the enabled status of a feature for a team.
* Requires appropriate PBAC permissions.
*/
setTeamFeatureEnabled: authedProcedure
Copy link

Choose a reason for hiding this comment

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

Missing Team Authorization

Team feature modification endpoint lacks authorization check to verify user has permission to modify team features. Attackers could modify features for teams they don't belong to or lack administrative rights for. This enables privilege escalation and unauthorized team configuration changes.

  setTeamFeatureEnabled: authedProcedure
    .input(
      z.object({
        teamId: z.number(),
        featureSlug: z.string(),
        enabled: z.boolean(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify user has admin permissions for the team
      const adminMembership = await ctx.prisma.membership.findFirst({
        where: {
          userId: ctx.user.id,
          teamId: input.teamId,
          role: { in: ['ADMIN', 'OWNER'] }
        }
      });
      
      if (!adminMembership) {
        throw new Error('Unauthorized: admin privileges required for team feature management');
      }

      const service = getFeatureManagementService(ctx.prisma);
      await service.setTeamFeatureEnabled(
        input.teamId,
        input.featureSlug,
        input.enabled,
        `user:${ctx.user.id}`
      );
      return { success: true };
    }),
Commitable Suggestion
Suggested change
setTeamFeatureEnabled: authedProcedure
setTeamFeatureEnabled: authedProcedure
.input(
z.object({
teamId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify user has admin permissions for the team
const adminMembership = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: input.teamId,
role: { in: ['ADMIN', 'OWNER'] }
}
});
if (!adminMembership) {
throw new Error('Unauthorized: admin privileges required for team feature management');
}
const service = getFeatureManagementService(ctx.prisma);
await service.setTeamFeatureEnabled(
input.teamId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
Standards
  • CWE-862
  • OWASP-A01
  • NIST-SSDF-PW.1

* Set the enabled status of a feature for an organization.
* Requires appropriate PBAC permissions.
*/
setOrganizationFeatureEnabled: authedProcedure
Copy link

Choose a reason for hiding this comment

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

Missing Organization Authorization

Organization feature modification endpoint lacks authorization check to verify user has permission to modify organization features. Attackers could modify features for organizations they don't belong to or lack administrative rights for. This enables privilege escalation and unauthorized organization-wide configuration changes affecting multiple users.

  setOrganizationFeatureEnabled: authedProcedure
    .input(
      z.object({
        organizationId: z.number(),
        featureSlug: z.string(),
        enabled: z.boolean(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify user has admin permissions for the organization
      const adminMembership = await ctx.prisma.membership.findFirst({
        where: {
          userId: ctx.user.id,
          teamId: input.organizationId,
          role: { in: ['ADMIN', 'OWNER'] }
        }
      });
      
      if (!adminMembership) {
        throw new Error('Unauthorized: admin privileges required for organization feature management');
      }

      const service = getFeatureManagementService(ctx.prisma);
      await service.setOrganizationFeatureEnabled(
        input.organizationId,
        input.featureSlug,
        input.enabled,
        `user:${ctx.user.id}`
      );
      return { success: true };
    }),
Commitable Suggestion
Suggested change
setOrganizationFeatureEnabled: authedProcedure
setOrganizationFeatureEnabled: authedProcedure
.input(
z.object({
organizationId: z.number(),
featureSlug: z.string(),
enabled: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify user has admin permissions for the organization
const adminMembership = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: input.organizationId,
role: { in: ['ADMIN', 'OWNER'] }
}
});
if (!adminMembership) {
throw new Error('Unauthorized: admin privileges required for organization feature management');
}
const service = getFeatureManagementService(ctx.prisma);
await service.setOrganizationFeatureEnabled(
input.organizationId,
input.featureSlug,
input.enabled,
`user:${ctx.user.id}`
);
return { success: true };
}),
Standards
  • CWE-862
  • OWASP-A01
  • NIST-SSDF-PW.1

Comment on lines +126 to +130
for (const slug of optInSlugs) {
const config = getOptInFeatureConfig(slug);
if (!config) continue;

const userFeature = await this.featuresRepository.getUserFeature(userId, slug);
Copy link

Choose a reason for hiding this comment

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

N+1 Query Pattern

A database query (getUserFeature) is executed inside a for loop that iterates over all opt-in feature slugs. This creates an N+1 query problem, where N is the number of opt-in features. This pattern significantly increases database load and API latency as the number of features grows, impacting scalability.

    const eligibleFeatures: EligibleOptInFeature[] = [];
    const optInSlugs = getOptInFeatureSlugs();

    // Batch fetch all user features to avoid N+1 queries
    const userFeatures = await this.featuresRepository.getUserFeatures(userId);
    const userFeatureMap = new Map(userFeatures.map(uf => [uf.feature.slug, uf]));

    for (const slug of optInSlugs) {
      const config = getOptInFeatureConfig(slug);
      if (!config) continue;

      // Use cached user feature instead of individual query
      const userFeature = userFeatureMap.get(slug);
Commitable Suggestion
Suggested change
for (const slug of optInSlugs) {
const config = getOptInFeatureConfig(slug);
if (!config) continue;
const userFeature = await this.featuresRepository.getUserFeature(userId, slug);
const eligibleFeatures: EligibleOptInFeature[] = [];
const optInSlugs = getOptInFeatureSlugs();
// Batch fetch all user features to avoid N+1 queries
const userFeatures = await this.featuresRepository.getUserFeatures(userId);
const userFeatureMap = new Map(userFeatures.map(uf => [uf.feature.slug, uf]));
for (const slug of optInSlugs) {
const config = getOptInFeatureConfig(slug);
if (!config) continue;
// Use cached user feature instead of individual query
const userFeature = userFeatureMap.get(slug);
Standards
  • ISO-IEC-25010-Performance-Efficiency-Time-Behavior
  • Optimization-Pattern-Batch-Processing

* Set the enabled status of a feature for the current user.
* Users can always control their own features - no PBAC check needed.
*/
setUserFeatureEnabled: authedProcedure
Copy link

Choose a reason for hiding this comment

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

Missing Feature Validation

Feature slug input lacks validation against allowed feature list before enabling. Attackers could enable non-existent or restricted features by providing arbitrary slug values. This could lead to system instability or unauthorized access to development features not intended for production use.

Standards
  • CWE-20
  • OWASP-A03
  • NIST-SSDF-PW.1

@ayushjrathod-dd
Copy link
Author

@refacto-visz

@refacto-visz
Copy link

refacto-visz bot commented Dec 15, 2025

PR already reviewed at the latest commit: 3c54b1f.
Please try again with new changes.

@ayushjrathod-dd
Copy link
Author

@refacto-visz

2 similar comments
@ayushjrathod-dd
Copy link
Author

@refacto-visz

@ayushjrathod-dd
Copy link
Author

@refacto-visz

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

Labels

size:XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants