chore: remove temporary plan files that were accidentally merged (#28381)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
This commit is contained in:
Eunjae Lee
2026-03-12 08:54:00 +00:00
committed by GitHub
co-authored by Devin AI Rajiv Sahal
parent 7c8f17ccc7
commit 26e225e87b
2 changed files with 0 additions and 491 deletions
@@ -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 && <FeatureOptInBanner {...optInBanner} />}`
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
@@ -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<UserFeatures | null> {
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<UserFeatures> {
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<TArgs extends unknown[]> {
key: (...args: TArgs) => string;
ttl?: number;
schema?: ZodSchema;
}
function Memoize<TArgs extends unknown[]>(config: MemoizeConfig<TArgs>) {
return function <T extends { redis: IRedisService }>(
target: (this: T, ...args: TArgs) => Promise<unknown>,
context: ClassMethodDecoratorContext
) {
return async function (this: T, ...args: TArgs) {
const cacheKey = config.key(...args);
const cached = await this.redis.get<unknown>(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<TArgs extends unknown[]> {
keys: (...args: TArgs) => string[];
}
function Unmemoize<TArgs extends unknown[]>(config: UnmemoizeConfig<TArgs>) {
return function <T extends { redis: IRedisService }>(
target: (this: T, ...args: TArgs) => Promise<unknown>,
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<UserFeatures[]> {
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<UserFeatures | null> {
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<boolean> {
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<UserFeatures> {
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<void> {
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<void> {
await this.prisma.user.update({
where: { id: userId },
data: { autoOptInFeatures: enabled },
});
}
// Methods without caching (complex queries)
async checkIfUserBelongsToTeamWithFeature(userId: number, slug: string): Promise<boolean> {
// ... existing implementation
}
async checkIfUserBelongsToTeamWithFeatureNonHierarchical(userId: number, slug: string): Promise<boolean> {
// ... 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