Skip to content

Commit f4248bf

Browse files
feat: implement FeatureOptInService (calcom#25805)
* feat: implement FeatureOptInService WIP * clean up * feat: consolidate feature repositories and add updateFeatureForUser - Implement updateFeatureForUser in FeaturesRepository (similar to updateFeatureForTeam) - Move getUserFeatureState and getTeamFeatureState from PrismaFeatureOptInRepository to FeaturesRepository - Update FeatureOptInService to use only FeaturesRepository - Add setUserFeatureState and setTeamFeatureState methods to FeatureOptInService - Update _router.ts to remove PrismaFeatureOptInRepository usage - Remove PrismaFeatureOptInRepository.ts and FeatureOptInRepositoryInterface.ts - Update features.repository.interface.ts and features.repository.mock.ts - Add integration tests for updateFeatureForUser, getUserFeatureState, getTeamFeatureState - Update service.integration-test.ts to use FeaturesRepository Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: rename updateFeatureForUser to setUserFeatureState Rename to match the convention used for setTeamFeatureState Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: return FeatureState type from getUserFeatureState and getTeamFeatureState * fix integration tests * clean up logics * update services and router * refactor: change getUserFeatureState and getTeamFeatureState to accept featureIds array - Renamed getUserFeatureState to getUserFeatureStates - Renamed getTeamFeatureState to getTeamFeatureStates - Changed parameter from featureId: string to featureIds: string[] - Changed return type from FeatureState to Record<string, FeatureState> - Updated FeatureOptInService to use the new batch methods - Added tests for querying multiple features in a single call - Optimized listFeaturesForTeam to fetch all feature states in one query Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: add getFeatureStateForTeams for batch querying multiple teams - Added getFeatureStateForTeams method to query a single feature across multiple teams in one call - Updated FeatureOptInService.resolveFeatureStateAcrossTeams to use the new batch method - Replaces N+1 queries with a single database query for team states - Added comprehensive integration tests for the new method Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: combine org and team state queries into single call - Include orgId in the teamIds array passed to getFeatureStateForTeams - Extract org state and team states from the combined result - Reduces database queries from 3 to 2 in resolveFeatureStateAcrossTeams Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: use team.isOrganization and clarify computeEffectiveState comment Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: use MembershipRepository.findAllByUserId with isOrganization Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: add featureId validation using isOptInFeature type guard Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * less queries * add fallback value * fix type error * move files * add autoOptInFeatures column * use autoOptInFeatures flag within FeatureOptInService * add setUserAutoOptIn and setTeamAutoOptIn * fix computeEffectiveState logic * rewrite computeEffectiveState * clean up integration tests * clean up in afterEach * fix type error * refactor: use FeaturesRepository methods instead of direct Prisma calls Replace all manual userFeatures and teamFeatures Prisma operations with the new setUserFeatureState and setTeamFeatureState repository methods. Changes include: - Admin handlers (assignFeatureToTeam, unassignFeatureFromTeam) - Test fixtures and integration tests - Playwright fixtures - Development scripts This ensures consistent feature flag management through the repository pattern and supports the new tri-state semantics (enabled/disabled/inherit). Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * clean up * fix the logic * extract some logic into applyAutoOptIn() * remove wrong code * refactor: convert setUserFeatureState and setTeamFeatureState to object params with discriminated union - Convert multiple positional parameters to single object parameter - Use discriminated union types: assignedBy required for enabled/disabled, omitted for inherit - Update all callers across repository, service, handlers, fixtures, and tests * fix type error * use Promise.all * fix --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 750408e commit f4248bf

35 files changed

Lines changed: 3564 additions & 450 deletions

apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.e2e-spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,11 @@ describe("Organizations Roles Endpoints", () => {
112112
});
113113

114114
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
115-
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledOrganization.id, "pbac", "enabled");
115+
await featuresRepositoryFixture.setTeamFeatureState({
116+
teamId: pbacEnabledOrganization.id,
117+
featureId: "pbac",
118+
state: "enabled",
119+
});
116120

