* 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>
249 lines
7.4 KiB
TypeScript
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 };
|
|
}),
|
|
});
|