feat: implement FeatureOptInService (#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>
This commit is contained in:
1 parent
750408ec21
commit
f4248bf20d
35 files changed
+3564
-450
No files matched your search
+5
-1
@@ -112,7 +112,11 @@ describe("Organizations Roles Endpoints", () => {
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledOrganization.id, "pbac", "enabled");
|
||||
await featuresRepositoryFixture.setTeamFeatureState({
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
featureId: "pbac",
|
||||
state: "enabled",
|
||||
});
|
||||
|
||||
// Create memberships
|
||||
await membershipRepositoryFixture.create({
|
||||
|
||||
+5
-1
@@ -58,7 +58,11 @@ describe("Organizations Roles Permissions Endpoints", () => {
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledOrganization.id, "pbac", "enabled");
|
||||
await featuresRepositoryFixture.setTeamFeatureState({
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
featureId: "pbac",
|
||||
state: "enabled",
|
||||
});
|
||||
|
||||
// Create user + membership in org
|
||||
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||
|
||||
+5
-1
@@ -126,7 +126,11 @@ describe("Organizations Roles Endpoints", () => {
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledTeam.id, "pbac", "enabled");
|
||||
await featuresRepositoryFixture.setTeamFeatureState({
|
||||
teamId: pbacEnabledTeam.id,
|
||||
featureId: "pbac",
|
||||
state: "enabled",
|
||||
});
|
||||
|
||||
// Create memberships
|
||||
await membershipRepositoryFixture.create({
|
||||
|
||||
+5
-1
@@ -67,7 +67,11 @@ describe("Organizations Teams Roles Permissions Endpoints", () => {
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledTeam.id, "pbac", "enabled");
|
||||
await featuresRepositoryFixture.setTeamFeatureState({
|
||||
teamId: pbacEnabledTeam.id,
|
||||
featureId: "pbac",
|
||||
state: "enabled",
|
||||
});
|
||||
|
||||
// Create user + membership in org
|
||||
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||
|
||||
@@ -2,15 +2,19 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
import type { FeatureId } from "@calcom/features/flags/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class FeaturesRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
private prismaWriteClient: PrismaWriteService["prisma"];
|
||||
private featuresRepository: FeaturesRepository;
|
||||
|
||||
constructor(module: TestingModule) {
|
||||
this.prismaReadClient = module.get(PrismaReadService).prisma;
|
||||
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
|
||||
this.featuresRepository = new FeaturesRepository(this.prismaWriteClient);
|
||||
}
|
||||
async create(data: Prisma.FeatureCreateInput) {
|
||||
// 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 {
|
||||
});
|
||||
}
|
||||
|
||||
async createTeamFeature(data: Prisma.TeamFeaturesCreateInput) {
|
||||
return await this.prismaWriteClient.teamFeatures.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async setTeamFeatureState(
|
||||
teamId: number,
|
||||
featureId: string,
|
||||
state: "enabled" | "disabled" | "inherit",
|
||||
assignedBy = "test"
|
||||
input:
|
||||
| { teamId: number; featureId: string; state: "enabled" | "disabled"; assignedBy?: string }
|
||||
| { teamId: number; featureId: string; state: "inherit" }
|
||||
) {
|
||||
if (state === "enabled" || state === "disabled") {
|
||||
await this.prismaWriteClient.teamFeatures.upsert({
|
||||
where: {
|
||||
teamId_featureId: {
|
||||
teamId,
|
||||
featureId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId,
|
||||
featureId,
|
||||
assignedBy,
|
||||
enabled: state === "enabled",
|
||||
},
|
||||
update: {
|
||||
enabled: state === "enabled",
|
||||
},
|
||||
if (input.state === "inherit") {
|
||||
await this.featuresRepository.setTeamFeatureState({
|
||||
teamId: input.teamId,
|
||||
featureId: input.featureId as FeatureId,
|
||||
state: input.state,
|
||||
});
|
||||
} else if (state === "inherit") {
|
||||
await this.prismaWriteClient.teamFeatures.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
featureId,
|
||||
},
|
||||
} else {
|
||||
await this.featuresRepository.setTeamFeatureState({
|
||||
teamId: input.teamId,
|
||||
featureId: input.featureId as FeatureId,
|
||||
state: input.state,
|
||||
assignedBy: input.assignedBy ?? "test",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async disableFeatureForTeam(teamId: number, featureSlug: string) {
|
||||
return await this.prismaWriteClient.teamFeatures.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
featureId: featureSlug,
|
||||
},
|
||||
await this.featuresRepository.setTeamFeatureState({
|
||||
teamId,
|
||||
featureId: featureSlug as FeatureId,
|
||||
state: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,11 +62,10 @@ export class FeaturesRepositoryFixture {
|
||||
}
|
||||
|
||||
async deleteTeamFeature(teamId: number, featureSlug: string) {
|
||||
return await this.prismaWriteClient.teamFeatures.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
featureId: featureSlug,
|
||||
},
|
||||
await this.featuresRepository.setTeamFeatureState({
|
||||
teamId,
|
||||
featureId: featureSlug as FeatureId,
|
||||
state: "inherit",
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user