* init * DI and other improvements * type fix --------- Co-authored-by: Volnei Munhoz <volnei@cal.com>
This commit is contained in:
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 };
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user