refactor: SCIM - handleGroupEvents (#20537)

* Add logging to `handleGroupEvents`

* `createUsersAndConnectOrg` skipDuplicates when creating new users

* `ProfileRepository.createMany` to get username from `orgAutoAcceptEmail`

* Refactor `createUsersAndConnectToOrg` to use `createMany` instead of individual db calls

* Add error logging to `sendSignupToOrganizationEmail`

* Move `sendSignupToOrganizationEmail` to a `Promise.allSettled`

* Add `createManyForExistingUsers` and `createManyPromise`

* Move creating existing users profiles to `ProfileRepository.createManyForExistingUsers`

* Move `sendExistingUserTeamInviteEmails` to a `Promise.allSettled`

* Refactor `createUsersAndConnectToOrg` to use `ProfileRepository.createManyPromise`

* Type fix

* Create `generateNewChildEventTypeDataForDB`

* Create `addNewMembersToEventTypes`

* Type fix

* Type fix

* Type fix

* `createMany` new workflowsOnEventTypes

* Introduce `DATABASE_CHUNK_SIZE`

* Batch connect children event types

* Batch connect profiles to existing users
This commit is contained in:
Joe Au-Yeung
2025-04-04 17:36:26 -04:00
committed by GitHub
parent 31ccdba203
commit 5bfb6fc148
9 changed files with 405 additions and 107 deletions
+2
View File
@@ -441,3 +441,5 @@ NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
NEXT_PUBLIC_HEAD_SCRIPTS=
NEXT_PUBLIC_BODY_SCRIPTS=
DATABASE_CHUNK_SIZE=
@@ -1,10 +1,10 @@
import type { DirectorySyncEvent, Group } from "@boxyhq/saml-jackson";
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
import { addNewMembersToEventTypes } from "@calcom/lib/server/queries";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import prisma from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import {
@@ -15,18 +15,28 @@ import {
import createUsersAndConnectToOrg from "./users/createUsersAndConnectToOrg";
const log = logger.getSubLogger({ prefix: ["dsync/handleGroupEvents"] });
const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: number) => {
log.debug("called", safeStringify(event));
const log = logger.getSubLogger({
prefix: [`dsync/handleGroupEvents: directoryId: ${event.directory_id}`],
});
log.info(
"called",
safeStringify({
event: event.event,
tenant: event.tenant,
product: event.product,
})
);
// Find the group name associated with the event
const eventData = event.data as Group;
// If the group doesn't have any members assigned then return early
if (!eventData.raw.members.length) {
return;
}
log.info(`Event contains ${eventData.raw.members.length} members`);
const groupNames = await prisma.dSyncTeamGroupMapping.findMany({
where: {
directoryId: event.directory_id,
@@ -67,6 +77,8 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
userEmails = eventData.raw.members.map((member: { display: string }) => member.display);
}
log.info(`Event contains ${userEmails.length} emails`);
// Find existing users
const users = await prisma.user.findMany({
where: {
@@ -97,6 +109,9 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
const newUserEmails = userEmails.filter((email) => !users.find((user) => user.email === email));
let newUsers;
log.info(`Event processing ${newUserEmails.length} new users and ${users.length} existing users`);
// For each team linked to the dsync group name provision members
for (const group of groupNames) {
if (newUserEmails.length) {
@@ -117,7 +132,7 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
accepted: true,
})),
});
await Promise.all(
await Promise.allSettled(
newUserEmails.map((email) => {
return sendSignupToOrganizationEmail({
usernameOrEmail: email,
@@ -126,6 +141,16 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
inviterName: org.name,
teamId: group.teamId,
isOrg: false,
}).catch((error) => {
log.error(
"Failed to send signup to organization email",
safeStringify({
email,
organizationId,
teamId: group.teamId,
}),
error
);
});
})
);
@@ -161,7 +186,7 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
const newOrgMembers = users.filter(
(user) => !user.profiles.find((profile) => profile.organizationId === organizationId)
);
await Promise.all([
await Promise.allSettled([
...newMembers.map(async (user) => {
const translation = await getTranslation(user.locale || "en", "common");
return sendExistingUserTeamInviteEmails({
@@ -178,28 +203,31 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
isAutoJoin: true,
currentUserParentTeamName: org.name,
orgSlug: null,
});
}),
...newOrgMembers.map((user) => {
return createAProfileForAnExistingUser({
user: {
id: user.id,
email: user.email,
currentUsername: user.username,
},
organizationId,
}).catch((error) => {
log.error(
"Failed to send team invite to existing user",
safeStringify({
email: user.email,
organizationId,
teamId: group.teamId,
}),
error
);
});
}),
]);
await ProfileRepository.createManyForExistingUsers({
users: newOrgMembers,
organizationId,
orgAutoAcceptEmail: org.organizationSettings?.orgAutoAcceptEmail ?? "",
});
// Add users to team event types if assignAllTeamMembers is enabled
await Promise.all([
...(newUsers?.map((newUser) => {
return updateNewTeamMemberEventTypes(newUser.id, group.teamId);
}) ?? []),
...users.map((user) => {
return updateNewTeamMemberEventTypes(user.id, group.teamId);
}),
]);
await addNewMembersToEventTypes({
userIds: [...(newUsers ? newUsers.map((user) => user.id) : []), ...users.map((user) => user.id)],
teamId: group.teamId,
});
}
};
@@ -1,7 +1,8 @@
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import prisma from "@calcom/prisma";
import type { IdentityProvider } from "@calcom/prisma/enums";
import { CreationSource } from "@calcom/prisma/enums";
import { CreationSource, MembershipRole } from "@calcom/prisma/enums";
import {
deriveNameFromOrgUsername,
@@ -48,6 +49,7 @@ export const createUsersAndConnectToOrg = async ({
creationSource: CreationSource.WEBAPP,
};
}),
skipDuplicates: true,
});
const users = await prisma.user.findMany({
@@ -58,17 +60,24 @@ export const createUsersAndConnectToOrg = async ({
},
select: dSyncUserSelect,
});
// Assign created users to organization
for (const user of users) {
await createOrUpdateMemberships({
user,
team: {
id: org.id,
isOrganization: true,
parentId: null, // orgs don't have a parentId
},
});
}
// Create profiles for new users
await ProfileRepository.createManyPromise({
users,
organizationId: org.id,
orgAutoAcceptEmail: org.organizationSettings?.orgAutoAcceptEmail ?? "",
});
// Create memberships for new members
await MembershipRepository.createMany(
users.map((user) => ({
userId: user.id,
teamId: org.id,
role: MembershipRole.MEMBER,
accepted: true,
}))
);
return users;
};
+2
View File
@@ -217,3 +217,5 @@ export const IS_DUB_REFERRALS_ENABLED =
!!process.env.NEXT_PUBLIC_DUB_PROGRAM_ID && process.env.NEXT_PUBLIC_DUB_PROGRAM_ID !== "";
export const CAL_VIDEO_MEETING_LINK_FOR_TESTING = process.env.CAL_VIDEO_MEETING_LINK_FOR_TESTING;
export const DATABASE_CHUNK_SIZE = parseInt(process.env.DATABASE_CHUNK_SIZE || "25", 10);
+211 -40
View File
@@ -1,7 +1,10 @@
import { Prisma } from "@prisma/client";
import { getAppFromSlug } from "@calcom/app-store/utils";
import { DATABASE_CHUNK_SIZE } from "@calcom/lib/constants";
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma, { baseEventTypeSelect } from "@calcom/prisma";
import type { Team } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
@@ -422,8 +425,64 @@ export async function isTeamMember(userId: number, teamId: number) {
}));
}
export async function updateNewTeamMemberEventTypes(userId: number, teamId: number) {
const eventTypesToAdd = await prisma.eventType.findMany({
export function generateNewChildEventTypeDataForDB({
eventType,
userId,
includeWorkflow = true,
includeUserConnect = true,
}: {
eventType: Prisma.EventTypeGetPayload<{ select: typeof allManagedEventTypeProps & { id: true } }>;
userId: number;
includeWorkflow?: boolean;
includeUserConnect?: boolean;
}) {
const allManagedEventTypePropsZod = _EventTypeModel.pick(allManagedEventTypeProps).extend({
bookingFields: _EventTypeModel.shape.bookingFields.nullish(),
});
const managedEventTypeValues = allManagedEventTypePropsZod
.omit(unlockedManagedEventTypeProps)
.parse(eventType);
// Define the values for unlocked properties to use on creation, not updation
const unlockedEventTypeValues = allManagedEventTypePropsZod
.pick(unlockedManagedEventTypeProps)
.parse(eventType);
// Calculate if there are new workflows for which assigned members will get too
const currentWorkflowIds = Array.isArray(eventType.workflows)
? eventType.workflows.map((wf) => wf.workflowId)
: [];
return {
...managedEventTypeValues,
...unlockedEventTypeValues,
bookingLimits: (managedEventTypeValues.bookingLimits as unknown as Prisma.InputJsonObject) ?? undefined,
recurringEvent: (managedEventTypeValues.recurringEvent as unknown as Prisma.InputJsonValue) ?? undefined,
metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined,
bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined,
durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined,
eventTypeColor: (managedEventTypeValues.eventTypeColor as Prisma.InputJsonValue) ?? undefined,
rrSegmentQueryValue: (managedEventTypeValues.rrSegmentQueryValue as Prisma.InputJsonValue) ?? undefined,
onlyShowFirstAvailableSlot: managedEventTypeValues.onlyShowFirstAvailableSlot ?? false,
userId,
...(includeUserConnect && {
users: {
connect: [{ id: userId }],
},
}),
parentId: eventType.id,
hidden: false,
...(includeWorkflow && {
workflows: currentWorkflowIds && {
create: currentWorkflowIds.map((wfId) => ({ workflowId: wfId })),
},
}),
};
}
async function getEventTypesToAddNewMembers(teamId: number) {
return await prisma.eventType.findMany({
where: {
team: { id: teamId },
assignAllTeamMembers: true,
@@ -434,52 +493,20 @@ export async function updateNewTeamMemberEventTypes(userId: number, teamId: numb
schedulingType: true,
},
});
}
const allManagedEventTypePropsZod = _EventTypeModel.pick(allManagedEventTypeProps).extend({
bookingFields: _EventTypeModel.shape.bookingFields.nullish(),
});
export async function updateNewTeamMemberEventTypes(userId: number, teamId: number) {
const eventTypesToAdd = await getEventTypesToAddNewMembers(teamId);
eventTypesToAdd.length > 0 &&
(await prisma.$transaction(
eventTypesToAdd.map((eventType) => {
if (eventType.schedulingType === "MANAGED") {
const managedEventTypeValues = allManagedEventTypePropsZod
.omit(unlockedManagedEventTypeProps)
.parse(eventType);
// Define the values for unlocked properties to use on creation, not updation
const unlockedEventTypeValues = allManagedEventTypePropsZod
.pick(unlockedManagedEventTypeProps)
.parse(eventType);
// Calculate if there are new workflows for which assigned members will get too
const currentWorkflowIds = eventType.workflows?.map((wf) => wf.workflowId);
return prisma.eventType.create({
data: {
...managedEventTypeValues,
...unlockedEventTypeValues,
bookingLimits:
(managedEventTypeValues.bookingLimits as unknown as Prisma.InputJsonObject) ?? undefined,
recurringEvent:
(managedEventTypeValues.recurringEvent as unknown as Prisma.InputJsonValue) ?? undefined,
metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined,
bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined,
durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined,
eventTypeColor: (managedEventTypeValues.eventTypeColor as Prisma.InputJsonValue) ?? undefined,
rrSegmentQueryValue:
(managedEventTypeValues.rrSegmentQueryValue as Prisma.InputJsonValue) ?? undefined,
onlyShowFirstAvailableSlot: managedEventTypeValues.onlyShowFirstAvailableSlot ?? false,
data: generateNewChildEventTypeDataForDB({
eventType,
userId,
users: {
connect: [{ id: userId }],
},
parentId: eventType.id,
hidden: false,
workflows: currentWorkflowIds && {
create: currentWorkflowIds.map((wfId) => ({ workflowId: wfId })),
},
},
}),
});
} else {
return prisma.eventType.update({
@@ -490,3 +517,147 @@ export async function updateNewTeamMemberEventTypes(userId: number, teamId: numb
})
));
}
export async function addNewMembersToEventTypes({ userIds, teamId }: { userIds: number[]; teamId: number }) {
const log = logger.getSubLogger({
prefix: ["addNewMembersToEventTypes"],
});
const eventTypesToAdd = await getEventTypesToAddNewMembers(teamId);
const managedEventTypes = eventTypesToAdd.filter((eventType) => eventType.schedulingType === "MANAGED");
const teamEventTypes = eventTypesToAdd.filter((eventType) => eventType.schedulingType !== "MANAGED");
await Promise.allSettled([
prisma.eventType
.createMany({
data: managedEventTypes
.map((eventType) =>
userIds.map((userId) =>
generateNewChildEventTypeDataForDB({
eventType,
userId,
includeWorkflow: false,
includeUserConnect: false,
})
)
)
.flat(),
skipDuplicates: true,
})
.catch((error) => {
log.error(
`Failed to add new members to managed event types`,
safeStringify({
teamId,
error,
})
);
}),
prisma.host
.createMany({
data: teamEventTypes
.map((eventType) => {
return userIds.map((userId) => {
return {
userId,
eventTypeId: eventType.id,
isFixed: eventType.schedulingType === "COLLECTIVE",
};
});
})
.flat(),
skipDuplicates: true,
})
.catch((error) => {
log.error(
`Failed to add new members as hosts`,
safeStringify({
teamId,
error,
})
);
}),
]);
// Connect to users and workflows
const createdChildrenEventTypes = await prisma.eventType.findMany({
where: {
userId: {
in: userIds,
},
parent: {
id: {
in: managedEventTypes.map((eventType) => eventType.id),
},
},
},
select: {
id: true,
userId: true,
workflows: {
select: {
id: true,
},
},
},
});
if (createdChildrenEventTypes.length > 0) {
await Promise.allSettled([
prisma.workflowsOnEventTypes
.createMany({
data: createdChildrenEventTypes
.map((eventType) =>
eventType.workflows.map((workflow) => ({
eventTypeId: eventType.id,
workflowId: workflow.id,
}))
)
.flat(),
skipDuplicates: true,
})
.catch((error) => {
log.error(
`Failed to connect new children event types to workflows`,
safeStringify({
teamId,
error,
})
);
}),
]);
// Connect children event types to users
for (let i = 0; i < createdChildrenEventTypes.length; i += DATABASE_CHUNK_SIZE) {
const childrenEventTypeBatch = createdChildrenEventTypes.slice(i, i + DATABASE_CHUNK_SIZE);
await Promise.allSettled([
childrenEventTypeBatch.map((childEventType) => {
if (!childEventType.userId) return;
return prisma.eventType
.update({
where: {
id: childEventType.id,
},
data: {
users: {
connect: [{ id: childEventType.userId }],
},
},
})
.catch((error) => {
log.error(
`Failed to connect new children event types to users`,
safeStringify({
teamId,
childEventTypeId: childEventType.id,
userId: childEventType.userId,
error,
})
);
});
}),
]);
}
}
}
+75 -2
View File
@@ -2,6 +2,8 @@ import type { User as PrismaUser } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains";
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { DATABASE_CHUNK_SIZE } from "@calcom/lib/constants";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
@@ -249,20 +251,91 @@ export class ProfileRepository {
});
}
static async createManyForExistingUsers({
users,
organizationId,
orgAutoAcceptEmail,
}: {
users: { id: number; username: string | null; email: string }[];
organizationId: number;
orgAutoAcceptEmail: string;
}) {
await prisma.profile.createMany({
data: users.map((user) => ({
uid: ProfileRepository.generateProfileUid(),
userId: user.id,
organizationId,
username: user?.username || getOrgUsernameFromEmail(user.email, orgAutoAcceptEmail),
})),
skipDuplicates: true,
});
// Populate the movedFromUser
const createdProfiles = await prisma.profile.findMany({
where: {
userId: {
in: users.map((user) => user.id),
},
organizationId,
},
});
for (let i = 0; i < createdProfiles.length; i += DATABASE_CHUNK_SIZE) {
const profilesBatch = createdProfiles.slice(i, i + DATABASE_CHUNK_SIZE);
await Promise.allSettled([
profilesBatch.map((profile) => {
prisma.user.update({
where: {
id: profile.userId,
},
data: {
movedToProfile: {
connect: { id: profile.id },
},
},
});
}),
]);
}
}
static async createManyPromise({
users,
organizationId,
orgAutoAcceptEmail,
}: {
users: { id: number; username: string | null; email: string }[];
organizationId: number;
orgAutoAcceptEmail: string;
}) {
return await prisma.profile.createMany({
data: users.map((user) => ({
uid: ProfileRepository.generateProfileUid(),
userId: user.id,
organizationId,
username: user?.username || getOrgUsernameFromEmail(user.email, orgAutoAcceptEmail),
})),
skipDuplicates: true,
});
}
static createMany({
users,
organizationId,
orgAutoAcceptEmail,
}: {
users: { id: number; username: string; email: string }[];
users: { id: number; username: string | null; email: string }[];
organizationId: number;
orgAutoAcceptEmail: string;
}) {
return prisma.profile.createMany({
data: users.map((user) => ({
uid: ProfileRepository.generateProfileUid(),
userId: user.id,
organizationId,
username: user.username || user.email.split("@")[0],
username: user?.username || getOrgUsernameFromEmail(user.email, orgAutoAcceptEmail),
})),
skipDuplicates: true,
});
}
@@ -117,6 +117,7 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => {
};
}),
organizationId: input.orgId,
orgAutoAcceptEmail: acceptedEmailDomain,
}),
]);
@@ -457,35 +457,46 @@ export async function sendSignupToOrganizationEmail({
teamId: number;
isOrg: boolean;
}) {
const token: string = randomBytes(32).toString("hex");
try {
const token: string = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
identifier: usernameOrEmail,
token,
expires: new Date(new Date().setHours(168)), // +1 week
team: {
connect: {
id: teamId,
await prisma.verificationToken.create({
data: {
identifier: usernameOrEmail,
token,
expires: new Date(new Date().setHours(168)), // +1 week
team: {
connect: {
id: teamId,
},
},
},
},
});
await sendTeamInviteEmail({
language: translation,
from: inviterName || `${team.name}'s admin`,
to: usernameOrEmail,
teamName: team.name,
joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`,
isCalcomMember: false,
isOrg: isOrg,
parentTeamName: team?.parent?.name,
isAutoJoin: false,
isExistingUserMovedToOrg: false,
// For a new user there is no prev and new links.
prevLink: null,
newLink: null,
});
});
await sendTeamInviteEmail({
language: translation,
from: inviterName || `${team.name}'s admin`,
to: usernameOrEmail,
teamName: team.name,
joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`,
isCalcomMember: false,
isOrg: isOrg,
parentTeamName: team?.parent?.name,
isAutoJoin: false,
isExistingUserMovedToOrg: false,
// For a new user there is no prev and new links.
prevLink: null,
newLink: null,
});
} catch (error) {
logger.error(
"Failed to send signup to organization email",
safeStringify({
usernameOrEmail,
orgId: teamId,
}),
error
);
}
}
type TeamAndOrganizationSettings = Team & {
+1
View File
@@ -275,6 +275,7 @@
"DAILY_SCALE_PLAN",
"DAILY_WEBHOOK_SECRET",
"DAILY_MEETING_ENDED_WEBHOOK_SECRET",
"DATABASE_CHUNK_SIZE",
"DATABASE_DIRECT_URL",
"DATABASE_URL",
"DEBUG",