From 3e7e37610064aee3f83093731c64c4b3d09a2254 Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Mon, 12 Jan 2026 08:32:55 +0400 Subject: [PATCH] chore: Some minor fixes and follow up to #26446 (#26587) * init * DI and other improvements * type fix --------- Co-authored-by: Volnei Munhoz --- .../src/lib/modules/available-slots.module.ts | 2 + .../lib/services/available-slots.service.ts | 5 +- .../services/org-membership-lookup.service.ts | 14 ++++ .../features/di/containers/AvailableSlots.ts | 6 +- .../features/di/modules/AvailableSlots.ts | 1 + .../di/modules/OrgMembershipLookup.ts | 19 ++++++ packages/features/di/tokens.ts | 2 + .../ee/teams/repositories/TeamRepository.ts | 13 ++++ .../repositories/eventTypeRepository.ts | 5 +- .../operations/check-user-blocking.ts | 10 +-- .../trpc/server/routers/viewer/slots/util.ts | 64 ++++++++++++++++++- 11 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 apps/api/v2/src/lib/services/org-membership-lookup.service.ts create mode 100644 packages/features/di/modules/OrgMembershipLookup.ts diff --git a/apps/api/v2/src/lib/modules/available-slots.module.ts b/apps/api/v2/src/lib/modules/available-slots.module.ts index 49428b7e3c..5fbfc6413b 100644 --- a/apps/api/v2/src/lib/modules/available-slots.module.ts +++ b/apps/api/v2/src/lib/modules/available-slots.module.ts @@ -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], }) diff --git a/apps/api/v2/src/lib/services/available-slots.service.ts b/apps/api/v2/src/lib/services/available-slots.service.ts index dba0270eb9..f34a407c2b 100644 --- a/apps/api/v2/src/lib/services/available-slots.service.ts +++ b/apps/api/v2/src/lib/services/available-slots.service.ts @@ -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, }); } } diff --git a/apps/api/v2/src/lib/services/org-membership-lookup.service.ts b/apps/api/v2/src/lib/services/org-membership-lookup.service.ts new file mode 100644 index 0000000000..519ffde228 --- /dev/null +++ b/apps/api/v2/src/lib/services/org-membership-lookup.service.ts @@ -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 { + return ProfileRepository.findFirstOrganizationIdForUser({ userId }); + } +} diff --git a/packages/features/di/containers/AvailableSlots.ts b/packages/features/di/containers/AvailableSlots.ts index fd3941898f..d384567cdc 100644 --- a/packages/features/di/containers/AvailableSlots.ts +++ b/packages/features/di/containers/AvailableSlots.ts @@ -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(DI_TOKENS.AVAILABLE_SLOTS_SERVICE); diff --git a/packages/features/di/modules/AvailableSlots.ts b/packages/features/di/modules/AvailableSlots.ts index 0d5da5e018..567d3a34d1 100644 --- a/packages/features/di/modules/AvailableSlots.ts +++ b/packages/features/di/modules/AvailableSlots.ts @@ -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); diff --git a/packages/features/di/modules/OrgMembershipLookup.ts b/packages/features/di/modules/OrgMembershipLookup.ts new file mode 100644 index 0000000000..2396c110df --- /dev/null +++ b/packages/features/di/modules/OrgMembershipLookup.ts @@ -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 }; diff --git a/packages/features/di/tokens.ts b/packages/features/di/tokens.ts index f7b7c2343e..0e41688270 100644 --- a/packages/features/di/tokens.ts +++ b/packages/features/di/tokens.ts @@ -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, diff --git a/packages/features/ee/teams/repositories/TeamRepository.ts b/packages/features/ee/teams/repositories/TeamRepository.ts index 6218cdde01..928db06d7e 100644 --- a/packages/features/ee/teams/repositories/TeamRepository.ts +++ b/packages/features/ee/teams/repositories/TeamRepository.ts @@ -468,6 +468,19 @@ export class TeamRepository { }); } + async findOrganizationIdBySlug({ slug }: { slug: string }): Promise { + const org = await this.prismaClient.team.findFirst({ + where: { + slug, + isOrganization: true, + }, + select: { + id: true, + }, + }); + return org?.id ?? null; + } + async isSlugAvailableForUpdate({ slug, teamId, diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index a4b5413fcd..7aa4bbcde5 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -49,7 +49,7 @@ type UserWithSelectedCalendars = THost & { user: UserWithLegacySelectedCalendars; }; @@ -73,7 +73,7 @@ function hostsWithSelectedCalendars(users: UserWithLegacySelectedCalendars[]) { return users.map((user) => withSelectedCalendars(user)); } @@ -1271,6 +1271,7 @@ export class EventTypeRepository { }, select: { id: true, + userId: true, slug: true, minimumBookingNotice: true, length: true, diff --git a/packages/features/watchlist/operations/check-user-blocking.ts b/packages/features/watchlist/operations/check-user-blocking.ts index d65db02dbb..7057221bd1 100644 --- a/packages/features/watchlist/operations/check-user-blocking.ts +++ b/packages/features/watchlist/operations/check-user-blocking.ts @@ -87,8 +87,10 @@ export async function getBlockedUsersMap( users: T[], organizationId?: number | null ): Promise { - // 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( 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" }); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index a54bed2a6d..b66a1bffa5 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -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; +} + 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 { + 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,