diff --git a/.env.example b/.env.example index 0fbdde4fad..b9b1377e85 100644 --- a/.env.example +++ b/.env.example @@ -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= \ No newline at end of file diff --git a/packages/features/ee/dsync/lib/handleGroupEvents.ts b/packages/features/ee/dsync/lib/handleGroupEvents.ts index 5051ed010c..d58074a53f 100644 --- a/packages/features/ee/dsync/lib/handleGroupEvents.ts +++ b/packages/features/ee/dsync/lib/handleGroupEvents.ts @@ -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, + }); } }; diff --git a/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts b/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts index aee695057f..6676a8fdb8 100644 --- a/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts +++ b/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts @@ -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; }; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 4ab69b6714..895d82ae36 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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); diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 1d34c0f108..949014b2aa 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -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, + }) + ); + }); + }), + ]); + } + } +} diff --git a/packages/lib/server/repository/profile.ts b/packages/lib/server/repository/profile.ts index cb384e9b92..1df9cae978 100644 --- a/packages/lib/server/repository/profile.ts +++ b/packages/lib/server/repository/profile.ts @@ -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, }); } diff --git a/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts b/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts index d5abac27f3..d6ed73327c 100644 --- a/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts @@ -117,6 +117,7 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => { }; }), organizationId: input.orgId, + orgAutoAcceptEmail: acceptedEmailDomain, }), ]); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index 60ff6609e7..8ead22afe7 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -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 & { diff --git a/turbo.json b/turbo.json index e1a358dada..fa0230ffeb 100644 --- a/turbo.json +++ b/turbo.json @@ -275,6 +275,7 @@ "DAILY_SCALE_PLAN", "DAILY_WEBHOOK_SECRET", "DAILY_MEETING_ENDED_WEBHOOK_SECRET", + "DATABASE_CHUNK_SIZE", "DATABASE_DIRECT_URL", "DATABASE_URL", "DEBUG",