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:
co-authored by
Devin AI
Rajiv Sahal
parent
7c8f17ccc7
commit
26e225e87b
@@ -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
|
||||
Reference in New Issue
Block a user