Files
calendar/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts
T
be77ac133c feat: Domain-wide delegation for Google Calendar and Google Meet (#16622)
* Add endpoints for testing the flow

* Add MVP

* new route

* Fixes

* Fixes

* Remove enable toggle support from domainWideDelegation

* Fixes

* Revert "Remove enable toggle support from domainWideDelegation"

This reverts commit c29e729206cd1fa063f9c9ce0cf148ef1577d60b.

* Revert yarn.lock

* More fixes

* Fix new workspace platform add

* refactor: improvements

* refactor: bug fixes and improvements

* fix: type errors

* fix: conflicts

* chore: update test

* fix: logic

* chore: improvements

* fix: toglle

* fix: bugs

* fix: type err

* chore: check number type

* fix: after conflicts

* chore: fix type err

* fix: type errors and tests

* fix: tets

* test

* chore: remove unused

* fix: google meet url on booking page and secondary calendar

* fix: add property

* fix: type err

* fix: re assingment bug

* fix: use getAllCredentials

* chore: fix import

* fix: installed count

* fix: pass event type

* fix: import

* fix: [Stacked PR] Review fixes (#17958)

* Review fixes

* fix: destination calendar bug

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>

* refactor: use repository

* chore: remove duplicate

* fix: More review fixes for domain wide delegation (#17969)

* Reuse buildCredentialPayloadForCalendar

* fixes

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>

* fix ts error

* Fix getSchedule not using dwd credentials (#17995)

* fix: Remove direct DWD table access from google-calendar and remove delegatedToId column from Credential (#18015)

* Remove fn rename to reduce number of files changed

* chore: check feature globally

* test: add unit tests

* chore: move function

* test: add booking test

* wip

* Remove domain-wide-delegation team feature flag

* Make sure duplicate calendars are not shown due to DWD. Show DWD when there is conflict

* Fix tests and their ts errors

* Fix more tests

* Move findUsersForAvailabilityCheck to separate file as it has AppStore dependency causing problem with Routing Forms. Build crashes

* fix: Multiple calendar connections from Google not showing up in apps/installed

* DestinationCalendar must have either credentialId or domainWideDeelgationId. Also handle the case when DWD is disabled and there was a non-dwd credential that could be used

* Disable deletetion of DWD as it is destructive and prefer disabling instead

* Show DWD credential calendars at the top

* Fixed tests

* Calendar Cache DWD support

* Self-Review: Verify email required and add more selectedCalendar tests

* Self Review: shorten names

* Self ReviewL Dead code removal

* Revert "Calendar Cache DWD support"

This reverts commit 009f236470fa21eba5986117d4f6e4d4c5e38c34.

* Some misc fixes

* fixes

* Performance improvememt in slots loading and booking

* More cases for handling dwd credentials

* simplify the logic. Ensure that EventManager and the modules below it in the flow have CredentialForCalendarService available

* Fix tests

* Fix installed/conferencing not showing gogole meet

* Shorten name

* Fix ApI v2 tests

* Add some more tests

* add getSchedule tests

* Improve tests

* Make Google Meet default when DWD for google is enabled

* Enable feature flagging for DWD

* chore: bump libraries

* Encrypt serviceaccount private key

* Fix ts errors

* bump platform libraries

* org add dwd

* org add dwd

* bump platform libraries

* fix selected calendars

* fix remove selected dwd calendar

* remove oauthclient id aliad in authedCalendar

* remove oauthclient id aliad in authedCalendar

* refactor: OrganizationsDwdController

* chore: export toggleDwdEnabled from platform-libraries

* feat: v2 update (enable / disable) dwd

* refactor: SelectedCalendarsController check if user belongs to dwd org & for not dwd use previous logic

* wip: DestinationCalendar send domainWideDelegationCredentialId from frontend to api

* try fix set destination calendar api v2

* fixup! try fix set destination calendar api v2

* setting google meet as default location working for dwd

* bump platform libraries version

* allow office 365 workspace slug

* allow office 365 workspace slug

* chore: v2 create dwd MS and Google service keys input

* fix: CreateDwdInput serviceAccountKey

* fix: CredentialForCalendarService type

* fix: check workspace slug

* feat: update serviceAccessKey of DWD

* testing

* fixup! testing

* fix: getAuthUtl bug

* fix: unit tests

* fix: type err and unit test

* fix: e2e test

* fix: credentials bug and failing unit tests

* Update CalendarService.ts

* chore: refactor office365 calendar service and add testDomainWideDelegationSetup

* fix: office365Calendar use correct clientId/Secret for DWD

* fix: office365Calendar dwd no need refresh token

* test: add unit test for outlook dwd and setup

* feat: added dwd support for office365 calendar

* feat: added dwd support for office365 video

* test: finish test for outlook dwd

* fix: create dialog bug

* chore: remove console logs

* fix: refreshToken bug

* bump version libraries

* refactor: fetch dwd credentials only in findQualifiedHosts

* fixup! refactor: fetch dwd credentials only in findQualifiedHosts

* fix: type err

* fix: type err

* fix: getUserDisplayName

* fix: unit test

* chore: bump platform lib

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-03-02 16:52:57 -03:00

398 lines
12 KiB
TypeScript

import { Prisma } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import { keyBy } from "lodash";
import type { GetServerSidePropsContext, NextApiResponse } from "next";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import { getTranslation } from "@calcom/lib/server/i18n";
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import slugify from "@calcom/lib/slugify";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import { prisma } from "@calcom/prisma";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import { getDefaultScheduleId } from "../viewer/availability/util";
import { updateUserMetadataAllowedKeys, type TUpdateProfileInputSchema } from "./updateProfile.schema";
const log = logger.getSubLogger({ prefix: ["updateProfile"] });
type UpdateProfileOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
res?: NextApiResponse | GetServerSidePropsContext["res"];
};
input: TUpdateProfileInputSchema;
};
export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => {
const { user } = ctx;
const userMetadata = handleUserMetadata({ ctx, input });
const locale = input.locale || user.locale;
const emailVerification = await getFeatureFlag(prisma, "email-verification");
const { travelSchedules, ...rest } = input;
const secondaryEmails = input?.secondaryEmails || [];
delete input.secondaryEmails;
const data: Prisma.UserUpdateInput = {
...rest,
metadata: userMetadata,
secondaryEmails: undefined,
};
let isPremiumUsername = false;
const layoutError = validateBookerLayouts(input?.metadata?.defaultBookerLayouts || null);
if (layoutError) {
const t = await getTranslation(locale, "common");
throw new TRPCError({ code: "BAD_REQUEST", message: t(layoutError) });
}
if (input.username && !user.organizationId) {
const username = slugify(input.username);
// Only validate if we're changing usernames
if (username !== user.username) {
data.username = username;
const response = await checkUsername(username);
isPremiumUsername = response.premium;
if (!response.available) {
const t = await getTranslation(locale, "common");
throw new TRPCError({ code: "BAD_REQUEST", message: t("username_already_taken") });
}
}
} else if (input.username && user.organizationId && user.movedToProfileId) {
// don't change user.username if we have profile.username
delete data.username;
}
if (isPremiumUsername) {
const stripeCustomerId = userMetadata?.stripeCustomerId;
const isPremium = userMetadata?.isPremium;
if (!isPremium || !stripeCustomerId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "User is not premium" });
}
const stripeSubscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId });
if (!stripeSubscriptions || !stripeSubscriptions.data.length) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "No stripeSubscription found",
});
}
// Iterate over subscriptions and look for premium product id and status active
// @TODO: iterate if stripeSubscriptions.hasMore is true
const isPremiumUsernameSubscriptionActive = stripeSubscriptions.data.some(
(subscription) =>
subscription.items.data[0].price.id === getPremiumMonthlyPlanPriceId() &&
subscription.status === "active"
);
if (!isPremiumUsernameSubscriptionActive) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You need to pay for premium username",
});
}
}
const hasEmailBeenChanged = data.email && user.email !== data.email;
let secondaryEmail:
| {
id: number;
emailVerified: Date | null;
}
| null
| undefined;
const primaryEmailVerified = user.emailVerified;
if (hasEmailBeenChanged) {
secondaryEmail = await prisma.secondaryEmail.findUnique({
where: {
email: input.email,
userId: user.id,
},
select: {
id: true,
emailVerified: true,
},
});
if (emailVerification) {
if (secondaryEmail?.emailVerified) {
data.emailVerified = secondaryEmail.emailVerified;
} else {
// Set metadata of the user so we can set it to this updated email once it is confirmed
data.metadata = {
...userMetadata,
emailChangeWaitingForVerification: input.email?.toLocaleLowerCase(),
};
// Check to ensure this email isn't in use
// Don't include email in the data payload if we need to verify
delete data.email;
}
} else {
log.warn("Profile Update - Email verification is disabled - Skipping");
data.emailVerified = null;
}
}
// if defined AND a base 64 string, upload and update the avatar URL
if (input.avatarUrl && input.avatarUrl.startsWith("data:image/png;base64,")) {
data.avatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(input.avatarUrl),
userId: user.id,
});
}
if (input.completedOnboarding) {
const userTeams = await prisma.user.findFirst({
where: {
id: user.id,
},
select: {
teams: {
select: {
id: true,
},
},
},
});
if (userTeams && userTeams.teams.length > 0) {
await Promise.all(
userTeams.teams.map(async (team) => {
await updateNewTeamMemberEventTypes(user.id, team.id);
})
);
}
}
if (travelSchedules) {
const existingSchedules = await prisma.travelSchedule.findMany({
where: {
userId: user.id,
},
});
const schedulesToDelete = existingSchedules.filter(
(schedule) =>
!travelSchedules || !travelSchedules.find((scheduleInput) => scheduleInput.id === schedule.id)
);
await prisma.travelSchedule.deleteMany({
where: {
userId: user.id,
id: {
in: schedulesToDelete.map((schedule) => schedule.id) as number[],
},
},
});
await prisma.travelSchedule.createMany({
data: travelSchedules
.filter((schedule) => !schedule.id)
.map((schedule) => {
return {
userId: user.id,
startDate: schedule.startDate,
endDate: schedule.endDate,
timeZone: schedule.timeZone,
};
}),
});
}
const updatedUserSelect = Prisma.validator<Prisma.UserDefaultArgs>()({
select: {
id: true,
username: true,
email: true,
identityProvider: true,
identityProviderId: true,
metadata: true,
name: true,
createdDate: true,
avatarUrl: true,
locale: true,
schedules: {
select: {
id: true,
},
},
},
});
let updatedUser: Prisma.UserGetPayload<typeof updatedUserSelect>;
try {
updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data,
...updatedUserSelect,
});
} catch (e) {
// Catch unique constraint failure on email field.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
const meta = e.meta as { target: string[] };
if (meta.target.indexOf("email") !== -1) {
throw new HttpError({ statusCode: 409, message: "email_already_used" });
}
}
throw e; // make sure other errors are rethrown
}
if (user.timeZone !== data.timeZone && updatedUser.schedules.length > 0) {
// on timezone change update timezone of default schedule
const defaultScheduleId = await getDefaultScheduleId(user.id, prisma);
if (!user.defaultScheduleId) {
// set default schedule if not already set
await prisma.user.update({
where: {
id: user.id,
},
data: {
defaultScheduleId,
},
});
}
await prisma.schedule.updateMany({
where: {
id: defaultScheduleId,
},
data: {
timeZone: data.timeZone,
},
});
}
// Notify stripe about the change
if (updatedUser && updatedUser.metadata && hasKeyInMetadata(updatedUser, "stripeCustomerId")) {
const stripeCustomerId = `${updatedUser.metadata.stripeCustomerId}`;
await stripe.customers.update(stripeCustomerId, {
metadata: {
username: updatedUser.username,
email: updatedUser.email,
userId: updatedUser.id,
},
});
}
if (updatedUser && hasEmailBeenChanged) {
// Skip sending verification email when user tries to change his primary email to a verified secondary email
if (secondaryEmail?.emailVerified) {
secondaryEmails.push({
id: secondaryEmail.id,
email: user.email,
isDeleted: false,
});
} else {
await sendChangeOfEmailVerification({
user: {
username: updatedUser.username ?? "Nameless User",
emailFrom: user.email,
// We know email has been changed here so we can use input
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
emailTo: input.email!,
},
});
}
}
if (secondaryEmails.length) {
const recordsToDelete = secondaryEmails
.filter((secondaryEmail) => secondaryEmail.isDeleted)
.map((secondaryEmail) => secondaryEmail.id);
if (recordsToDelete.length) {
await prisma.secondaryEmail.deleteMany({
where: {
id: {
in: recordsToDelete,
},
userId: updatedUser.id,
},
});
}
const modifiedRecords = secondaryEmails.filter((secondaryEmail) => !secondaryEmail.isDeleted);
if (modifiedRecords.length) {
const secondaryEmailsFromDB = await prisma.secondaryEmail.findMany({
where: {
id: {
in: secondaryEmails.map((secondaryEmail) => secondaryEmail.id),
},
userId: updatedUser.id,
},
});
const keyedSecondaryEmailsFromDB = keyBy(secondaryEmailsFromDB, "id");
const recordsToModifyQueue = modifiedRecords.map((updated) => {
let emailVerified = keyedSecondaryEmailsFromDB[updated.id].emailVerified;
if (secondaryEmail?.id === updated.id) {
emailVerified = primaryEmailVerified;
} else if (updated.email !== keyedSecondaryEmailsFromDB[updated.id].email) {
emailVerified = null;
}
return prisma.secondaryEmail.update({
where: {
id: updated.id,
userId: updatedUser.id,
},
data: {
email: updated.email,
emailVerified,
},
});
});
await prisma.$transaction(recordsToModifyQueue);
}
}
return {
...input,
email: emailVerification && !secondaryEmail?.emailVerified ? user.email : input.email,
avatarUrl: updatedUser.avatarUrl,
hasEmailBeenChanged,
sendEmailVerification: emailVerification && !secondaryEmail?.emailVerified,
};
};
const cleanMetadataAllowedUpdateKeys = (metadata: TUpdateProfileInputSchema["metadata"]) => {
if (!metadata) {
return {};
}
const cleanedMetadata = updateUserMetadataAllowedKeys.safeParse(metadata);
if (!cleanedMetadata.success) {
logger.error("Error cleaning metadata", cleanedMetadata.error);
return {};
}
return cleanedMetadata.data;
};
const handleUserMetadata = ({ ctx, input }: UpdateProfileOptions) => {
const { user } = ctx;
const cleanMetadata = cleanMetadataAllowedUpdateKeys(input.metadata);
const userMetadata = userMetadataSchema.parse(user.metadata);
// Required so we don't override and delete saved values
return { ...userMetadata, ...cleanMetadata };
};