117121
// Create memberships
118122
await membershipRepositoryFixture.create({

apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.e2e-spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ describe("Organizations Roles Permissions Endpoints", () => {
5858
});
5959

6060
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
61-
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledOrganization.id, "pbac", "enabled");
61+
await featuresRepositoryFixture.setTeamFeatureState({
62+
teamId: pbacEnabledOrganization.id,
63+
featureId: "pbac",
64+
state: "enabled",
65+
});
6266

6367
// Create user + membership in org
6468
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({

apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.e2e-spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,11 @@ describe("Organizations Roles Endpoints", () => {
126126
});
127127

128128
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
129-
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledTeam.id, "pbac", "enabled");
129+
await featuresRepositoryFixture.setTeamFeatureState({
130+
teamId: pbacEnabledTeam.id,
131+
featureId: "pbac",
132+
state: "enabled",
133+
});
130134

131135
// Create memberships
132136
await membershipRepositoryFixture.create({

apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.e2e-spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ describe("Organizations Teams Roles Permissions Endpoints", () => {
6767
});
6868

6969
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
70-
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledTeam.id, "pbac", "enabled");
70+
await featuresRepositoryFixture.setTeamFeatureState({
71+
teamId: pbacEnabledTeam.id,
72+
featureId: "pbac",
73+
state: "enabled",
74+
});
7175

7276
// Create user + membership in org
7377
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({

apps/api/v2/test/fixtures/repository/features.repository.fixture.ts

Lines changed: 26 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
22
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
33
import { TestingModule } from "@nestjs/testing";
44

5+
import type { FeatureId } from "@calcom/features/flags/config";
6+
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
57
import type { Prisma } from "@calcom/prisma/client";
68

79
export class FeaturesRepositoryFixture {
810
private prismaReadClient: PrismaReadService["prisma"];
911
private prismaWriteClient: PrismaWriteService["prisma"];
12+
private featuresRepository: FeaturesRepository;
1013

1114
constructor(module: TestingModule) {
1215
this.prismaReadClient = module.get(PrismaReadService).prisma;
1316
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
17+
this.featuresRepository = new FeaturesRepository(this.prismaWriteClient);
1418
}
1519
async create(data: Prisma.FeatureCreateInput) {
1620
// note(Lauris): upserting because this create function is called in multiple tests in parallel and otherwise would lead to unique
@@ -22,52 +26,32 @@ export class FeaturesRepositoryFixture {
2226
});
2327
}
2428

25-
async createTeamFeature(data: Prisma.TeamFeaturesCreateInput) {
26-
return await this.prismaWriteClient.teamFeatures.create({
27-
data,
28-
});
29-
}
30-
3129
async setTeamFeatureState(
32-
teamId: number,
33-
featureId: string,
34-
state: "enabled" | "disabled" | "inherit",
35-
assignedBy = "test"
30+
input:
31+
| { teamId: number; featureId: string; state: "enabled" | "disabled"; assignedBy?: string }
32+
| { teamId: number; featureId: string; state: "inherit" }
3633
) {
37-
if (state === "enabled" || state === "disabled") {
38-
await this.prismaWriteClient.teamFeatures.upsert({
39-
where: {
40-
teamId_featureId: {
41-
teamId,
42-
featureId,
43-
},
44-
},
45-
create: {
46-
teamId,
47-
featureId,
48-
assignedBy,
49-
enabled: state === "enabled",
50-
},
51-
update: {
52-
enabled: state === "enabled",
53-
},
34+
if (input.state === "inherit") {
35+
await this.featuresRepository.setTeamFeatureState({
36+
teamId: input.teamId,
37+
featureId: input.featureId as FeatureId,
38+
state: input.state,
5439
});
55-
} else if (state === "inherit") {
56-
await this.prismaWriteClient.teamFeatures.deleteMany({
57-
where: {
58-
teamId,
59-
featureId,
60-
},
40+
} else {
41+
await this.featuresRepository.setTeamFeatureState({
42+
teamId: input.teamId,
43+
featureId: input.featureId as FeatureId,
44+
state: input.state,
45+
assignedBy: input.assignedBy ?? "test",
6146
});
6247
}
6348
}
6449

6550
async disableFeatureForTeam(teamId: number, featureSlug: string) {
66-
return await this.prismaWriteClient.teamFeatures.deleteMany({
67-
where: {
68-
teamId,
69-
featureId: featureSlug,
70-
},
51+
await this.featuresRepository.setTeamFeatureState({
52+
teamId,
53+
featureId: featureSlug as FeatureId,
54+
state: "inherit",
7155
});
7256
}
7357

@@ -78,11 +62,10 @@ export class FeaturesRepositoryFixture {
7862
}
7963

8064
async deleteTeamFeature(teamId: number, featureSlug: string) {
81-
return await this.prismaWriteClient.teamFeatures.deleteMany({
82-
where: {
83-
teamId,
84-
featureId: featureSlug,
85-
},
65+
await this.featuresRepository.setTeamFeatureState({
66+
teamId,
67+
featureId: featureSlug as FeatureId,
68+
state: "inherit",
8669
});
8770
}
8871
}

apps/web/playwright/fixtures/users.ts

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { v4 } from "uuid";
66

77
import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes";
88
import stripe from "@calcom/features/ee/payments/server/stripe";
9-
import type { AppFlags } from "@calcom/features/flags/config";
9+
import type { AppFlags, FeatureId } from "@calcom/features/flags/config";
10+
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
1011
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
1112
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
1213
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -253,15 +254,17 @@ const createTeamAndAddUser = async (
253254

254255
// Enable feature flags for the team if specified
255256
if (teamFeatureFlags && teamFeatureFlags.length > 0) {
256-
await prisma.teamFeatures.createMany({
257-
data: teamFeatureFlags.map((featureFlag) => ({
258-
teamId: team.id,
259-
featureId: featureFlag,
260-
assignedBy: "e2e-fixture",
261-
assignedAt: new Date(),
262-
enabled: true,
263-
})),
264-
});
257+
const featuresRepository = new FeaturesRepository(prisma);
258+
await Promise.all(
259+
teamFeatureFlags.map((featureFlag) =>
260+
featuresRepository.setTeamFeatureState({
261+
teamId: team.id,
262+
featureId: featureFlag as FeatureId,
263+
state: "enabled",
264+
assignedBy: "e2e-fixture",
265+
})
266+
)
267+
);
265268
}
266269

267270
return team;
@@ -423,15 +426,17 @@ export const createUsersFixture = (
423426
// Default to DEFAULT_USER_FEATURE_FLAGS if not specified
424427
const userFeatureFlags = opts?.userFeatureFlags ?? DEFAULT_USER_FEATURE_FLAGS;
425428
if (userFeatureFlags.length > 0) {
426-
await prisma.userFeatures.createMany({
427-
data: userFeatureFlags.map((featureFlag) => ({
428-
userId: user.id,
429-
featureId: featureFlag,
430-
assignedBy: "e2e-fixture",
431-
assignedAt: new Date(),
432-
enabled: true,
433-
})),
434-
});
429+
const featuresRepository = new FeaturesRepository(prisma);
430+
await Promise.all(
431+
userFeatureFlags.map((featureFlag) =>
432+
featuresRepository.setUserFeatureState({
433+
userId: user.id,
434+
featureId: featureFlag as FeatureId,
435+
state: "enabled",
436+
assignedBy: "e2e-fixture",
437+
})
438+
)
439+
);
435440
}
436441

437442
if (scenario.hasTeam) {

apps/web/playwright/lib/test-helpers/pbac.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { FeatureId } from "@calcom/features/flags/config";
2+
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
13
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
24
import { prisma } from "@calcom/prisma";
35

@@ -18,13 +20,11 @@ export const createAllPermissionsArray = () => {
1820
};
1921

2022
export const enablePBACForTeam = async (teamId: number) => {
21-
await prisma.teamFeatures.create({
22-
data: {
23-
featureId: "pbac",
24-
teamId: teamId,
25-
assignedBy: "e2e",
26-
assignedAt: new Date(),
27-
enabled: true,
28-
},
23+
const featuresRepository = new FeaturesRepository(prisma);
24+
await featuresRepository.setTeamFeatureState({
25+
teamId,
26+
featureId: "pbac" as FeatureId,
27+
state: "enabled",
28+
assignedBy: "e2e",
2929
});
3030
};

packages/app-store/delegationCredential.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { organizationRepositoryMock } from "@calcom/features/ee/organizations/__mocks__/organizationMock";
2+
13
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
24

35
import { describe, it, expect, beforeEach, vi } from "vitest";
@@ -6,7 +8,6 @@ import { metadata as googleCalendarMetadata } from "@calcom/app-store/googlecale
68
import { metadata as googleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
79
import type { ServiceAccountKey } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
810
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
9-
import { organizationRepositoryMock } from "@calcom/features/ee/organizations/__mocks__/organizationMock";
1011
import { SMSLockState, RRTimestampBasis } from "@calcom/prisma/enums";
1112
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
1213

@@ -105,6 +106,7 @@ const mockOrganization = {
105106
hideTeamProfileLink: false,
106107
rrResetInterval: null,
107108
rrTimestampBasis: RRTimestampBasis.CREATED_AT,
109+
autoOptInFeatures: false,
108110
};
109111

110112
// Credential Builders

0 commit comments

Comments
 (0)