cal.pub0.org/packages/trpc/server/routers/viewer.tsx

1463 lines
45 KiB
TypeScript

import { AppCategories, BookingStatus, IdentityProvider, MembershipRole, Prisma } from "@prisma/client";
import _ from "lodash";
import { authenticator } from "otplib";
import { z } from "zod";
import app_RoutingForms from "@calcom/app-store/ee/routing-forms/trpc-router";
import ethRouter from "@calcom/app-store/rainbow/trpc/router";
import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession";
import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { DailyLocationType } from "@calcom/core/location";
import dayjs from "@calcom/dayjs";
import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { ErrorCode, verifyPassword } from "@calcom/lib/auth";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import jackson from "@calcom/lib/jackson";
import {
hostedCal,
isSAMLAdmin,
isSAMLLoginEnabled,
samlProductID,
samlTenantID,
samlTenantProduct,
tenantPrefix,
} from "@calcom/lib/saml";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import { getTranslation } from "@calcom/lib/server/i18n";
import { isTeamOwner } from "@calcom/lib/server/queries/teams";
import slugify from "@calcom/lib/slugify";
import {
deleteWebUser as syncServicesDeleteWebUser,
updateWebUser as syncServicesUpdateWebUser,
} from "@calcom/lib/sync/SyncServiceManager";
import prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image";
import { TRPCError } from "@trpc/server";
import { createProtectedRouter, createRouter } from "../createRouter";
import { apiKeysRouter } from "./viewer/apiKeys";
import { authRouter } from "./viewer/auth";
import { availabilityRouter } from "./viewer/availability";
import { bookingsRouter } from "./viewer/bookings";
import { eventTypesRouter } from "./viewer/eventTypes";
import { slotsRouter } from "./viewer/slots";
import { viewerTeamsRouter } from "./viewer/teams";
import { webhookRouter } from "./viewer/webhook";
import { workflowsRouter } from "./viewer/workflows";
// things that unauthenticated users can query about themselves
const publicViewerRouter = createRouter()
.query("session", {
resolve({ ctx }) {
return ctx.session;
},
})
.query("i18n", {
async resolve({ ctx }) {
const { locale, i18n } = ctx;
return {
i18n,
locale,
};
},
})
.mutation("samlTenantProduct", {
input: z.object({
email: z.string().email(),
}),
async resolve({ input, ctx }) {
const { prisma } = ctx;
const { email } = input;
return await samlTenantProduct(prisma, email);
},
})
.query("stripeCheckoutSession", {
input: z.object({
stripeCustomerId: z.string().optional(),
checkoutSessionId: z.string().optional(),
}),
async resolve({ input }) {
const { checkoutSessionId, stripeCustomerId } = input;
// TODO: Move the following data checks to superRefine
if (!checkoutSessionId && !stripeCustomerId) {
throw new Error("Missing checkoutSessionId or stripeCustomerId");
}
if (checkoutSessionId && stripeCustomerId) {
throw new Error("Both checkoutSessionId and stripeCustomerId provided");
}
let customerId: string;
let isPremiumUsername = false;
let hasPaymentFailed = false;
if (checkoutSessionId) {
try {
const session = await stripe.checkout.sessions.retrieve(checkoutSessionId);
if (typeof session.customer !== "string") {
return {
valid: false,
};
}
customerId = session.customer;
isPremiumUsername = true;
hasPaymentFailed = session.payment_status !== "paid";
} catch (e) {
return {
valid: false,
};
}
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
customerId = stripeCustomerId!;
}
try {
const customer = await stripe.customers.retrieve(customerId);
if (customer.deleted) {
return {
valid: false,
};
}
return {
valid: true,
hasPaymentFailed,
isPremiumUsername,
customer: {
username: customer.metadata.username,
email: customer.metadata.email,
stripeCustomerId: customerId,
},
};
} catch (e) {
return {
valid: false,
};
}
},
})
.merge("slots.", slotsRouter);
// routes only available to authenticated users
const loggedInViewerRouter = createProtectedRouter()
.query("me", {
resolve({ ctx: { user } }) {
// Destructuring here only makes it more illegible
// pick only the part we want to expose in the API
return {
id: user.id,
name: user.name,
username: user.username,
email: user.email,
startTime: user.startTime,
endTime: user.endTime,
bufferTime: user.bufferTime,
locale: user.locale,
timeFormat: user.timeFormat,
timeZone: user.timeZone,
avatar: user.avatar,
createdDate: user.createdDate,
trialEndsAt: user.trialEndsAt,
completedOnboarding: user.completedOnboarding,
twoFactorEnabled: user.twoFactorEnabled,
disableImpersonation: user.disableImpersonation,
identityProvider: user.identityProvider,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
plan: user.plan,
away: user.away,
bio: user.bio,
weekStart: user.weekStart,
theme: user.theme,
hideBranding: user.hideBranding,
metadata: user.metadata,
};
},
})
.mutation("deleteMe", {
input: z.object({
password: z.string(),
totpCode: z.string().optional(),
}),
async resolve({ input, ctx }) {
// Check if input.password is correct
const user = await prisma.user.findUnique({
where: {
email: ctx.user.email.toLowerCase(),
},
});
if (!user) {
throw new Error(ErrorCode.UserNotFound);
}
if (user.identityProvider !== IdentityProvider.CAL) {
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
}
if (!user.password) {
throw new Error(ErrorCode.UserMissingPassword);
}
const isCorrectPassword = await verifyPassword(input.password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.IncorrectPassword);
}
if (user.twoFactorEnabled) {
if (!input.totpCode) {
throw new Error(ErrorCode.SecondFactorRequired);
}
if (!user.twoFactorSecret) {
console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
throw new Error(ErrorCode.InternalServerError);
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error(`"Missing encryption key; cannot proceed with two factor login."`);
throw new Error(ErrorCode.InternalServerError);
}
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
if (secret.length !== 32) {
console.error(
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
);
throw new Error(ErrorCode.InternalServerError);
}
const isValidToken = authenticator.check(input.totpCode, secret);
if (!isValidToken) {
throw new Error(ErrorCode.IncorrectTwoFactorCode);
}
// If user has 2fa enabled, check if input.totpCode is correct
// If it is, delete the user from stripe and database
// Remove me from Stripe
await deleteStripeCustomer(user).catch(console.warn);
// Remove my account
const deletedUser = await ctx.prisma.user.delete({
where: {
id: ctx.user.id,
},
});
// Sync Services
syncServicesDeleteWebUser(deletedUser);
}
return;
},
})
.mutation("away", {
input: z.object({
away: z.boolean(),
}),
async resolve({ input, ctx }) {
await ctx.prisma.user.update({
where: {
email: ctx.user.email,
},
data: {
away: input.away,
},
});
},
})
.query("eventTypes", {
async resolve({ ctx }) {
const { prisma } = ctx;
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
position: true,
successRedirectUrl: true,
hashedLink: true,
destinationCalendar: true,
team: true,
users: {
select: {
id: true,
username: true,
name: true,
},
},
...baseEventTypeSelect,
});
const user = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
id: true,
username: true,
name: true,
startTime: true,
endTime: true,
bufferTime: true,
plan: true,
teams: {
where: {
accepted: true,
},
select: {
role: true,
team: {
select: {
id: true,
name: true,
slug: true,
logo: true,
members: {
select: {
userId: true,
},
},
eventTypes: {
select: eventTypeSelect,
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
},
},
},
},
},
eventTypes: {
where: {
team: null,
},
select: eventTypeSelect,
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
},
},
});
if (!user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
// backwards compatibility, TMP:
const typesRaw = await prisma.eventType.findMany({
where: {
userId: ctx.user.id,
},
select: eventTypeSelect,
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
});
type EventTypeGroup = {
teamId?: number | null;
profile: {
slug: typeof user["username"];
name: typeof user["name"];
};
metadata: {
membershipCount: number;
readOnly: boolean;
};
eventTypes: typeof user.eventTypes[number][];
};
let eventTypeGroups: EventTypeGroup[] = [];
const eventTypesHashMap = user.eventTypes.concat(typesRaw).reduce((hashMap, newItem) => {
const oldItem = hashMap[newItem.id];
hashMap[newItem.id] = { ...oldItem, ...newItem };
return hashMap;
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
const mergedEventTypes = Object.values(eventTypesHashMap).map((eventType) => eventType);
eventTypeGroups.push({
teamId: null,
profile: {
slug: user.username,
name: user.name,
},
eventTypes: _.orderBy(mergedEventTypes, ["position", "id"], ["desc", "asc"]),
metadata: {
membershipCount: 1,
readOnly: false,
},
});
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
user.teams.map((membership) => ({
teamId: membership.team.id,
profile: {
name: membership.team.name,
image: membership.team.logo || "",
slug: "team/" + membership.team.slug,
},
metadata: {
membershipCount: membership.team.members.length,
readOnly: membership.role === MembershipRole.MEMBER,
},
eventTypes: membership.team.eventTypes,
}))
);
return {
viewer: {
plan: user.plan,
},
// don't display event teams without event types,
eventTypeGroups: eventTypeGroups.filter((groupBy) => !!groupBy.eventTypes?.length),
// so we can show a dropdown when the user has teams
profiles: eventTypeGroups.map((group) => ({
teamId: group.teamId,
...group.profile,
...group.metadata,
})),
};
},
})
.query("bookings", {
input: z.object({
status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]),
limit: z.number().min(1).max(100).nullish(),
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
}),
async resolve({ ctx, input }) {
// using offset actually because cursor pagination requires a unique column
// for orderBy, but we don't use a unique column in our orderBy
const take = input.limit ?? 10;
const skip = input.cursor ?? 0;
const { prisma, user } = ctx;
const bookingListingByStatus = input.status;
const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput> = {
upcoming: {
endTime: { gte: new Date() },
// These changes are needed to not show confirmed recurring events,
// as rescheduling or cancel for recurring event bookings should be
// handled separately for each occurrence
OR: [
{
AND: [
{ NOT: { recurringEventId: { equals: null } } },
{
status: {
notIn: [BookingStatus.PENDING, BookingStatus.CANCELLED, BookingStatus.REJECTED],
},
},
],
},
{
AND: [
{ recurringEventId: { equals: null } },
{ status: { notIn: [BookingStatus.CANCELLED, BookingStatus.REJECTED] } },
],
},
],
},
recurring: {
endTime: { gte: new Date() },
AND: [
{ NOT: { recurringEventId: { equals: null } } },
{ status: { notIn: [BookingStatus.CANCELLED, BookingStatus.REJECTED] } },
],
},
past: {
endTime: { lte: new Date() },
AND: [
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
],
},
cancelled: {
OR: [
{ status: { equals: BookingStatus.CANCELLED } },
{ status: { equals: BookingStatus.REJECTED } },
],
},
unconfirmed: {
endTime: { gte: new Date() },
status: { equals: BookingStatus.PENDING },
},
};
const bookingListingOrderby: Record<
typeof bookingListingByStatus,
Prisma.BookingOrderByWithAggregationInput
> = {
upcoming: { startTime: "asc" },
recurring: { startTime: "asc" },
past: { startTime: "desc" },
cancelled: { startTime: "desc" },
unconfirmed: { startTime: "asc" },
};
const passedBookingsFilter = bookingListingFilters[bookingListingByStatus];
const orderBy = bookingListingOrderby[bookingListingByStatus];
const bookingsQuery = await prisma.booking.findMany({
where: {
OR: [
{
userId: user.id,
},
{
attendees: {
some: {
email: user.email,
},
},
},
{
eventType: {
team: {
members: {
some: {
userId: user.id,
role: "OWNER",
},
},
},
},
},
],
AND: [passedBookingsFilter],
},
select: {
...bookingMinimalSelect,
uid: true,
recurringEventId: true,
location: true,
eventType: {
select: {
slug: true,
id: true,
eventName: true,
price: true,
recurringEvent: true,
team: {
select: {
name: true,
},
},
},
},
status: true,
paid: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
rescheduled: true,
},
orderBy,
take: take + 1,
skip,
});
let bookings = bookingsQuery.map((booking) => {
return {
...booking,
eventType: {
...booking.eventType,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
},
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
};
});
const bookingsFetched = bookings.length;
const seenBookings: Record<string, boolean> = {};
// Remove duplicate recurring bookings for upcoming status.
// Couldn't use distinct in query because the distinct column would be different for recurring and non recurring event.
// We might be actually sending less then the limit, due to this filter
// TODO: Figure out a way to fix it.
if (bookingListingByStatus === "upcoming") {
bookings = bookings.filter((booking) => {
if (!booking.recurringEventId) {
return true;
}
if (seenBookings[booking.recurringEventId]) {
return false;
}
seenBookings[booking.recurringEventId] = true;
return true;
});
}
let nextCursor: typeof skip | null = skip;
if (bookingsFetched > take) {
nextCursor += bookingsFetched;
} else {
nextCursor = null;
}
return {
bookings,
nextCursor,
};
},
})
.query("connectedCalendars", {
async resolve({ ctx }) {
const { user } = ctx;
// get user's credentials + their connected integrations
const calendarCredentials = getCalendarCredentials(user.credentials, user.id);
// get all the connected integrations' calendars (from third party)
const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
if (connectedCalendars.length === 0) {
/* As there are no connected calendars, delete the destination calendar if it exists */
if (user.destinationCalendar) {
await ctx.prisma.destinationCalendar.delete({
where: { userId: user.id },
});
user.destinationCalendar = null;
}
} else if (!user.destinationCalendar) {
/*
There are connected calendars, but no destination calendar
So create a default destination calendar with the first primary connected calendar
*/
const { integration = "", externalId = "", credentialId } = connectedCalendars[0].primary ?? {};
user.destinationCalendar = await ctx.prisma.destinationCalendar.create({
data: {
userId: user.id,
integration,
externalId,
credentialId,
},
});
} else {
/* There are connected calendars and a destination calendar */
// Check if destinationCalendar exists in connectedCalendars
const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat();
const destinationCal = allCals.find(
(cal) =>
cal.externalId === user.destinationCalendar?.externalId &&
cal.integration === user.destinationCalendar?.integration
);
if (!destinationCal) {
// If destinationCalendar is out of date, update it with the first primary connected calendar
const { integration = "", externalId = "" } = connectedCalendars[0].primary ?? {};
user.destinationCalendar = await ctx.prisma.destinationCalendar.update({
where: { userId: user.id },
data: {
integration,
externalId,
},
});
}
}
return {
connectedCalendars,
destinationCalendar: user.destinationCalendar,
};
},
})
.mutation("setDestinationCalendar", {
input: z.object({
integration: z.string(),
externalId: z.string(),
eventTypeId: z.number().nullish(),
bookingId: z.number().nullish(),
}),
async resolve({ ctx, input }) {
const { user } = ctx;
const { integration, externalId, eventTypeId } = input;
const calendarCredentials = getCalendarCredentials(user.credentials, user.id);
const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat();
const credentialId = allCals.find(
(cal) => cal.externalId === externalId && cal.integration === integration && cal.readOnly === false
)?.credentialId;
if (!credentialId) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find calendar ${input.externalId}` });
}
let where;
if (eventTypeId) where = { eventTypeId };
else where = { userId: user.id };
await ctx.prisma.destinationCalendar.upsert({
where,
update: {
integration,
externalId,
credentialId,
},
create: {
...where,
integration,
externalId,
credentialId,
},
});
},
})
.query("integrations", {
input: z.object({
variant: z.string().optional(),
exclude: z.array(z.string()).optional(),
onlyInstalled: z.boolean().optional(),
}),
async resolve({ ctx, input }) {
const { user } = ctx;
const { variant, exclude, onlyInstalled } = input;
const { credentials } = user;
let apps = getApps(credentials).map(
({ credentials: _, credential: _1 /* don't leak to frontend */, ...app }) => ({
...app,
credentialIds: credentials.filter((c) => c.type === app.type).map((c) => c.id),
})
);
if (exclude) {
// exclusion filter
apps = apps.filter((item) => (exclude ? !exclude.includes(item.variant) : true));
}
if (variant) {
// `flatMap()` these work like `.filter()` but infers the types correctly
apps = apps
// variant check
.flatMap((item) => (item.variant.startsWith(variant) ? [item] : []));
}
if (onlyInstalled) {
apps = apps.flatMap((item) => (item.credentialIds.length > 0 || item.isGlobal ? [item] : []));
}
return {
items: apps,
};
},
})
.query("appById", {
input: z.object({
appId: z.string(),
}),
async resolve({ ctx, input }) {
const { user } = ctx;
const appId = input.appId;
const { credentials } = user;
const apps = getApps(credentials);
const appFromDb = apps.find((app) => app.credential?.appId === appId);
if (!appFromDb) {
return appFromDb;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { credential: _, credentials: _1, ...app } = appFromDb;
return app;
},
})
.query("appCredentialsByType", {
input: z.object({
appType: z.string(),
}),
async resolve({ ctx, input }) {
const { user } = ctx;
return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id);
},
})
.query("stripeCustomer", {
async resolve({ ctx }) {
const {
user: { id: userId },
prisma,
} = ctx;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
metadata: true,
},
});
if (!user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "User not found" });
}
const metadata = userMetadata.parse(user.metadata);
const checkoutSessionId = metadata?.checkoutSessionId;
//TODO: Rename checkoutSessionId to premiumUsernameCheckoutSessionId
if (!checkoutSessionId) return { isPremium: false };
const { stripeCustomer, checkoutSession } = await getCustomerAndCheckoutSession(checkoutSessionId);
if (!stripeCustomer) {
throw new TRPCError({ code: "NOT_FOUND", message: "Stripe User not found" });
}
return {
isPremium: true,
paidForPremium: checkoutSession.payment_status === "paid",
username: stripeCustomer.metadata.username,
};
},
})
.mutation("updateProfile", {
input: z.object({
username: z.string().optional(),
name: z.string().optional(),
email: z.string().optional(),
bio: z.string().optional(),
avatar: z.string().optional(),
timeZone: z.string().optional(),
weekStart: z.string().optional(),
hideBranding: z.boolean().optional(),
allowDynamicBooking: z.boolean().optional(),
brandColor: z.string().optional(),
darkBrandColor: z.string().optional(),
theme: z.string().optional().nullable(),
completedOnboarding: z.boolean().optional(),
locale: z.string().optional(),
timeFormat: z.number().optional(),
disableImpersonation: z.boolean().optional(),
}),
async resolve({ input, ctx }) {
const { user, prisma } = ctx;
const data: Prisma.UserUpdateInput = {
...input,
};
let isPremiumUsername = false;
if (input.username) {
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) {
throw new TRPCError({ code: "BAD_REQUEST", message: response.message });
}
}
}
if (input.avatar) {
data.avatar = await resizeBase64Image(input.avatar);
}
const userToUpdate = await prisma.user.findUnique({
where: {
id: user.id,
},
});
if (!userToUpdate) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
const metadata = userMetadata.parse(userToUpdate.metadata);
// Checking the status of payment directly from stripe allows to avoid the situation where the user has got the refund or maybe something else happened asyncly at stripe but our DB thinks it's still paid for
// TODO: Test the case where one time payment is refunded.
const premiumUsernameCheckoutSessionId = metadata?.checkoutSessionId;
if (premiumUsernameCheckoutSessionId) {
const checkoutSession = await stripe.checkout.sessions.retrieve(premiumUsernameCheckoutSessionId);
const canUserHavePremiumUsername = checkoutSession.payment_status == "paid";
if (isPremiumUsername && !canUserHavePremiumUsername) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You need to pay for premium username",
});
}
}
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data,
select: {
id: true,
username: true,
email: true,
metadata: true,
name: true,
plan: true,
createdDate: true,
},
});
// Sync Services
await syncServicesUpdateWebUser(updatedUser);
// 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,
},
});
}
},
})
.mutation("eventTypeOrder", {
input: z.object({
ids: z.array(z.number()),
}),
async resolve({ input, ctx }) {
const { prisma, user } = ctx;
const allEventTypes = await ctx.prisma.eventType.findMany({
select: {
id: true,
},
where: {
id: {
in: input.ids,
},
OR: [
{
userId: user.id,
},
{
users: {
some: {
id: user.id,
},
},
},
{
team: {
members: {
some: {
userId: user.id,
},
},
},
},
],
},
});
const allEventTypeIds = new Set(allEventTypes.map((type) => type.id));
if (input.ids.some((id) => !allEventTypeIds.has(id))) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
await Promise.all(
_.reverse(input.ids).map((id, position) => {
return prisma.eventType.update({
where: {
id,
},
data: {
position,
},
});
})
);
},
})
.mutation("eventTypePosition", {
input: z.object({
eventType: z.number(),
action: z.string(),
}),
async resolve({ input, ctx }) {
// This mutation is for the user to be able to order their event types by incrementing or decrementing the position number
const { prisma } = ctx;
if (input.eventType && input.action == "increment") {
await prisma.eventType.update({
where: {
id: input.eventType,
},
data: {
position: {
increment: 1,
},
},
});
}
if (input.eventType && input.action == "decrement") {
await prisma.eventType.update({
where: {
id: input.eventType,
},
data: {
position: {
decrement: 1,
},
},
});
}
},
})
.query("showSAMLView", {
input: z.object({
teamsView: z.boolean(),
teamId: z.union([z.number(), z.null(), z.undefined()]),
}),
async resolve({ input, ctx }) {
const { user } = ctx;
const { teamsView, teamId } = input;
if ((teamsView && !hostedCal) || (!teamsView && hostedCal)) {
return {
isSAMLLoginEnabled: false,
hostedCal,
};
}
let enabled = isSAMLLoginEnabled;
// in teams view we already check for isAdmin
if (teamsView) {
enabled = enabled && user.plan === "PRO";
} else {
enabled = enabled && isSAMLAdmin(user.email);
}
let provider;
if (enabled) {
const { apiController } = await jackson();
try {
const resp = await apiController.getConfig({
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
product: samlProductID,
});
provider = resp.provider;
} catch (err) {
console.error("Error getting SAML config", err);
throw new TRPCError({ code: "BAD_REQUEST", message: "SAML configuration fetch failed" });
}
}
return {
isSAMLLoginEnabled: enabled,
hostedCal,
provider,
};
},
})
.mutation("updateSAMLConfig", {
input: z.object({
encodedRawMetadata: z.string(),
teamId: z.union([z.number(), z.null(), z.undefined()]),
}),
async resolve({ ctx, input }) {
const { encodedRawMetadata, teamId } = input;
if (teamId && !(await isTeamOwner(ctx.user?.id, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
const { apiController } = await jackson();
try {
return await apiController.config({
encodedRawMetadata,
defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/auth/saml/idp`,
redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]),
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
product: samlProductID,
});
} catch (err) {
console.error("Error setting SAML config", err);
throw new TRPCError({ code: "BAD_REQUEST" });
}
},
})
.mutation("deleteSAMLConfig", {
input: z.object({
teamId: z.union([z.number(), z.null(), z.undefined()]),
}),
async resolve({ ctx, input }) {
const { teamId } = input;
if (teamId && !(await isTeamOwner(ctx.user?.id, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
const { apiController } = await jackson();
try {
return await apiController.deleteConfig({
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
product: samlProductID,
});
} catch (err) {
console.error("Error deleting SAML configuration", err);
throw new TRPCError({ code: "BAD_REQUEST" });
}
},
})
.mutation("submitFeedback", {
input: z.object({
rating: z.string(),
comment: z.string(),
}),
async resolve({ input, ctx }) {
const { rating, comment } = input;
const feedback = {
username: ctx.user.username || "Nameless",
email: ctx.user.email || "No email address",
rating: rating,
comment: comment,
};
await ctx.prisma.feedback.create({
data: {
date: dayjs().toISOString(),
userId: ctx.user.id,
rating: rating,
comment: comment,
},
});
if (process.env.SEND_FEEDBACK_EMAIL && comment) sendFeedbackEmail(feedback);
},
})
.query("locationOptions", {
async resolve({ ctx }) {
const credentials = await prisma.credential.findMany({
where: {
userId: ctx.user.id,
},
select: {
id: true,
type: true,
key: true,
userId: true,
appId: true,
},
});
const integrations = getApps(credentials);
const t = await getTranslation(ctx.user.locale ?? "en", "common");
const locationOptions = getLocationOptions(integrations, t);
return locationOptions;
},
})
.mutation("deleteCredential", {
input: z.object({
id: z.number(),
externalId: z.string().optional(),
}),
async resolve({ input, ctx }) {
const { id, externalId } = input;
const credential = await prisma.credential.findFirst({
where: {
id: id,
userId: ctx.user.id,
},
include: {
app: {
select: {
slug: true,
categories: true,
},
},
},
});
if (!credential) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const eventTypes = await prisma.eventType.findMany({
where: {
userId: ctx.user.id,
},
select: {
id: true,
locations: true,
destinationCalendar: true,
price: true,
},
});
for (const eventType of eventTypes) {
if (eventType.locations) {
// If it's a video, replace the location with Cal video
if (credential.app?.categories.includes(AppCategories.video)) {
// Find the user's event types
// Look for integration name from app slug
const integrationQuery =
credential.app?.slug === "msteams" ? "office365_video" : credential.app?.slug.split("-")[0];
// Check if the event type uses the deleted integration
// To avoid type errors, need to stringify and parse JSON to use array methods
const locationsSchema = z.array(z.object({ type: z.string() }));
const locations = locationsSchema.parse(eventType.locations);
const updatedLocations = locations.map((location: { type: string }) => {
if (location.type.includes(integrationQuery)) {
return { type: DailyLocationType };
}
return location;
});
await prisma.eventType.update({
where: {
id: eventType.id,
},
data: {
locations: updatedLocations,
},
});
}
}
// If it's a calendar, remove the destination calendar from the event type
if (credential.app?.categories.includes(AppCategories.calendar)) {
if (eventType.destinationCalendar?.integration === credential.type) {
const destinationCalendar = await prisma.destinationCalendar.findFirst({
where: {
id: eventType.destinationCalendar?.id,
},
});
if (destinationCalendar) {
await prisma.destinationCalendar.delete({
where: {
id: destinationCalendar.id,
},
});
}
}
if (externalId) {
const existingSelectedCalendar = await prisma.selectedCalendar.findFirst({
where: {
externalId: externalId,
},
});
// @TODO: SelectedCalendar doesn't have unique ID so we should only delete one item
if (existingSelectedCalendar) {
await prisma.selectedCalendar.delete({
where: {
userId_integration_externalId: {
userId: existingSelectedCalendar.userId,
externalId: existingSelectedCalendar.externalId,
integration: existingSelectedCalendar.integration,
},
},
});
}
}
}
// If it's a payment, hide the event type and set the price to 0. Also cancel all pending bookings
if (credential.app?.categories.includes(AppCategories.payment)) {
if (eventType.price) {
await prisma.$transaction(async () => {
await prisma.eventType.update({
where: {
id: eventType.id,
},
data: {
hidden: true,
price: 0,
},
});
// Assuming that all bookings under this eventType need to be paid
const unpaidBookings = await prisma.booking.findMany({
where: {
userId: ctx.user.id,
eventTypeId: eventType.id,
status: "PENDING",
paid: false,
payment: {
every: {
success: false,
},
},
},
select: {
...bookingMinimalSelect,
recurringEventId: true,
userId: true,
user: {
select: {
id: true,
credentials: true,
email: true,
timeZone: true,
name: true,
destinationCalendar: true,
locale: true,
},
},
location: true,
references: {
select: {
uid: true,
type: true,
externalCalendarId: true,
},
},
payment: true,
paid: true,
eventType: {
select: {
recurringEvent: true,
title: true,
},
},
uid: true,
eventTypeId: true,
destinationCalendar: true,
},
});
for (const booking of unpaidBookings) {
await prisma.booking.update({
where: {
id: booking.id,
},
data: {
status: BookingStatus.CANCELLED,
cancellationReason: "Payment method removed",
},
});
for (const payment of booking.payment) {
// Right now we only close payments on Stripe
const stripeKeysSchema = z.object({
stripe_user_id: z.string(),
});
const { stripe_user_id } = stripeKeysSchema.parse(credential.key);
await closePayments(payment.externalId, stripe_user_id);
await prisma.payment.delete({
where: {
id: payment.id,
},
});
}
await prisma.attendee.deleteMany({
where: {
bookingId: booking.id,
},
});
await prisma.bookingReference.deleteMany({
where: {
bookingId: booking.id,
},
});
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const tOrganizer = await getTranslation(booking?.user?.locale ?? "en", "common");
await sendCancelledEmails({
type: booking?.eventType?.title as string,
title: booking.title,
description: booking.description,
customInputs: isPrismaObjOrUndefined(booking.customInputs),
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
email: booking?.user?.email as string,
name: booking?.user?.name ?? "Nameless",
timeZone: booking?.user?.timeZone as string,
language: { translate: tOrganizer, locale: booking?.user?.locale ?? "en" },
},
attendees: attendeesList,
uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
location: booking.location,
destinationCalendar: booking.destinationCalendar || booking.user?.destinationCalendar,
cancellationReason: "Payment method removed by organizer",
});
}
});
}
}
}
// if zapier get disconnected, delete zapier apiKey, delete zapier webhooks and cancel all scheduled jobs from zapier
if (credential.app?.slug === "zapier") {
await prisma.apiKey.deleteMany({
where: {
userId: ctx.user.id,
appId: "zapier",
},
});
await prisma.webhook.deleteMany({
where: {
appId: "zapier",
},
});
const bookingsWithScheduledJobs = await prisma.booking.findMany({
where: {
userId: ctx.user.id,
scheduledJobs: {
isEmpty: false,
},
},
});
for (const booking of bookingsWithScheduledJobs) {
cancelScheduledJobs(booking, credential.appId);
}
}
// Validated that credential is user's above
await prisma.credential.delete({
where: {
id: id,
},
});
},
});
export const viewerRouter = createRouter()
.merge("public.", publicViewerRouter)
.merge(loggedInViewerRouter)
.merge("auth.", authRouter)
.merge("bookings.", bookingsRouter)
.merge("eventTypes.", eventTypesRouter)
.merge("availability.", availabilityRouter)
.merge("teams.", viewerTeamsRouter)
.merge("webhook.", webhookRouter)
.merge("apiKeys.", apiKeysRouter)
.merge("slots.", slotsRouter)
.merge("workflows.", workflowsRouter)
// NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved.
// After that there would just one merge call here for all the apps.
.merge("app_routing_forms.", app_RoutingForms)
.merge("eth.", ethRouter);