fix: use FeatureOptInService for bookings-v3 check (#27183)

* fix: use FeatureOptInService for bookings-v3 check

* refactor: simplify FeatureOptInService API and fix bookings page

- Add resolveFeatureStates method that takes only userId and featureIds
- Update listFeaturesForUser to take only userId parameter
- Remove getUserOrgAndTeamIds from _router.ts (now internal to service)
- Fix bookings page to use new resolveFeatureStates method
- Parallelize getUserFeaturesStatus and resolveFeatureStates calls

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* check session?.user?.id first

* remove comment

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Eunjae Lee
2026-01-28 14:28:36 +00:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 987e727464
commit ddceabe5d4
4 changed files with 57 additions and 70 deletions
@@ -1,18 +1,15 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getFeatureOptInService } from "@calcom/features/di/containers/FeatureOptInService";
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validStatuses } from "~/bookings/lib/validStatuses";
import BookingsList from "~/bookings/views/bookings-view";
@@ -37,29 +34,36 @@ const Page = async ({ params }: PageProps) => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
let canReadOthersBookings = false;
if (session?.user?.id) {
const permissionService = new PermissionCheckService();
const userId = session.user.id;
const teamIdsWithPermission = await permissionService.getTeamIdsWithPermission({
userId,
permission: "booking.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
// We check if teamIdsWithPermission.length > 0.
// While this may not be entirely accurate, it's acceptable
// because we perform a thorough validation on the server side for the actual filter values.
// This variable is primarily for UI purposes.
canReadOthersBookings = teamIdsWithPermission.length > 0;
if (!session?.user?.id) {
return redirect("/auth/login");
}
const featuresRepository = new FeaturesRepository(prisma);
const featureFlags = session?.user?.id
? await featuresRepository.getUserFeaturesStatus(session.user.id, ["bookings-v3", "booking-audit"])
: { "bookings-v3": false, "booking-audit": false };
const userId = session.user.id;
const permissionService = new PermissionCheckService();
const bookingsV3Enabled = featureFlags["bookings-v3"] ?? false;
const teamIdsWithPermission = await permissionService.getTeamIdsWithPermission({
userId,
permission: "booking.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
// We check if teamIdsWithPermission.length > 0.
// While this may not be entirely accurate, it's acceptable
// because we perform a thorough validation on the server side for the actual filter values.
// This variable is primarily for UI purposes.
const canReadOthersBookings = teamIdsWithPermission.length > 0;
const featuresRepository = getFeaturesRepository();
const featureOptInService = getFeatureOptInService();
const [featureFlags, featureStates] = await Promise.all([
featuresRepository.getUserFeaturesStatus(userId, ["booking-audit"]),
featureOptInService.resolveFeatureStates({
userId,
featureIds: ["bookings-v3"],
}),
]);
const bookingsV3Enabled = featureStates["bookings-v3"]?.effectiveEnabled ?? false;
const bookingAuditEnabled = featureFlags["booking-audit"] ?? false;
return (
@@ -69,7 +73,7 @@ const Page = async ({ params }: PageProps) => {
headerClassName="bookings-shell-heading">
<BookingsList
status={parsed.data.status}
userId={session?.user?.id}
userId={userId}
permissions={{ canReadOthersBookings }}
bookingsV3Enabled={bookingsV3Enabled}
bookingAuditEnabled={bookingAuditEnabled}
@@ -227,23 +227,32 @@ export class FeatureOptInService implements IFeatureOptInService {
};
}
async resolveFeatureStates(input: {
userId: number;
featureIds: FeatureId[];
}): Promise<Record<string, ResolvedFeatureState>> {
const { userId, featureIds } = input;
const { orgId, teamIds } = await this.getUserOrgAndTeamIds(userId);
return this.resolveFeatureStatesAcrossTeams({
userId,
orgId,
teamIds,
featureIds,
});
}
/**
* List all opt-in features with their states for a user across teams.
* Only returns features that are in the allowlist, globally enabled, and scoped to "user".
*/
async listFeaturesForUser(input: {
userId: number;
orgId: number | null;
teamIds: number[];
}): Promise<ListFeaturesForUserResult[]> {
const { userId, orgId, teamIds } = input;
async listFeaturesForUser(input: { userId: number }): Promise<ListFeaturesForUserResult[]> {
const { userId } = input;
const userScopedFeatures = getOptInFeaturesForScope("user");
const featureIds = userScopedFeatures.map((config) => config.slug);
const resolvedStates = await this.resolveFeatureStatesAcrossTeams({
const resolvedStates = await this.resolveFeatureStates({
userId,
orgId,
teamIds,
featureIds,
});
@@ -64,11 +64,11 @@ export interface IFeatureOptInService {
teamIds: number[];
featureIds: FeatureId[];
}): Promise<Record<string, ResolvedFeatureState>>;
listFeaturesForUser(input: {
resolveFeatureStates(input: {
userId: number;
orgId: number | null;
teamIds: number[];
}): Promise<ResolvedFeatureState[]>;
featureIds: FeatureId[];
}): Promise<Record<string, ResolvedFeatureState>>;
listFeaturesForUser(input: { userId: number }): Promise<ResolvedFeatureState[]>;
listFeaturesForTeam(input: {
teamId: number;
parentOrgId?: number | null;
@@ -3,7 +3,6 @@ import { getTeamFeatureRepository } from "@calcom/features/di/containers/TeamFea
import { getUserFeatureRepository } from "@calcom/features/di/containers/UserFeatureRepository";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { isOptInFeature } from "@calcom/features/feature-opt-in/config";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { ZodEnum } from "zod";
@@ -22,27 +21,6 @@ const featureOptInService: ReturnType<typeof getFeatureOptInService> = getFeatur
const teamFeatureRepository: ReturnType<typeof getTeamFeatureRepository> = getTeamFeatureRepository();
const userFeatureRepository: ReturnType<typeof getUserFeatureRepository> = getUserFeatureRepository();
const teamRepository: TeamRepository = new TeamRepository(prisma);
const membershipRepository: MembershipRepository = new MembershipRepository(prisma);
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({
/**
@@ -50,12 +28,8 @@ export const featureOptInRouter = router({
* 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,
});
}),