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:
Eunjae LeeandDevin AI authored and GitHub committed 2026-01-06 16:55:53 +01:00
1 parent 750408ec21
commit f4248bf20d
35 files changed
+3564 -450

No files matched your search

@@ -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({
@@ -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({
@@ -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({
@@ -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",
});
}
}