Files
cal.diy/packages/trpc/server/routers/viewer/featureOptIn/_router.ts
T
Eunjae LeeandDevin AI f4248bf20d 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>
2026-01-06 16:55:53 +01:00

249 lines
7.4 KiB
TypeScript

import { z } from "zod";
import { isOptInFeature } from "@calcom/features/feature-opt-in/config";
import { FeatureOptInService } from "@calcom/features/feature-opt-in/services/FeatureOptInService";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
const featureStateSchema = z.enum(["enabled", "disabled", "inherit"]);
const featuresRepository = new FeaturesRepository(prisma);
const featureOptInService = new FeatureOptInService(featuresRepository);
/**
* Helper to get user's org and team IDs from their memberships.
* Returns orgId (if user belongs to an org) and teamIds (non-org teams).
*/
async function getUserOrgAndTeamIds(userId: number): Promise<{ orgId: number | null; teamIds: number[] }> {
const memberships = await MembershipRepository.findAllByUserId({
userId,
filters: { accepted: true },
});
let orgId: number | null = null;
const teamIds: number[] = [];
for (const membership of memberships) {
if (membership.team.isOrganization) {
orgId = membership.teamId;
} else {
teamIds.push(membership.teamId);
}
}
return { orgId, teamIds };
}
export const featureOptInRouter = router({
/**
* Get all opt-in features with states for current user.
* This considers all teams/orgs the user belongs to.
*/
listForUser: authedProcedure.query(async ({ ctx }) => {
const { orgId, teamIds } = await getUserOrgAndTeamIds(ctx.user.id);
return featureOptInService.listFeaturesForUser({
userId: ctx.user.id,
orgId,
teamIds,
});
}),
/**
* Get all opt-in features with states for a team settings page.
* Used by team admins to configure feature opt-in for their team.
*/
listForTeam: authedProcedure
.input(
z.object({
teamId: z.number(),
})
)
.query(async ({ ctx, input }) => {
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: input.teamId,
permission: "team.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to view team feature settings.",
});
}
return featureOptInService.listFeaturesForTeam({ teamId: input.teamId });
}),
/**
* Get all opt-in features with states for organization settings page.
* Used by org admins to configure feature opt-in for their organization.
* Uses the organization from the current user's context.
*/
listForOrganization: authedProcedure.query(async ({ ctx }) => {
const organizationId = ctx.user.organizationId;
if (!organizationId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You are not a member of any organization.",
});
}
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: organizationId,
permission: "organization.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to view organization feature settings.",
});
}
// Organizations use the same listFeaturesForTeam since they're stored in TeamFeatures
return featureOptInService.listFeaturesForTeam({ teamId: organizationId });
}),
/**
* Set user's feature state.
*/
setUserState: authedProcedure
.input(
z.object({
featureId: z.string(),
state: featureStateSchema,
})
)
.mutation(async ({ ctx, input }) => {
if (!isOptInFeature(input.featureId)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid featureId. This feature is not opt-in configurable.",
});
}
await featureOptInService.setUserFeatureState({
userId: ctx.user.id,
featureId: input.featureId,
state: input.state,
assignedBy: ctx.user.id,
});
return { success: true };
}),
/**
* Set team's feature state (requires team admin).
*/
setTeamState: authedProcedure
.input(
z.object({
teamId: z.number(),
featureId: z.string(),
state: featureStateSchema,
})
)
.mutation(async ({ ctx, input }) => {
if (!isOptInFeature(input.featureId)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid featureId. This feature is not opt-in configurable.",
});
}
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: input.teamId,
permission: "team.update",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to update team feature settings.",
});
}
await featureOptInService.setTeamFeatureState({
teamId: input.teamId,
featureId: input.featureId,
state: input.state,
assignedBy: ctx.user.id,
});
return { success: true };
}),
/**
* Set organization's feature state (requires org admin).
* Uses the organization from the current user's context.
*/
setOrganizationState: authedProcedure
.input(
z.object({
featureId: z.string(),
state: featureStateSchema,
})
)
.mutation(async ({ ctx, input }) => {
if (!isOptInFeature(input.featureId)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid featureId. This feature is not opt-in configurable.",
});
}
const organizationId = ctx.user.organizationId;
if (!organizationId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You are not a member of any organization.",
});
}
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: organizationId,
permission: "organization.update",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to update organization feature settings.",
});
}
// Organizations use the same TeamFeatures table
await featureOptInService.setTeamFeatureState({
teamId: organizationId,
featureId: input.featureId,
state: input.state,
assignedBy: ctx.user.id,
});
return { success: true };
}),
});