Files
cal.diy/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts
T
Keith WilliamsandDevin AI 15b1675db1 perf: reduce TypeScript type bloat in tRPC package via z.ZodType annotations (#25845)
* fix: prevent Prisma conditional types from leaking into .d.ts files

This PR addresses TypeScript performance issues caused by Prisma's conditional types leaking through the type graph:

1. Replace Prisma.UserGetPayload with explicit UpdatedUserResult type in updateProfile.handler.ts
2. Replace Prisma.EventTypeGetPayload with explicit UpdatedEventTypeResult type in update.handler.ts
3. Replace Prisma.OutOfOfficeEntryGetPayload with explicit OOOEntryResult type in outOfOfficeCreateOrUpdate.handler.ts
4. Replace Prisma.CredentialGetPayload with explicit Credential type in getUserConnectedApps.handler.ts
5. Fix inconsistent DI usage in EventTypeRepository - use this.prismaClient instead of global prisma singleton

These changes prevent massive recursive Prisma types from propagating through the type graph and being emitted in .d.ts files, which improves TypeScript performance.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: correct reasonId type to number | null in OOOEntryResult

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* perf: add z.ZodType annotations to reduce .d.ts file sizes

- Annotate exported Zod schemas with z.ZodType<T> to prevent full Zod generic tree from being emitted in declaration files
- eventTypes/types.d.ts reduced from 231KB to 115KB (50% reduction)
- _app.d.ts reduced from 782KB to 753KB (3.7% reduction)
- viewer/_router.d.ts reduced from 722KB to 695KB (3.7% reduction)
- workflows/getAllActiveWorkflows.schema.d.ts significantly reduced
- routing-forms/formMutation.schema.d.ts significantly reduced

Files modified:
- packages/trpc/server/routers/viewer/eventTypes/types.ts
- packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.schema.ts
- packages/trpc/server/routers/apps/routing-forms/formMutation.schema.ts
- packages/features/eventtypes/lib/types.ts

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* perf: comprehensive z.ZodType annotations to reduce .d.ts file sizes

Applied z.ZodType<T> annotations to 89 schema files across the tRPC package.
This prevents TypeScript from emitting the full Zod generic tree in .d.ts files,
reducing declaration file sizes for downstream consumers.

Files modified include schemas in:
- viewer/teams (round-robin, managed events, invitations, etc.)
- viewer/bookings (get, find, confirm, etc.)
- viewer/eventTypes (get, delete, getByViewer, etc.)
- viewer/workflows (list, delete, verify, etc.)
- viewer/auth (changePassword, verifyPassword, etc.)
- viewer/apiKeys (create, delete, edit, etc.)
- viewer/sso (get, update, delete, updateOIDC)
- viewer/oAuth (addClient, generateAuthCode)
- viewer/calendars (setDestinationCalendar)
- viewer/deploymentSetup (update, validateLicense)
- apps/routing-forms (formQuery, deleteForm, etc.)
- publicViewer (submitRating, markHostAsNoShow, etc.)
- loggedInViewer (eventTypeOrder, routingFormOrder, etc.)

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: revert slots/types.ts z.ZodType annotation that caused type mismatch

The slots/types.ts schema has a transform that converts duration from
string to number, which makes the z.ZodType<T> annotation incompatible.
Reverting to original to fix Unit test failure.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: use 3-generic z.ZodType pattern for schemas with .default() modifiers

For schemas with .default() modifiers, the input and output types differ:
- Input type: field is optional (what callers send)
- Output type: field is required (what handlers receive after parsing)

This commit:
1. Fixes get.schema.ts (offset has .default(0))
2. Fixes removeMember.schema.ts (isOrg has .default(false))
3. Fixes resendInvitation.schema.ts (isOrg has .default(false))
4. Fixes listMembers.schema.ts (limit has .default(10))
5. Fixes getByViewer.schema.ts (limit has .default(10))
6. Reverts eventTypes/types.ts (complex transforms hard to model)
7. Reverts features/eventtypes/lib/types.ts (complex transforms hard to model)

The 3-generic pattern z.ZodType<Output, z.ZodTypeDef, Input> properly models
the difference between input and output types while still preventing the full
Zod generic tree from being emitted in .d.ts files.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: use 3-generic z.ZodType pattern for schemas with transforms/defaults

- organizations/update.schema.ts: orgId has .transform() that converts string to number, so input type is string | number but output type is number
- publicViewer/event.schema.ts: fromRedirectOfNonOrgLink has .default(false), so input has it optional but output has it required

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: use 3-generic z.ZodType pattern for updateProfile.schema.ts

Address reviewer feedback: isDeleted field in secondaryEmails has .default(false),
so input type has it optional but output type has it required.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* chore: remove explanatory comments from schema files

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: use 3-generic z.ZodType pattern for addClient.schema.ts enablePkce field

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: address Hariom's PR feedback

- Rename TGetInputSchemaInput to TGetInputRawSchema in get.schema.ts
- Use Prisma-free JsonValue type from @calcom/types/Json in updateProfile.handler.ts
- Fix publish.schema.ts with 3-generic pattern for z.coerce.number()

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: add sort field to TGetInputSchema types

Added sort field to both TGetInputRawSchema and TGetInputSchema types
to match the Zod schema that was updated in main branch.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-17 15:06:57 -03:00

419 lines
13 KiB
TypeScript

import { keyBy } from "lodash";
import type { GetServerSidePropsContext, NextApiResponse } from "next";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing";
import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
import { ScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository";
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 { getTranslation } from "@calcom/lib/server/i18n";
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 { Prisma } from "@calcom/prisma/client";
import type { JsonValue } from "@calcom/types/Json";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
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 billingService = getBillingProviderService();
const userMetadata = handleUserMetadata({ ctx, input });
const locale = input.locale || user.locale;
const featuresRepository = new FeaturesRepository(prisma);
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("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 billingService.getSubscriptions(stripeCustomerId);
if (!stripeSubscriptions || !stripeSubscriptions.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.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,") ||
input.avatarUrl.startsWith("data:image/jpeg;base64,") ||
input.avatarUrl.startsWith("data:image/jpg;base64,"))
) {
data.avatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(input.avatarUrl),
userId: user.id,
});
}
if (input.completedOnboarding) {
const userTeams = await prisma.user.findUnique({
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 = {
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,
},
},
},
} satisfies Prisma.UserDefaultArgs;
// Explicit type to avoid Prisma.UserGetPayload conditional types leaking into .d.ts files
type UpdatedUserResult = {
id: number;
username: string | null;
email: string;
identityProvider: string | null;
identityProviderId: string | null;
metadata: JsonValue;
name: string | null;
createdDate: Date;
avatarUrl: string | null;
locale: string | null;
schedules: { id: number }[];
};
let updatedUser: UpdatedUserResult;
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 scheduleRepository = new ScheduleRepository(prisma);
const defaultScheduleId = await scheduleRepository.getDefaultScheduleId(user.id);
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 billingService.updateCustomer({
customerId: stripeCustomerId,
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
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 };
};