29ec2b9cb6
* 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>
275 lines
7.6 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|