Files
calendar/packages/features/flags/repositories/PrismaTeamFeatureRepository.ts
T
512002aaef refactor: replace FeaturesRepository with DI-based feature repositories (#27200)
* refactor: replace FeaturesRepository with DI-based feature repositories

Migrate from the legacy FeaturesRepository pattern to separate DI-based repositories:
- Add checkIfFeatureIsEnabledGlobally to IFeatureRepository
- Add getTeamsWithFeatureEnabled to ITeamFeatureRepository
- Update CalendarSubscriptionService to use featureRepository, teamFeatureRepository, and userFeatureRepository
- Update SelectedCalendarRepository.findNextSubscriptionBatch to filter by teamIds instead of featureIds
- Update OnboardingPathService to use DI via getFeatureRepository()
- Remove prisma parameter from OnboardingPathService callsites

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: update tests to use new DI-based feature repositories

Update CalendarSubscriptionService and SelectedCalendarRepository tests
to use the new separate repository interfaces:
- featureRepository for global feature checks
- teamFeatureRepository for team-level feature checks
- userFeatureRepository for user-level feature checks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: update API routes to use DI-based feature repositories

Update cron and webhook routes to use the new separate repository
interfaces instead of the combined FeaturesRepository.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove prisma arg from getGettingStartedPathWithParams call

The OnboardingPathService method no longer requires a prisma argument
as it now uses DI containers internally.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: update route tests to expect new DI-based feature repositories

Update service instantiation tests to expect featureRepository,
teamFeatureRepository, and userFeatureRepository instead of the
old featuresRepository.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: optimize global feature check and add guard in checkForNewSubscriptions

- Use targeted select for only `enabled` field in checkIfFeatureIsEnabledGlobally
- Add global feature flag guard in checkForNewSubscriptions to avoid unnecessary
  DB queries and API calls when the cache feature is globally disabled

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* remove redundant comment

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 09:34:03 +00:00

267 lines
7.4 KiB
TypeScript

import type { TeamFeaturesDto } from "@calcom/lib/dto/TeamFeaturesDto";
import type { PrismaClient } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import { captureException } from "@sentry/nextjs";
import type { FeatureId, TeamFeatures } from "../config";
export interface ITeamFeatureRepository {
findByTeamIdAndFeatureId(teamId: number, featureId: FeatureId): Promise<TeamFeaturesDto | null>;
findByTeamIdsAndFeatureIds(
teamIds: number[],
featureIds: FeatureId[]
): Promise<Partial<Record<FeatureId, Record<number, TeamFeaturesDto>>>>;
upsert(
teamId: number,
featureId: FeatureId,
enabled: boolean,
assignedBy: string
): Promise<TeamFeaturesDto>;
delete(teamId: number, featureId: FeatureId): Promise<void>;
findAutoOptInByTeamId(teamId: number): Promise<boolean>;
findAutoOptInByTeamIds(teamIds: number[]): Promise<Record<number, boolean>>;
setAutoOptIn(teamId: number, enabled: boolean): Promise<void>;
checkIfTeamHasFeature(teamId: number, featureId: FeatureId): Promise<boolean>;
getEnabledFeatures(teamId: number): Promise<TeamFeatures | null>;
getTeamsWithFeatureEnabled(featureId: FeatureId): Promise<number[]>;
}
export class PrismaTeamFeatureRepository implements ITeamFeatureRepository {
private prisma: PrismaClient;
constructor(prisma: PrismaClient) {
this.prisma = prisma;
}
async findByTeamIdAndFeatureId(teamId: number, featureId: FeatureId): Promise<TeamFeaturesDto | null> {
const result = await this.prisma.teamFeatures.findUnique({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
});
if (!result) return null;
return this.toDto(result);
}
async findByTeamIdsAndFeatureIds(
teamIds: number[],
featureIds: FeatureId[]
): Promise<Partial<Record<FeatureId, Record<number, TeamFeaturesDto>>>> {
if (teamIds.length === 0 || featureIds.length === 0) {
return {};
}
const results = await Promise.all(
teamIds.flatMap((teamId) =>
featureIds.map(async (featureId) => {
const teamFeature = await this.findByTeamIdAndFeatureId(teamId, featureId);
return { teamId, featureId, teamFeature };
})
)
);
const result: Partial<Record<FeatureId, Record<number, TeamFeaturesDto>>> = {};
for (const { teamId, featureId, teamFeature } of results) {
if (teamFeature !== null) {
if (!result[featureId]) {
result[featureId] = {};
}
const featureRecord = result[featureId];
if (featureRecord) {
featureRecord[teamId] = teamFeature;
}
}
}
return result;
}
async upsert(
teamId: number,
featureId: FeatureId,
enabled: boolean,
assignedBy: string
): Promise<TeamFeaturesDto> {
const result = await this.prisma.teamFeatures.upsert({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
create: {
teamId,
featureId,
enabled,
assignedBy,
},
update: {
enabled,
assignedBy,
},
});
return this.toDto(result);
}
private toDto(teamFeature: {
teamId: number;
featureId: string;
enabled: boolean;
assignedBy: string;
updatedAt: Date;
}): TeamFeaturesDto {
return {
teamId: teamFeature.teamId,
featureId: teamFeature.featureId,
enabled: teamFeature.enabled,
assignedBy: teamFeature.assignedBy,
updatedAt: teamFeature.updatedAt,
};
}
async delete(teamId: number, featureId: FeatureId): Promise<void> {
await this.prisma.teamFeatures.delete({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
});
}
async findAutoOptInByTeamId(teamId: number): Promise<boolean> {
const team = await this.prisma.team.findUnique({
where: { id: teamId },
select: { autoOptInFeatures: true },
});
return team?.autoOptInFeatures ?? false;
}
async findAutoOptInByTeamIds(teamIds: number[]): Promise<Record<number, boolean>> {
if (teamIds.length === 0) {
return {};
}
const results = await Promise.all(
teamIds.map(async (teamId) => {
const autoOptIn = await this.findAutoOptInByTeamId(teamId);
return { teamId, autoOptIn };
})
);
const result: Record<number, boolean> = {};
for (const { teamId, autoOptIn } of results) {
result[teamId] = autoOptIn;
}
return result;
}
async setAutoOptIn(teamId: number, enabled: boolean): Promise<void> {
await this.prisma.team.update({
where: { id: teamId },
data: { autoOptInFeatures: enabled },
});
}
/**
* Checks if a team or any of its ancestors has access to a specific feature.
* Uses a recursive CTE raw SQL query for performance.
* Uses tri-state semantics: only treats as enabled if TeamFeatures row exists AND enabled=true.
*/
async checkIfTeamHasFeature(teamId: number, featureId: FeatureId): Promise<boolean> {
try {
const teamFeature = await this.prisma.teamFeatures.findUnique({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
select: { enabled: true },
});
if (teamFeature) return teamFeature.enabled;
const query = Prisma.sql`
WITH RECURSIVE TeamHierarchy AS (
SELECT id, "parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = id AND tf."featureId" = ${featureId} AND tf."enabled" = true
) THEN true ELSE false END as has_feature
FROM "Team"
WHERE id = ${teamId}
UNION ALL
SELECT p.id, p."parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = p.id AND tf."featureId" = ${featureId} 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
)
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);
console.error(
`Recursive feature check failed for team ${teamId}, feature ${featureId}:`,
err instanceof Error ? err.message : err
);
throw err;
}
}
/**
* Gets all features enabled for a specific team in a map format.
*/
async getEnabledFeatures(teamId: number): Promise<TeamFeatures | null> {
const result = await this.prisma.teamFeatures.findMany({
where: {
teamId,
enabled: true,
},
select: {
feature: {
select: {
slug: true,
},
},
},
});
if (!result.length) return null;
const features: TeamFeatures = Object.fromEntries(
result.map((teamFeature) => [teamFeature.feature.slug, true])
) as TeamFeatures;
return features;
}
async getTeamsWithFeatureEnabled(featureId: FeatureId): Promise<number[]> {
const result = await this.prisma.teamFeatures.findMany({
where: {
featureId,
enabled: true,
},
select: {
teamId: true,
},
});
return result.map((tf) => tf.teamId);
}
}