diff --git a/packages/features/feature-opt-in/FEATURE_OPT_IN_BANNER_PLAN.md b/packages/features/feature-opt-in/FEATURE_OPT_IN_BANNER_PLAN.md deleted file mode 100644 index d8a6eceab4..0000000000 --- a/packages/features/feature-opt-in/FEATURE_OPT_IN_BANNER_PLAN.md +++ /dev/null @@ -1,201 +0,0 @@ -# Feature Opt-In Banner System Plan - -This document outlines the high-level plan for implementing a feature opt-in banner/popup system that allows users to opt into experimental features directly from the pages where those features apply. - -## Overview - -When a new opt-in feature (like "bookings-v3") is available, users will see a floating banner on relevant pages. Clicking the banner opens a confirmation dialog where users can opt-in based on their role (org owner, team owner, or regular user). - ---- - -## 1. Backend: New TRPC Procedure - -**Location:** `packages/trpc/server/routers/viewer/featureOptIn/_router.ts` - -**New Procedure:** `checkFeatureOptInEligibility` - -This procedure will: -- Accept a `featureId` parameter (e.g., "bookings-v3") -- Use the existing `resolveFeatureStatesAcrossTeams` function from `FeatureOptInService` -- Return one of three response types: - 1. **Already Enabled**: User effectively has the feature enabled - no banner needed - 2. **Can Opt-In**: User can opt-in - return user's role context (feature metadata like title/description comes from `getOptInFeatureConfig()` in `config.ts`) - 3. **Cannot Opt-In**: User is blocked by org/team (strict policy) - return blocking reason - -The response should include: -- User's role context for the confirmation dialog: - - Is org owner/admin? - - Which teams is user owner/admin of? - - Organization ID (if applicable) - - Team IDs where user has admin rights - ---- - -## 2. Frontend: Custom Hook - -**Location:** New file in `apps/web/modules/feature-opt-in/hooks/` - -**Hook:** `useFeatureOptInBanner(featureId: string)` - -This hook will: -1. **Check localStorage first** for dismissal state in a single consolidated key `feature-opt-in-dismissed` - - The key stores an object mapping feature IDs to dismissal status: `{ "bookings-v3": true, "insights-v2": true }` - - If the feature is dismissed, skip the TRPC call entirely and return `{ shouldShow: false }` - - Using a single key avoids localStorage bloat from multiple per-feature keys -2. **Call the TRPC procedure** if not dismissed -3. **Get feature metadata** from `getOptInFeatureConfig(featureId)` in `config.ts` for title/description i18n keys -4. **Return state** for the banner: - - `shouldShow: boolean` - - `featureConfig: OptInFeatureConfig` (from config.ts, includes titleI18nKey, descriptionI18nKey) - - `canOptIn: boolean` - - `blockingReason: string | null` - - `userRoleContext: { isOrgAdmin, adminTeamIds, orgId }` - - `dismiss(): void` - updates the consolidated localStorage key - - `openDialog(): void` - triggers confirmation dialog - ---- - -## 3. UI Components - -### 3a. Floating Banner Component - -**Location:** `packages/features/feature-opt-in/components/FeatureOptInBanner.tsx` - -A minimal floating banner positioned at bottom-right: -- Small rectangle with feature title and brief description -- Close button (X) - calls `dismiss()` to save to localStorage -- Clickable area - opens confirmation dialog -- Can be refined later for design polish - -### 3b. Confirmation Dialog - -**Location:** `packages/features/feature-opt-in/components/FeatureOptInConfirmDialog.tsx` - -Complex dialog with role-based options: - -**For Org Owners/Admins:** -- Radio/checkbox to choose scope: - - "Enable for entire organization" - - "Enable just for me" -- Auto opt-in checkbox: "Automatically opt into future features for my organization" - -**For Team Owners/Admins (not org admin):** -- Radio/checkbox to choose scope: - - "Enable for my teams" (list team names if multiple) - - "Enable just for me" -- Auto opt-in checkbox: "Automatically opt into future features for my teams" - -**For Regular Users:** -- Simple confirmation message -- Auto opt-in checkbox: "Automatically opt into future features" - -**CTA Button:** "Enable Feature" - calls appropriate mutation(s) based on selection - -### 3c. Success State (within Confirmation Dialog) - -Instead of a separate success dialog (which would cause UI flashing), the confirmation dialog handles the success state internally: - -**Flow:** -1. User clicks "Enable Feature" button -2. Button shows a loading spinner while the mutation runs -3. On success, the dialog content transitions to show success state -4. Success state displays: - - "Successfully enabled!" message - - Two buttons: - 1. "Dismiss" - closes dialog - 2. "View Settings" - redirects to appropriate settings page - -**Redirect Logic:** -- If user opted in for org: `/settings/organizations/features` -- If user opted in for team(s): `/settings/teams/[first-team-id]/features` -- If user opted in for self only: `/settings/my-account/features` - -This approach provides a smoother UX by avoiding the jarring close-and-reopen of separate dialogs. - ---- - -## 4. Integration Points - -**Example:** `apps/web/modules/bookings/views/bookings-view.tsx` - -At the view level: -1. Import and call the hook: `const optInBanner = useFeatureOptInBanner("bookings-v3")` -2. Conditionally render the banner: `{optInBanner.shouldShow && }` - -This pattern can be replicated for any view where a feature opt-in banner is needed. - ---- - -## 5. Policy Considerations - -The existing `computeEffectiveStateAcrossTeams` function already handles the two policies: - -**Permissive Policy:** -- User can opt-in even if org/teams haven't explicitly enabled -- Only blocked if ALL teams have explicitly disabled - -**Strict Policy:** -- User cannot opt-in alone; requires org/team to enable first -- ANY explicit disable from org/team blocks the user - -The TRPC procedure should use the feature's policy (from `OPT_IN_FEATURES` config) to determine if the user can opt-in. - ---- - -## 6. Data Flow Summary - -``` -View (bookings-view.tsx) - | - v -useFeatureOptInBanner("bookings-v3") - | - v -[Check localStorage for dismissal] - | (if not dismissed) - v -TRPC: checkFeatureOptInEligibility - | - v -FeatureOptInService.resolveFeatureStatesAcrossTeams - | - v -[Return eligibility + user role context] - | - v -Render FeatureOptInBanner (if eligible) - | (on click) - v -FeatureOptInConfirmDialog - | (on confirm) - v -Existing mutations: setUserState / setTeamState / setOrganizationState - | - v -[Dialog transitions to success state] -``` - ---- - -## 7. Key Dependencies on PR #25892 - -This plan relies on these existing pieces from the underlying PR: -- `FeatureOptInService` and `resolveFeatureStatesAcrossTeams` -- `computeEffectiveStateAcrossTeams` with policy support -- `OPT_IN_FEATURES` config with policy field -- Existing mutations for setting user/team/org states -- Settings pages at `/settings/*/features` - ---- - -## 8. Files to Create/Modify - -### New Files: -- `packages/features/feature-opt-in/components/FeatureOptInBanner.tsx` -- `packages/features/feature-opt-in/components/FeatureOptInConfirmDialog.tsx` (includes success state handling) -- `apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts` - -### Modified Files: -- `packages/trpc/server/routers/viewer/featureOptIn/_router.ts` (add new procedure) -- `apps/web/modules/bookings/views/bookings-view.tsx` (integrate banner) -- Translation files for new i18n keys diff --git a/packages/features/flags/DECORATOR_IMPLEMENTATION_PLAN.md b/packages/features/flags/DECORATOR_IMPLEMENTATION_PLAN.md deleted file mode 100644 index 3d67efa1fc..0000000000 --- a/packages/features/flags/DECORATOR_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,290 +0,0 @@ -# Memoize/Unmemoize Decorator Implementation Plan - -## Overview - -This plan outlines the implementation of `@Memoize` and `@Unmemoize` decorators to replace the current CachedXXRepository + PrismaXXRepository + RedisXXRepository pattern (9 classes per 3 entities) with a more declarative approach. - -## Current State - -The current implementation uses three layers per entity: -- `PrismaUserFeatureRepository` - Database access -- `RedisUserFeatureRepository` - Cache operations (get/set/invalidate) -- `CachedUserFeatureRepository` - Orchestrates cache-aside pattern - -This results in 9 repository classes for 3 entities (Feature, TeamFeature, UserFeature), with repetitive cache-aside logic in each CachedXXRepository. - -## Target State - -A single repository class per entity with decorators handling caching: - -```typescript -class UserFeatureRepository { - @Memoize({ key: (userId, featureId) => `features:user:${userId}:${featureId}`, schema: userFeaturesSchema }) - async findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise { - return this.prisma.userFeatures.findUnique({ where: { userId_featureId: { userId, featureId } } }); - } - - @Unmemoize({ keys: (userId, featureId) => [`features:user:${userId}:${featureId}`] }) - async upsert(userId: number, featureId: FeatureId, enabled: boolean, assignedBy: string): Promise { - return this.prisma.userFeatures.upsert({ ... }); - } -} -``` - -## Implementation Steps - -### Step 1: Enable Decorators in tsconfig (if needed) - -The `packages/features/tsconfig.json` extends `@calcom/tsconfig/react-library.json`. We need to add `experimentalDecorators: true` to enable legacy decorators. - -**Note**: Many packages already have `experimentalDecorators: true` (apps/web, apps/api/v2, packages/trpc, etc.), so this is consistent with the codebase. - -### Step 2: Create Decorator Infrastructure - -Create new files in `packages/features/cache/`: - -1. **`packages/features/cache/decorators/Memoize.ts`** - - Decorator that wraps methods with cache-aside logic - - Accepts configuration: `key`, `ttl`, `schema` (Zod) - - Uses `IRedisService` for cache operations - - Validates cached data with Zod schema before returning - -2. **`packages/features/cache/decorators/Unmemoize.ts`** - - Decorator that invalidates cache keys after method execution - - Accepts configuration: `keys` (function returning array of cache keys) - - Invalidates specified keys after the wrapped method completes - -3. **`packages/features/cache/decorators/index.ts`** - - Re-exports both decorators - -4. **`packages/features/cache/decorators/types.ts`** - - Shared types for decorator configuration - -### Step 3: Implement @Memoize Decorator - -```typescript -interface MemoizeConfig { - key: (...args: TArgs) => string; - ttl?: number; - schema?: ZodSchema; -} - -function Memoize(config: MemoizeConfig) { - return function ( - target: (this: T, ...args: TArgs) => Promise, - context: ClassMethodDecoratorContext - ) { - return async function (this: T, ...args: TArgs) { - const cacheKey = config.key(...args); - const cached = await this.redis.get(cacheKey); - - if (cached !== null) { - if (config.schema) { - const parsed = config.schema.safeParse(cached); - if (parsed.success) return parsed.data; - } else { - return cached; - } - } - - const result = await target.call(this, ...args); - if (result !== null) { - await this.redis.set(cacheKey, result, { ttl: config.ttl ?? DEFAULT_TTL }); - } - return result; - }; - }; -} -``` - -### Step 4: Implement @Unmemoize Decorator - -```typescript -interface UnmemoizeConfig { - keys: (...args: TArgs) => string[]; -} - -function Unmemoize(config: UnmemoizeConfig) { - return function ( - target: (this: T, ...args: TArgs) => Promise, - context: ClassMethodDecoratorContext - ) { - return async function (this: T, ...args: TArgs) { - const result = await target.call(this, ...args); - const keysToInvalidate = config.keys(...args); - await Promise.all(keysToInvalidate.map((key) => this.redis.del(key))); - return result; - }; - }; -} -``` - -### Step 5: Refactor UserFeatureRepository - -Create a new unified `UserFeatureRepository` that uses decorators: - -```typescript -export class UserFeatureRepository implements IUserFeatureRepository { - constructor( - private prisma: PrismaClient, - private redis: IRedisService - ) {} - - async findByUserId(userId: number): Promise { - return this.prisma.userFeatures.findMany({ where: { userId } }); - } - - @Memoize({ - key: (userId: number, featureId: FeatureId) => `features:user:${userId}:${featureId}`, - schema: userFeaturesSchema, - }) - async findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise { - return this.prisma.userFeatures.findUnique({ - where: { userId_featureId: { userId, featureId } }, - }); - } - - @Memoize({ - key: (userId: number) => `features:user:autoOptIn:${userId}`, - schema: booleanSchema, - }) - async findAutoOptInByUserId(userId: number): Promise { - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - select: { autoOptInFeatures: true }, - }); - return user?.autoOptInFeatures ?? false; - } - - @Unmemoize({ - keys: (userId: number, featureId: FeatureId) => [`features:user:${userId}:${featureId}`], - }) - async upsert(userId: number, featureId: FeatureId, enabled: boolean, assignedBy: string): Promise { - return this.prisma.userFeatures.upsert({ - where: { userId_featureId: { userId, featureId } }, - create: { userId, featureId, enabled, assignedBy }, - update: { enabled, assignedBy }, - }); - } - - @Unmemoize({ - keys: (userId: number, featureId: FeatureId) => [`features:user:${userId}:${featureId}`], - }) - async delete(userId: number, featureId: FeatureId): Promise { - await this.prisma.userFeatures.delete({ - where: { userId_featureId: { userId, featureId } }, - }); - } - - @Unmemoize({ - keys: (userId: number) => [`features:user:autoOptIn:${userId}`], - }) - async updateAutoOptIn(userId: number, enabled: boolean): Promise { - await this.prisma.user.update({ - where: { id: userId }, - data: { autoOptInFeatures: enabled }, - }); - } - - // Methods without caching (complex queries) - async checkIfUserBelongsToTeamWithFeature(userId: number, slug: string): Promise { - // ... existing implementation - } - - async checkIfUserBelongsToTeamWithFeatureNonHierarchical(userId: number, slug: string): Promise { - // ... existing implementation - } -} -``` - -### Step 6: Update DI Configuration - -Update the DI module to use the new unified repository: - -```typescript -// packages/features/flags/di/UserFeatureRepository.module.ts -const loadModule = bindModuleToClassOnToken({ - module: thisModule, - moduleToken, - token, - classs: UserFeatureRepository, - depsMap: { - prisma: prismaModuleLoader, - redis: redisModuleLoader, - }, -}); -``` - -### Step 7: Write Tests - -Create comprehensive tests for the decorators: - -1. **`packages/features/cache/decorators/__tests__/Memoize.test.ts`** - - Test cache hit returns cached value - - Test cache miss fetches from source and caches - - Test Zod validation on cached data - - Test null results are not cached - - Test TTL is respected - -2. **`packages/features/cache/decorators/__tests__/Unmemoize.test.ts`** - - Test cache keys are invalidated after method execution - - Test multiple keys can be invalidated - - Test method result is returned correctly - -## File Structure - -``` -packages/features/ - cache/ - decorators/ - __tests__/ - Memoize.test.ts - Unmemoize.test.ts - Memoize.ts - Unmemoize.ts - types.ts - index.ts - flags/ - repositories/ - UserFeatureRepository.ts (new unified repository) - # Keep existing files for backward compatibility during migration - di/ - UserFeatureRepository.module.ts (new module) - tokens.ts (add new token) -``` - -## Migration Strategy - -1. Create the new decorator infrastructure alongside existing code -2. Create the new unified `UserFeatureRepository` with decorators -3. Add new DI module for the unified repository -4. Update consumers to use the new repository -5. Remove old CachedXXRepository, RedisXXRepository files after migration is complete - -## Benefits - -1. **Reduced boilerplate**: 1 class instead of 3 per entity -2. **Declarative caching**: Cache behavior is visible at method level -3. **Co-located logic**: Caching and invalidation rules are next to the methods they affect -4. **Type-safe**: Full TypeScript support with proper typing -5. **Testable**: Decorators can be tested independently -6. **Consistent**: Same pattern for all repositories - -## Considerations - -1. **Decorator limitations**: Legacy decorators (experimentalDecorators) are used since TC39 decorators have different semantics -2. **Redis dependency**: Repository classes must have `redis: IRedisService` property for decorators to work -3. **Zod validation**: Optional but recommended for cached data integrity -4. **TTL**: Default 5 minutes, configurable per method - -## Timeline - -1. Step 1-2: Create decorator infrastructure (~30 min) -2. Step 3-4: Implement decorators (~30 min) -3. Step 5: Refactor UserFeatureRepository (~30 min) -4. Step 6: Update DI configuration (~15 min) -5. Step 7: Write tests (~45 min) -6. Type checks and lint (~15 min) -7. Create PR (~10 min) - -Total estimated time: ~3 hours