chore: Some minor fixes and follow up to #26446 (#26587)

* init

* DI and other improvements

* type fix

---------

Co-authored-by: Volnei Munhoz <volnei@cal.com>
This commit is contained in:
Syed Ali ShahbazandVolnei Munhoz authored and GitHub committed 2026-01-12 04:32:55 +00:00
1 parent 93cac5a1c9
commit 3e7e376100
11 files changed
+131 -10

No files matched your search

@@ -14,6 +14,7 @@ import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
import { OrgMembershipLookupService } from "@/lib/services/org-membership-lookup.service";
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
import { PrismaWorkerModule } from "@/modules/prisma/prisma-worker.module";
@@ -42,6 +43,7 @@ import { Module } from "@nestjs/common";
FilterHostsService,
QualifiedHostsService,
NoSlotsNotificationService,
OrgMembershipLookupService,
],
exports: [AvailableSlotsService],
})
@@ -10,6 +10,7 @@ import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"
import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
import { OrgMembershipLookupService } from "@/lib/services/org-membership-lookup.service";
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
@@ -35,7 +36,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
checkBookingLimitsService: CheckBookingLimitsService,
userAvailabilityService: UserAvailabilityService,
busyTimesService: BusyTimesService,
noSlotsNotificationService: NoSlotsNotificationService
noSlotsNotificationService: NoSlotsNotificationService,
orgMembershipLookupService: OrgMembershipLookupService
) {
super({
oooRepo: oooRepoDependency,
@@ -53,6 +55,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
qualifiedHostsService,
featuresRepo: featuresRepository,
noSlotsNotificationService,
orgMembershipLookup: orgMembershipLookupService,
});
}
}
@@ -0,0 +1,14 @@
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import type { OrgMembershipLookup } from "@calcom/trpc/server/routers/viewer/slots/util";
import { Injectable } from "@nestjs/common";
/**
* NestJS service that implements OrgMembershipLookup interface.
* Wraps ProfileRepository's static method for DI compatibility.
*/
@Injectable()
export class OrgMembershipLookupService implements OrgMembershipLookup {
async findFirstOrganizationIdForUser({ userId }: { userId: number }): Promise<number | null> {
return ProfileRepository.findFirstOrganizationIdForUser({ userId });
}
}
@@ -3,7 +3,7 @@ import { redisModule } from "@calcom/features/redis/di/redisModule";
import { prismaModule } from "@calcom/features/di/modules/Prisma";
import type { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util";
import { membershipRepositoryModule } from "@calcom/features/users/di/MembershipRepository.module";
import { createContainer } from "../di";
import { createContainer, type Container } from "../di";
import { availableSlotsModule } from "../modules/AvailableSlots";
import { bookingRepositoryModule } from "../modules/Booking";
import { busyTimesModule } from "../modules/BusyTimes";
@@ -15,6 +15,7 @@ import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
import { holidayRepositoryModule } from "../modules/Holiday";
import { noSlotsNotificationModule } from "../modules/NoSlotsNotification";
import { oooRepositoryModule } from "../modules/Ooo";
import { orgMembershipLookupModule } from "../modules/OrgMembershipLookup";
import { qualifiedHostsModule } from "../modules/QualifiedHosts";
import { routingFormResponseRepositoryModule } from "../modules/RoutingFormResponse";
import { scheduleRepositoryModule } from "../modules/Schedule";
@@ -22,7 +23,7 @@ import { selectedSlotsRepositoryModule } from "../modules/SelectedSlots";
import { teamRepositoryModule } from "../modules/Team";
import { userRepositoryModule } from "../modules/User";
const container = createContainer();
const container: Container = createContainer();
container.load(DI_TOKENS.REDIS_CLIENT, redisModule);
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule);
@@ -44,6 +45,7 @@ container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule);
container.load(DI_TOKENS.QUALIFIED_HOSTS_SERVICE_MODULE, qualifiedHostsModule);
container.load(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE_MODULE, noSlotsNotificationModule);
container.load(DI_TOKENS.ORG_MEMBERSHIP_LOOKUP_MODULE, orgMembershipLookupModule);
export function getAvailableSlotsService() {
return container.get<AvailableSlotsService>(DI_TOKENS.AVAILABLE_SLOTS_SERVICE);
@@ -21,4 +21,5 @@ availableSlotsModule.bind(DI_TOKENS.AVAILABLE_SLOTS_SERVICE).toClass(AvailableSl
featuresRepo: DI_TOKENS.FEATURES_REPOSITORY,
qualifiedHostsService: DI_TOKENS.QUALIFIED_HOSTS_SERVICE,
noSlotsNotificationService: DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE,
orgMembershipLookup: DI_TOKENS.ORG_MEMBERSHIP_LOOKUP,
} satisfies Record<keyof IAvailableSlotsService, symbol>);
@@ -0,0 +1,19 @@
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import type { OrgMembershipLookup } from "@calcom/trpc/server/routers/viewer/slots/util";
import { createModule, type Module } from "../di";
import { DI_TOKENS } from "../tokens";
/**
* Adapter that wraps ProfileRepository's static method to satisfy the OrgMembershipLookup interface.
* This lives in the composition root where implementation details are wired together.
*/
const orgMembershipLookupAdapter: OrgMembershipLookup = {
findFirstOrganizationIdForUser: ({ userId }: { userId: number }) =>
ProfileRepository.findFirstOrganizationIdForUser({ userId }),
};
const orgMembershipLookupModule: Module = createModule();
orgMembershipLookupModule.bind(DI_TOKENS.ORG_MEMBERSHIP_LOOKUP).toValue(orgMembershipLookupAdapter);
export { orgMembershipLookupModule };
+2
View File
@@ -68,6 +68,8 @@ export const DI_TOKENS = {
CREDENTIAL_REPOSITORY_MODULE: Symbol("CredentialRepositoryModule"),
MANAGED_EVENT_REASSIGNMENT_SERVICE: Symbol("ManagedEventReassignmentService"),
MANAGED_EVENT_REASSIGNMENT_SERVICE_MODULE: Symbol("ManagedEventReassignmentServiceModule"),
ORG_MEMBERSHIP_LOOKUP: Symbol("OrgMembershipLookup"),
ORG_MEMBERSHIP_LOOKUP_MODULE: Symbol("OrgMembershipLookupModule"),
// Booking service tokens
...BOOKING_AUDIT_DI_TOKENS,
...BOOKING_DI_TOKENS,
@@ -468,6 +468,19 @@ export class TeamRepository {
});
}
async findOrganizationIdBySlug({ slug }: { slug: string }): Promise<number | null> {
const org = await this.prismaClient.team.findFirst({
where: {
slug,
isOrganization: true,
},
select: {
id: true,
},
});
return org?.id ?? null;
}
async isSlugAvailableForUpdate({
slug,
teamId,
@@ -49,7 +49,7 @@ type UserWithSelectedCalendars<TSelectedCalendar extends { eventTypeId: number |
type HostWithLegacySelectedCalendars<
TSelectedCalendar extends { eventTypeId: number | null },
THost,
TUser
TUser,
> = THost & {
user: UserWithLegacySelectedCalendars<TSelectedCalendar, TUser>;
};
@@ -73,7 +73,7 @@ function hostsWithSelectedCalendars<TSelectedCalendar extends { eventTypeId: num
function usersWithSelectedCalendars<
TSelectedCalendar extends { eventTypeId: number | null },
TUser extends { selectedCalendars: TSelectedCalendar[] }
TUser extends { selectedCalendars: TSelectedCalendar[] },
>(users: UserWithLegacySelectedCalendars<TSelectedCalendar, TUser>[]) {
return users.map((user) => withSelectedCalendars(user));
}
@@ -1271,6 +1271,7 @@ export class EventTypeRepository {
},
select: {
id: true,
userId: true,
slug: true,
minimumBookingNotice: true,
length: true,
@@ -87,8 +87,10 @@ export async function getBlockedUsersMap<T extends BlockableUser>(
users: T[],
organizationId?: number | null
): Promise<CheckUserBlockingResult> {
// Filter out users with empty/invalid emails
const validUsers = users.filter((u) => u.email && u.email.trim().length > 0);
// Map to trim emails first, then filter out empty/invalid ones
const validUsers = users
.map((u) => ({ ...u, email: u.email?.trim() ?? "" }))
.filter((u) => u.email.length > 0);
if (validUsers.length === 0) {
return { blockingMap: new Map(), blockedCount: 0, lockedCount: 0, watchlistBlockedCount: 0 };
@@ -98,9 +100,9 @@ export async function getBlockedUsersMap<T extends BlockableUser>(
let lockedCount = 0;
// First pass: check locked users (immediate block, no DB needed)
const nonLockedUsers: T[] = [];
const nonLockedUsers: (typeof validUsers)[number][] = [];
for (const user of validUsers) {
const normalizedEmail = user.email.trim().toLowerCase();
const normalizedEmail = user.email.toLowerCase();
if (user.locked) {
blockingMap.set(normalizedEmail, { isBlocked: true, reason: "locked" });
@@ -90,6 +90,14 @@ export type GetAvailableSlotsResponse = Awaited<
ReturnType<(typeof AvailableSlotsService)["prototype"]["_getAvailableSlots"]>
>;
/**
* Minimal capability interface for looking up a user's organization membership.
* Used as a fallback when org context can't be determined from request or eventType.
*/
export interface OrgMembershipLookup {
findFirstOrganizationIdForUser(args: { userId: number }): Promise<number | null>;
}
export interface IAvailableSlotsService {
oooRepo: PrismaOOORepository;
scheduleRepo: ScheduleRepository;
@@ -106,6 +114,7 @@ export interface IAvailableSlotsService {
featuresRepo: FeaturesRepository;
qualifiedHostsService: QualifiedHostsService;
noSlotsNotificationService: NoSlotsNotificationService;
orgMembershipLookup: OrgMembershipLookup;
}
function withSlotsCache(
@@ -987,6 +996,55 @@ export class AvailableSlotsService {
return await this.dependencies.featuresRepo.checkIfTeamHasFeature(teamId, "restriction-schedule");
}
/**
* Resolves the organization ID for watchlist blocking with the following priority:
* 1. Request context (orgSlug) - most accurate for the current request scope
* 2. EventType team hierarchy - for team/managed events
* 3. User's org membership - fallback for personal events of org members
*
* This ensures org-scoped blocking works correctly for:
* - Team events under an org
* - Managed events (child events of org team event types)
* - Personal events of org members accessed via org domain
*/
private async resolveOrganizationIdForBlocking({
orgDetails,
eventType,
}: {
orgDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean };
eventType: {
parent?: { team?: { parentId: number | null } | null } | null;
team?: { parentId: number | null } | null;
userId: number | null;
};
}): Promise<number | null> {
if (orgDetails.isValidOrgDomain && orgDetails.currentOrgDomain) {
const orgId = await this.dependencies.teamRepo.findOrganizationIdBySlug({
slug: orgDetails.currentOrgDomain,
});
if (orgId) {
return orgId;
}
}
// EventType team hierarchy - for team/managed events
const eventTypeOrgId = eventType.parent?.team?.parentId ?? eventType.team?.parentId ?? null;
if (eventTypeOrgId) {
return eventTypeOrgId;
}
// User's org membership - fallback for personal events of org members
// This is a heuristic and uses the first org membership.
// TODO:When multi-org is supported, revisit.
if (eventType.userId) {
return await this.dependencies.orgMembershipLookup.findFirstOrganizationIdForUser({
userId: eventType.userId,
});
}
return null;
}
private async _getRegularOrDynamicEventType(
input: TGetScheduleInputSchema,
organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean }
@@ -1098,7 +1156,11 @@ export class AvailableSlotsService {
});
// Filter out blocked hosts BEFORE calculating availability (batched - single DB query)
const organizationId = eventType.parent?.team?.parentId ?? eventType.team?.parentId ?? null;
const organizationId = await this.resolveOrganizationIdForBlocking({
orgDetails,
eventType,
});
const { eligibleHosts: eligibleQualifiedRRHosts } = await filterBlockedHosts(
qualifiedRRHosts,