Files
calendar/packages/features/flags/repositories/PrismaUserFeatureRepository.ts
T
Eunjae LeeGitHubunknown <>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
29ec2b9cb6 refactor: split flag repositories into Prisma and Cached layers (#27186)
* refactor: split flag repositories into Prisma and Cached layers

- Rename FeatureRepository to PrismaFeatureRepository (raw DB access)
- Rename TeamFeatureRepository to PrismaTeamFeatureRepository (raw DB access)
- Rename UserFeatureRepository to PrismaUserFeatureRepository (raw DB access)
- Create CachedFeatureRepository with @Memoize wrapping PrismaFeatureRepository
- Create CachedTeamFeatureRepository with @Memoize/@Unmemoize wrapping PrismaTeamFeatureRepository
- Create CachedUserFeatureRepository with @Memoize/@Unmemoize wrapping PrismaUserFeatureRepository
- Update DI tokens, modules, and containers for all 6 repositories
- Update imports in FeatureOptInService and related modules
- Update tests to use new repository structure

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: simplify @Memoize key patterns and delegate batch methods to Prisma

- Use direct function references for @Memoize key (e.g., KEY.all instead of () => KEY.all())
- Simplify batch methods in Cached repositories to delegate to Prisma repository
- Update tests to reflect the new delegation pattern

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: add orderBy to TeamRepository.findAllByParentId for deterministic results

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* test: update TeamRepository test to expect orderBy in findAllByParentId

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: cleanup features repository and add specialized repository methods (#27195)

* refactor: cleanup features repository and add findBySlug, update methods

- Remove unused methods from FeaturesRepository (keep getTeamsWithFeatureEnabled)
- Add findAll(), findBySlug(), update() to IFeatureRepository interface
- Add findAll() with caching to CachedFeatureRepository
- Add findBySlug() with caching to CachedFeatureRepository
- Add update() with Unmemoize to CachedFeatureRepository
- Add checkIfFeatureIsEnabledGlobally() to CachedFeatureRepository
- Update toggleFeatureFlag.handler.ts to use repository instead of raw Prisma
- Add comprehensive unit tests for all new methods

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: update updatedAt timestamp in feature update method

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: move feature check methods to specialized repositories

- Replace getUserFeaturesStatus with two checkIfUserHasFeature calls in bookings page
- Move checkIfTeamHasFeature to PrismaTeamFeatureRepository with pass-through in CachedTeamFeatureRepository
- Move checkIfUserHasFeature and checkIfUserHasFeatureNonHierarchical to PrismaUserFeatureRepository with pass-throughs in CachedUserFeatureRepository
- Add getEnabledFeatures to PrismaTeamFeatureRepository with caching in CachedTeamFeatureRepository
- Keep FeaturesRepository methods as pass-throughs for backward compatibility
- Update test to expect updatedAt in feature update

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: remove getUserFeaturesStatus and unused methods from FeaturesRepository

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* restore comment

* fix: invalidate all-features cache on update and enabledFeatures cache on upsert/delete

- CachedFeatureRepository: Add KEY.all() to @Unmemoize keys in update() to prevent stale findAll() results
- CachedTeamFeatureRepository: Add KEY.enabledFeatures(teamId) to @Unmemoize keys in upsert() and delete() to prevent stale getEnabledFeatures() results

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: add CachedUserFeatureRepository tests

Add comprehensive tests for CachedUserFeatureRepository covering:
- findByUserIdAndFeatureId (cache hit, cache miss, not found)
- findByUserIdAndFeatureIds (empty input, multiple features)
- upsert (with cache invalidation)
- delete (with cache invalidation)
- findAutoOptInByUserId (cache hit, cache miss, not found)
- setAutoOptIn (with cache invalidation)

Co-Authored-By: unknown <>

* test: remove tests for methods removed from FeaturesRepository

Remove integration tests for methods that were intentionally removed:
- getUserFeatureStates
- getTeamsFeatureStates
- getUserAutoOptIn
- getTeamsAutoOptIn
- setUserAutoOptIn
- setTeamAutoOptIn

Co-Authored-By: unknown <>

* avoid N+1 query

* refactor: add select clauses to PrismaFeatureRepository queries

- Add explicit select clauses to findAll, findBySlug, and update methods
- Only fetch fields needed for FeatureDto (slug, enabled, description, type, stale, lastUsedAt, createdAt, updatedAt, updatedBy)
- Update tests to expect select clauses
- Fix UserFeatureRepository test to use findMany mock

Co-Authored-By: unknown <>

* fix bad conflict resolved

* use userId

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-02 14:26:23 +01:00

275 lines
7.6 KiB
TypeScript

import type { UserFeaturesDto } from "@calcom/lib/dto/UserFeaturesDto";
import type { PrismaClient } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import { captureException } from "@sentry/nextjs";
import type { FeatureId } from "../config";
export interface IUserFeatureRepository {
findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise<UserFeaturesDto | null>;
findByUserIdAndFeatureIds(
userId: number,
featureIds: FeatureId[]
): Promise<Partial<Record<FeatureId, UserFeaturesDto>>>;
upsert(
userId: number,
featureId: FeatureId,
enabled: boolean,
assignedBy: string
): Promise<UserFeaturesDto>;
delete(userId: number, featureId: FeatureId): Promise<void>;
findAutoOptInByUserId(userId: number): Promise<boolean>;
setAutoOptIn(userId: number, enabled: boolean): Promise<void>;
checkIfUserHasFeature(userId: number, slug: string): Promise<boolean>;
checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise<boolean>;
}
export class PrismaUserFeatureRepository implements IUserFeatureRepository {
private prisma: PrismaClient;
constructor(prisma: PrismaClient) {
this.prisma = prisma;
}
async findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise<UserFeaturesDto | null> {
const result = await this.prisma.userFeatures.findUnique({
where: {
userId_featureId: {
userId,
featureId,
},
},
});
if (!result) return null;
return this.toDto(result);
}
async findByUserIdAndFeatureIds(
userId: number,
featureIds: FeatureId[]
): Promise<Partial<Record<FeatureId, UserFeaturesDto>>> {
if (featureIds.length === 0) {
return {};
}
const results = await this.prisma.userFeatures.findMany({
where: {
userId,
featureId: {
in: featureIds,
},
},
select: {
userId: true,
featureId: true,
enabled: true,
assignedBy: true,
updatedAt: true,
},
});
const result: Partial<Record<FeatureId, UserFeaturesDto>> = {};
for (const userFeature of results) {
result[userFeature.featureId as FeatureId] = this.toDto(userFeature);
}
return result;
}
async upsert(
userId: number,
featureId: FeatureId,
enabled: boolean,
assignedBy: string
): Promise<UserFeaturesDto> {
const result = await this.prisma.userFeatures.upsert({
where: {
userId_featureId: {
userId,
featureId,
},
},
create: {
userId,
featureId,
enabled,
assignedBy,
},
update: {
enabled,
assignedBy,
},
});
return this.toDto(result);
}
private toDto(userFeature: {
userId: number;
featureId: string;
enabled: boolean;
assignedBy: string;
updatedAt: Date;
}): UserFeaturesDto {
return {
userId: userFeature.userId,
featureId: userFeature.featureId,
enabled: userFeature.enabled,
assignedBy: userFeature.assignedBy,
updatedAt: userFeature.updatedAt,
};
}
async delete(userId: number, featureId: FeatureId): Promise<void> {
await this.prisma.userFeatures.delete({
where: {
userId_featureId: {
userId,
featureId,
},
},
});
}
async findAutoOptInByUserId(userId: number): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { autoOptInFeatures: true },
});
return user?.autoOptInFeatures ?? false;
}
async setAutoOptIn(userId: number, enabled: boolean): Promise<void> {
await this.prisma.user.update({
where: { id: userId },
data: { autoOptInFeatures: enabled },
});
}
/**
* Checks if a specific user has access to a feature based on user and team assignments.
* Uses tri-state semantics:
* - Row with enabled=true → feature is enabled
* - Row with enabled=false → feature is explicitly disabled (blocks inheritance)
* - No row → inherit from team/org level
*/
async checkIfUserHasFeature(userId: number, slug: string): Promise<boolean> {
try {
const userFeature = await this.prisma.userFeatures.findFirst({
where: {
userId,
featureId: slug,
},
select: { enabled: true },
});
if (userFeature) {
return userFeature.enabled;
}
const userBelongsToTeamWithFeature = await this.checkIfUserBelongsToTeamWithFeature(userId, slug);
return userBelongsToTeamWithFeature;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Checks if a specific user has access to a feature, ignoring hierarchical (parent) teams.
* Only checks direct user assignments and direct team memberships — does not traverse parents.
*/
async checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise<boolean> {
try {
const userFeature = await this.prisma.userFeatures.findFirst({
where: {
userId,
featureId: slug,
},
select: { enabled: true },
});
if (userFeature) {
return userFeature.enabled;
}
const userBelongsToTeamWithFeature = await this.checkIfUserBelongsToTeamWithFeatureNonHierarchical(
userId,
slug
);
return userBelongsToTeamWithFeature;
} catch (err) {
captureException(err);
throw err;
}
}
private async checkIfUserBelongsToTeamWithFeature(userId: number, slug: string): Promise<boolean> {
try {
const query = Prisma.sql`
WITH RECURSIVE TeamHierarchy AS (
-- Start with teams the user belongs to
SELECT DISTINCT t.id, t."parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = t.id AND tf."featureId" = ${slug} AND tf."enabled" = true
) THEN true ELSE false END as has_feature
FROM "Team" t
INNER JOIN "Membership" m ON m."teamId" = t.id
WHERE m."userId" = ${userId} AND m.accepted = true
UNION ALL
-- Recursively get parent teams
SELECT DISTINCT p.id, p."parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = p.id AND tf."featureId" = ${slug} AND tf."enabled" = true
) THEN true ELSE false END as has_feature
FROM "Team" p
INNER JOIN TeamHierarchy c ON p.id = c."parentId"
WHERE NOT c.has_feature -- Stop recursion if we found a team with the feature
)
SELECT 1
FROM TeamHierarchy
WHERE has_feature = true
LIMIT 1;
`;
const result = await this.prisma.$queryRaw<unknown[]>(query);
return result.length > 0;
} catch (err) {
captureException(err);
throw err;
}
}
private async checkIfUserBelongsToTeamWithFeatureNonHierarchical(
userId: number,
slug: string
): Promise<boolean> {
try {
const query = Prisma.sql`
SELECT 1
FROM "Team" t
INNER JOIN "Membership" m ON m."teamId" = t.id
WHERE m."userId" = ${userId}
AND m.accepted = true
AND EXISTS (
SELECT 1
FROM "TeamFeatures" tf
WHERE tf."teamId" = t.id
AND tf."featureId" = ${slug}
AND tf."enabled" = true
)
LIMIT 1;
`;
const result = await this.prisma.$queryRaw<unknown[]>(query);
return result.length > 0;
} catch (err) {
captureException(err);
throw err;
}
}
}