2022-07-22 17:27:06 +00:00
|
|
|
import { AppCategories, BookingStatus, MembershipRole, Prisma } from "@prisma/client";
|
2021-11-15 12:25:49 +00:00
|
|
|
import _ from "lodash";
|
2022-02-01 21:48:40 +00:00
|
|
|
import { JSONObject } from "superjson/dist/types";
|
2021-09-28 08:57:30 +00:00
|
|
|
import { z } from "zod";
|
2021-09-27 14:47:55 +00:00
|
|
|
|
2022-07-14 12:40:53 +00:00
|
|
|
import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router";
|
2022-07-28 19:58:26 +00:00
|
|
|
import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server";
|
2022-05-27 23:27:41 +00:00
|
|
|
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
|
2022-08-15 20:18:41 +00:00
|
|
|
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
2022-03-23 22:00:30 +00:00
|
|
|
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
2022-06-28 20:40:58 +00:00
|
|
|
import dayjs from "@calcom/dayjs";
|
2022-07-22 17:27:06 +00:00
|
|
|
import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails";
|
|
|
|
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
2022-08-13 11:04:57 +00:00
|
|
|
import { CAL_URL } from "@calcom/lib/constants";
|
2022-07-22 17:27:06 +00:00
|
|
|
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
|
|
|
import jackson from "@calcom/lib/jackson";
|
2022-01-13 20:05:23 +00:00
|
|
|
import {
|
2022-05-27 23:27:41 +00:00
|
|
|
hostedCal,
|
|
|
|
isSAMLAdmin,
|
2022-01-13 20:05:23 +00:00
|
|
|
isSAMLLoginEnabled,
|
|
|
|
samlProductID,
|
2022-05-27 23:27:41 +00:00
|
|
|
samlTenantID,
|
2022-01-13 20:05:23 +00:00
|
|
|
samlTenantProduct,
|
2022-05-27 23:27:41 +00:00
|
|
|
tenantPrefix,
|
2022-07-22 17:27:06 +00:00
|
|
|
} 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 prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
|
|
|
|
import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image";
|
2021-09-27 14:47:55 +00:00
|
|
|
|
2021-10-14 19:22:01 +00:00
|
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
|
2021-10-14 10:57:49 +00:00
|
|
|
import { createProtectedRouter, createRouter } from "../createRouter";
|
2022-07-22 17:27:06 +00:00
|
|
|
import { apiKeysRouter } from "./viewer/apiKeys";
|
|
|
|
import { availabilityRouter } from "./viewer/availability";
|
|
|
|
import { bookingsRouter } from "./viewer/bookings";
|
|
|
|
import { eventTypesRouter } from "./viewer/eventTypes";
|
|
|
|
import { slotsRouter } from "./viewer/slots";
|
2021-12-09 23:51:30 +00:00
|
|
|
import { viewerTeamsRouter } from "./viewer/teams";
|
2021-10-25 16:15:52 +00:00
|
|
|
import { webhookRouter } from "./viewer/webhook";
|
2022-07-22 17:27:06 +00:00
|
|
|
import { workflowsRouter } from "./viewer/workflows";
|
2021-09-28 08:57:30 +00:00
|
|
|
|
2021-10-14 10:57:49 +00:00
|
|
|
// 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,
|
|
|
|
};
|
|
|
|
},
|
2022-01-13 20:05:23 +00:00
|
|
|
})
|
|
|
|
.mutation("samlTenantProduct", {
|
|
|
|
input: z.object({
|
|
|
|
email: z.string().email(),
|
|
|
|
}),
|
|
|
|
async resolve({ input, ctx }) {
|
|
|
|
const { prisma } = ctx;
|
|
|
|
const { email } = input;
|
|
|
|
|
|
|
|
return await samlTenantProduct(prisma, email);
|
|
|
|
},
|
2022-06-19 15:02:00 +00:00
|
|
|
})
|
|
|
|
.merge("slots.", slotsRouter);
|
2021-10-14 10:57:49 +00:00
|
|
|
|
2021-09-28 08:57:30 +00:00
|
|
|
// routes only available to authenticated users
|
2021-10-14 10:57:49 +00:00
|
|
|
const loggedInViewerRouter = createProtectedRouter()
|
2021-09-27 14:47:55 +00:00
|
|
|
.query("me", {
|
2022-01-21 21:35:31 +00:00
|
|
|
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,
|
2022-02-28 16:24:47 +00:00
|
|
|
timeFormat: user.timeFormat,
|
2022-07-07 01:08:38 +00:00
|
|
|
timeZone: user.timeZone,
|
2022-01-21 21:35:31 +00:00
|
|
|
avatar: user.avatar,
|
|
|
|
createdDate: user.createdDate,
|
2022-03-03 19:29:19 +00:00
|
|
|
trialEndsAt: user.trialEndsAt,
|
2022-01-21 21:35:31 +00:00
|
|
|
completedOnboarding: user.completedOnboarding,
|
|
|
|
twoFactorEnabled: user.twoFactorEnabled,
|
2022-05-25 15:21:18 +00:00
|
|
|
disableImpersonation: user.disableImpersonation,
|
2022-01-21 21:35:31 +00:00
|
|
|
identityProvider: user.identityProvider,
|
|
|
|
brandColor: user.brandColor,
|
2022-03-05 15:37:46 +00:00
|
|
|
darkBrandColor: user.darkBrandColor,
|
2022-01-21 21:35:31 +00:00
|
|
|
plan: user.plan,
|
|
|
|
away: user.away,
|
2021-10-13 11:35:25 +00:00
|
|
|
};
|
2021-09-27 14:47:55 +00:00
|
|
|
},
|
|
|
|
})
|
2022-01-14 13:49:15 +00:00
|
|
|
.mutation("deleteMe", {
|
|
|
|
async resolve({ ctx }) {
|
|
|
|
// Remove me from Stripe
|
|
|
|
|
|
|
|
// Remove my account
|
|
|
|
await ctx.prisma.user.delete({
|
|
|
|
where: {
|
|
|
|
id: ctx.user.id,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
return;
|
|
|
|
},
|
|
|
|
})
|
2022-01-11 10:32:40 +00:00
|
|
|
.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,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
},
|
|
|
|
})
|
2021-10-15 19:07:00 +00:00
|
|
|
.query("eventTypes", {
|
|
|
|
async resolve({ ctx }) {
|
|
|
|
const { prisma } = ctx;
|
|
|
|
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
2021-11-15 12:25:49 +00:00
|
|
|
position: true,
|
2022-04-05 08:05:40 +00:00
|
|
|
successRedirectUrl: true,
|
2022-04-28 15:44:26 +00:00
|
|
|
hashedLink: true,
|
2022-08-03 09:03:01 +00:00
|
|
|
destinationCalendar: true,
|
|
|
|
team: true,
|
2021-10-15 19:07:00 +00:00
|
|
|
users: {
|
|
|
|
select: {
|
|
|
|
id: true,
|
2022-02-10 01:56:02 +00:00
|
|
|
username: true,
|
2021-10-15 19:07:00 +00:00
|
|
|
name: true,
|
|
|
|
},
|
|
|
|
},
|
2022-06-06 16:54:47 +00:00
|
|
|
...baseEventTypeSelect,
|
2021-10-15 19:07:00 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
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,
|
2021-11-15 12:25:49 +00:00
|
|
|
orderBy: [
|
|
|
|
{
|
|
|
|
position: "desc",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: "asc",
|
|
|
|
},
|
|
|
|
],
|
2021-10-15 19:07:00 +00:00
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
eventTypes: {
|
|
|
|
where: {
|
|
|
|
team: null,
|
|
|
|
},
|
|
|
|
select: eventTypeSelect,
|
2021-11-15 12:25:49 +00:00
|
|
|
orderBy: [
|
|
|
|
{
|
|
|
|
position: "desc",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: "asc",
|
|
|
|
},
|
|
|
|
],
|
2021-10-15 19:07:00 +00:00
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2021-10-18 07:02:25 +00:00
|
|
|
if (!user) {
|
|
|
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
|
|
|
}
|
2021-10-15 19:07:00 +00:00
|
|
|
|
|
|
|
// backwards compatibility, TMP:
|
|
|
|
const typesRaw = await prisma.eventType.findMany({
|
|
|
|
where: {
|
|
|
|
userId: ctx.user.id,
|
|
|
|
},
|
|
|
|
select: eventTypeSelect,
|
2021-11-15 12:25:49 +00:00
|
|
|
orderBy: [
|
|
|
|
{
|
|
|
|
position: "desc",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: "asc",
|
|
|
|
},
|
|
|
|
],
|
2021-10-15 19:07:00 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
type EventTypeGroup = {
|
|
|
|
teamId?: number | null;
|
|
|
|
profile: {
|
|
|
|
slug: typeof user["username"];
|
|
|
|
name: typeof user["name"];
|
|
|
|
};
|
|
|
|
metadata: {
|
|
|
|
membershipCount: number;
|
|
|
|
readOnly: boolean;
|
|
|
|
};
|
|
|
|
eventTypes: (typeof user.eventTypes[number] & { $disabled?: boolean })[];
|
|
|
|
};
|
|
|
|
|
|
|
|
let eventTypeGroups: EventTypeGroup[] = [];
|
2022-06-13 13:25:05 +00:00
|
|
|
const eventTypesHashMap = user.eventTypes.concat(typesRaw).reduce((hashMap, newItem, currentIndex) => {
|
|
|
|
const oldItem = hashMap[newItem.id] || {
|
|
|
|
$disabled: user.plan === "FREE" && currentIndex > 0,
|
|
|
|
};
|
2021-10-15 19:07:00 +00:00
|
|
|
hashMap[newItem.id] = { ...oldItem, ...newItem };
|
|
|
|
return hashMap;
|
|
|
|
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
|
2022-06-13 13:25:05 +00:00
|
|
|
const mergedEventTypes = Object.values(eventTypesHashMap).map((eventType) => eventType);
|
2021-10-15 19:07:00 +00:00
|
|
|
|
|
|
|
eventTypeGroups.push({
|
|
|
|
teamId: null,
|
|
|
|
profile: {
|
|
|
|
slug: user.username,
|
|
|
|
name: user.name,
|
|
|
|
},
|
2021-11-15 12:25:49 +00:00
|
|
|
eventTypes: _.orderBy(mergedEventTypes, ["position", "id"], ["desc", "asc"]),
|
2021-10-15 19:07:00 +00:00
|
|
|
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,
|
2021-12-17 00:16:59 +00:00
|
|
|
readOnly: membership.role === MembershipRole.MEMBER,
|
2021-10-15 19:07:00 +00:00
|
|
|
},
|
|
|
|
eventTypes: membership.team.eventTypes,
|
|
|
|
}))
|
|
|
|
);
|
|
|
|
|
|
|
|
const canAddEvents = user.plan !== "FREE" || eventTypeGroups[0].eventTypes.length < 1;
|
|
|
|
|
|
|
|
return {
|
2021-11-24 10:42:55 +00:00
|
|
|
viewer: {
|
|
|
|
canAddEvents,
|
|
|
|
plan: user.plan,
|
|
|
|
},
|
2021-10-15 19:07:00 +00:00
|
|
|
// 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,
|
|
|
|
})),
|
|
|
|
};
|
|
|
|
},
|
|
|
|
})
|
2021-09-27 14:47:55 +00:00
|
|
|
.query("bookings", {
|
2021-09-30 10:46:39 +00:00
|
|
|
input: z.object({
|
2022-05-05 21:16:25 +00:00
|
|
|
status: z.enum(["upcoming", "recurring", "past", "cancelled"]),
|
2021-10-28 15:02:22 +00:00
|
|
|
limit: z.number().min(1).max(100).nullish(),
|
|
|
|
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
|
2021-09-30 10:46:39 +00:00
|
|
|
}),
|
|
|
|
async resolve({ ctx, input }) {
|
2021-10-28 15:02:22 +00:00
|
|
|
// 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;
|
2021-09-27 14:47:55 +00:00
|
|
|
const { prisma, user } = ctx;
|
2021-10-02 13:29:26 +00:00
|
|
|
const bookingListingByStatus = input.status;
|
2022-07-22 17:27:06 +00:00
|
|
|
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 } },
|
|
|
|
],
|
|
|
|
},
|
2021-09-30 10:46:39 +00:00
|
|
|
};
|
2022-01-06 17:28:31 +00:00
|
|
|
const bookingListingOrderby: Record<
|
|
|
|
typeof bookingListingByStatus,
|
|
|
|
Prisma.BookingOrderByWithAggregationInput
|
|
|
|
> = {
|
2022-02-20 01:00:35 +00:00
|
|
|
upcoming: { startTime: "asc" },
|
2022-05-05 21:16:25 +00:00
|
|
|
recurring: { startTime: "asc" },
|
2022-04-16 11:51:44 +00:00
|
|
|
past: { startTime: "desc" },
|
|
|
|
cancelled: { startTime: "desc" },
|
2021-09-30 10:46:39 +00:00
|
|
|
};
|
|
|
|
const passedBookingsFilter = bookingListingFilters[bookingListingByStatus];
|
|
|
|
const orderBy = bookingListingOrderby[bookingListingByStatus];
|
2021-09-27 14:47:55 +00:00
|
|
|
const bookingsQuery = await prisma.booking.findMany({
|
|
|
|
where: {
|
|
|
|
OR: [
|
|
|
|
{
|
|
|
|
userId: user.id,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
attendees: {
|
|
|
|
some: {
|
|
|
|
email: user.email,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
],
|
2022-07-22 17:27:06 +00:00
|
|
|
AND: [passedBookingsFilter],
|
2021-09-27 14:47:55 +00:00
|
|
|
},
|
|
|
|
select: {
|
2022-05-18 21:05:49 +00:00
|
|
|
...bookingMinimalSelect,
|
2021-09-27 14:47:55 +00:00
|
|
|
uid: true,
|
2022-05-05 21:16:25 +00:00
|
|
|
recurringEventId: true,
|
2022-05-18 21:05:49 +00:00
|
|
|
location: true,
|
2021-09-27 14:47:55 +00:00
|
|
|
eventType: {
|
|
|
|
select: {
|
2022-05-18 21:05:49 +00:00
|
|
|
slug: true,
|
|
|
|
id: true,
|
|
|
|
eventName: true,
|
2021-12-17 16:58:23 +00:00
|
|
|
price: true,
|
2022-05-05 21:16:25 +00:00
|
|
|
recurringEvent: true,
|
2021-09-27 14:47:55 +00:00
|
|
|
team: {
|
|
|
|
select: {
|
|
|
|
name: true,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
status: true,
|
2021-12-17 16:58:23 +00:00
|
|
|
paid: true,
|
2022-03-04 10:04:05 +00:00
|
|
|
user: {
|
|
|
|
select: {
|
|
|
|
id: true,
|
|
|
|
},
|
|
|
|
},
|
2022-04-14 21:25:24 +00:00
|
|
|
rescheduled: true,
|
2021-09-27 14:47:55 +00:00
|
|
|
},
|
2021-09-30 10:46:39 +00:00
|
|
|
orderBy,
|
2021-10-28 15:02:22 +00:00
|
|
|
take: take + 1,
|
|
|
|
skip,
|
2021-09-27 14:47:55 +00:00
|
|
|
});
|
|
|
|
|
2022-05-09 16:04:42 +00:00
|
|
|
let bookings = bookingsQuery.map((booking) => {
|
2021-09-27 14:47:55 +00:00
|
|
|
return {
|
|
|
|
...booking,
|
2022-05-05 21:16:25 +00:00
|
|
|
eventType: {
|
|
|
|
...booking.eventType,
|
2022-06-10 00:32:34 +00:00
|
|
|
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
|
2022-05-05 21:16:25 +00:00
|
|
|
},
|
2021-09-27 14:47:55 +00:00
|
|
|
startTime: booking.startTime.toISOString(),
|
|
|
|
endTime: booking.endTime.toISOString(),
|
|
|
|
};
|
|
|
|
});
|
2022-05-11 10:35:43 +00:00
|
|
|
const bookingsFetched = bookings.length;
|
2022-05-09 16:04:42 +00:00
|
|
|
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;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-10-28 15:02:22 +00:00
|
|
|
let nextCursor: typeof skip | null = skip;
|
2022-05-11 10:35:43 +00:00
|
|
|
if (bookingsFetched > take) {
|
2022-06-01 04:03:14 +00:00
|
|
|
nextCursor += bookingsFetched;
|
2021-10-28 15:02:22 +00:00
|
|
|
} else {
|
|
|
|
nextCursor = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
bookings,
|
|
|
|
nextCursor,
|
|
|
|
};
|
2021-09-27 14:47:55 +00:00
|
|
|
},
|
2021-09-28 08:57:30 +00:00
|
|
|
})
|
2021-10-30 15:54:21 +00:00
|
|
|
.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);
|
|
|
|
|
2022-03-31 17:26:26 +00:00
|
|
|
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
|
|
|
|
*/
|
2022-07-18 15:37:47 +00:00
|
|
|
const { integration = "", externalId = "", credentialId } = connectedCalendars[0].primary ?? {};
|
2022-03-31 17:26:26 +00:00
|
|
|
user.destinationCalendar = await ctx.prisma.destinationCalendar.create({
|
|
|
|
data: {
|
|
|
|
userId: user.id,
|
|
|
|
integration,
|
|
|
|
externalId,
|
2022-07-18 15:37:47 +00:00
|
|
|
credentialId,
|
2022-03-31 17:26:26 +00:00
|
|
|
},
|
|
|
|
});
|
|
|
|
} 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,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-12-09 15:51:37 +00:00
|
|
|
return {
|
|
|
|
connectedCalendars,
|
|
|
|
destinationCalendar: user.destinationCalendar,
|
|
|
|
};
|
|
|
|
},
|
|
|
|
})
|
2022-01-21 21:35:31 +00:00
|
|
|
.mutation("setDestinationCalendar", {
|
2021-12-09 15:51:37 +00:00
|
|
|
input: z.object({
|
|
|
|
integration: z.string(),
|
|
|
|
externalId: z.string(),
|
2022-01-21 21:35:31 +00:00
|
|
|
eventTypeId: z.number().optional(),
|
|
|
|
bookingId: z.number().optional(),
|
2021-12-09 15:51:37 +00:00
|
|
|
}),
|
|
|
|
async resolve({ ctx, input }) {
|
|
|
|
const { user } = ctx;
|
2022-07-18 15:37:47 +00:00
|
|
|
const { integration, externalId, eventTypeId } = input;
|
2021-12-09 15:51:37 +00:00
|
|
|
const calendarCredentials = getCalendarCredentials(user.credentials, user.id);
|
|
|
|
const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
|
|
|
|
const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat();
|
|
|
|
|
2022-07-01 20:55:27 +00:00
|
|
|
const credentialId = allCals.find(
|
|
|
|
(cal) => cal.externalId === externalId && cal.integration === integration && cal.readOnly === false
|
|
|
|
)?.credentialId;
|
|
|
|
|
|
|
|
if (!credentialId) {
|
2021-12-09 15:51:37 +00:00
|
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find calendar ${input.externalId}` });
|
|
|
|
}
|
2022-01-21 21:35:31 +00:00
|
|
|
|
|
|
|
let where;
|
|
|
|
|
|
|
|
if (eventTypeId) where = { eventTypeId };
|
|
|
|
else where = { userId: user.id };
|
|
|
|
|
2021-12-09 15:51:37 +00:00
|
|
|
await ctx.prisma.destinationCalendar.upsert({
|
2022-01-21 21:35:31 +00:00
|
|
|
where,
|
2021-12-09 15:51:37 +00:00
|
|
|
update: {
|
2022-01-21 21:35:31 +00:00
|
|
|
integration,
|
|
|
|
externalId,
|
2022-07-01 20:55:27 +00:00
|
|
|
credentialId,
|
2021-12-09 15:51:37 +00:00
|
|
|
},
|
|
|
|
create: {
|
2022-01-21 21:35:31 +00:00
|
|
|
...where,
|
|
|
|
integration,
|
|
|
|
externalId,
|
2022-07-01 20:55:27 +00:00
|
|
|
credentialId,
|
2021-12-09 15:51:37 +00:00
|
|
|
},
|
|
|
|
});
|
2021-10-30 15:54:21 +00:00
|
|
|
},
|
|
|
|
})
|
2022-02-01 21:48:40 +00:00
|
|
|
.mutation("enableOrDisableWeb3", {
|
|
|
|
input: z.object({}),
|
|
|
|
async resolve({ ctx }) {
|
|
|
|
const { user } = ctx;
|
|
|
|
const where = { userId: user.id, type: "metamask_web3" };
|
|
|
|
|
|
|
|
const web3Credential = await ctx.prisma.credential.findFirst({
|
|
|
|
where,
|
|
|
|
select: {
|
|
|
|
id: true,
|
|
|
|
key: true,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
if (web3Credential) {
|
2022-06-06 18:24:37 +00:00
|
|
|
const deleted = await ctx.prisma.credential.delete({
|
2022-02-01 21:48:40 +00:00
|
|
|
where: {
|
|
|
|
id: web3Credential.id,
|
|
|
|
},
|
|
|
|
});
|
2022-06-06 18:24:37 +00:00
|
|
|
return {
|
|
|
|
...deleted,
|
|
|
|
key: {
|
|
|
|
...(deleted.key as JSONObject),
|
|
|
|
isWeb3Active: false,
|
|
|
|
},
|
|
|
|
};
|
2022-02-01 21:48:40 +00:00
|
|
|
} else {
|
|
|
|
return ctx.prisma.credential.create({
|
|
|
|
data: {
|
|
|
|
type: "metamask_web3",
|
|
|
|
key: {
|
|
|
|
isWeb3Active: true,
|
|
|
|
} as unknown as Prisma.InputJsonObject,
|
|
|
|
userId: user.id,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
})
|
2021-10-12 09:35:44 +00:00
|
|
|
.query("integrations", {
|
2022-06-01 17:24:41 +00:00
|
|
|
input: z.object({
|
|
|
|
variant: z.string().optional(),
|
|
|
|
onlyInstalled: z.boolean().optional(),
|
|
|
|
}),
|
|
|
|
async resolve({ ctx, input }) {
|
2021-10-12 09:35:44 +00:00
|
|
|
const { user } = ctx;
|
2022-06-01 17:24:41 +00:00
|
|
|
const { variant, onlyInstalled } = input;
|
2021-10-12 09:35:44 +00:00
|
|
|
const { credentials } = user;
|
|
|
|
|
2022-06-01 17:24:41 +00:00
|
|
|
let apps = getApps(credentials).map(
|
2022-03-23 22:00:30 +00:00
|
|
|
({ credentials: _, credential: _1 /* don't leak to frontend */, ...app }) => ({
|
|
|
|
...app,
|
|
|
|
credentialIds: credentials.filter((c) => c.type === app.type).map((c) => c.id),
|
|
|
|
})
|
|
|
|
);
|
2022-06-01 17:24:41 +00:00
|
|
|
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] : []));
|
|
|
|
}
|
2021-10-12 09:35:44 +00:00
|
|
|
return {
|
2022-06-01 17:24:41 +00:00
|
|
|
items: apps,
|
2021-10-12 09:35:44 +00:00
|
|
|
};
|
|
|
|
},
|
|
|
|
})
|
2022-07-14 12:40:53 +00:00
|
|
|
.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;
|
|
|
|
},
|
|
|
|
})
|
2022-02-01 21:48:40 +00:00
|
|
|
.query("web3Integration", {
|
|
|
|
async resolve({ ctx }) {
|
|
|
|
const { user } = ctx;
|
|
|
|
|
|
|
|
const where = { userId: user.id, type: "metamask_web3" };
|
|
|
|
|
|
|
|
const web3Credential = await ctx.prisma.credential.findFirst({
|
|
|
|
where,
|
|
|
|
select: {
|
|
|
|
key: true,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
return {
|
|
|
|
isWeb3Active: web3Credential ? (web3Credential.key as JSONObject).isWeb3Active : false,
|
|
|
|
};
|
|
|
|
},
|
|
|
|
})
|
2021-09-28 08:57:30 +00:00
|
|
|
.mutation("updateProfile", {
|
|
|
|
input: z.object({
|
|
|
|
username: z.string().optional(),
|
|
|
|
name: z.string().optional(),
|
2022-02-08 16:13:42 +00:00
|
|
|
email: z.string().optional(),
|
2021-09-28 08:57:30 +00:00
|
|
|
bio: z.string().optional(),
|
|
|
|
avatar: z.string().optional(),
|
|
|
|
timeZone: z.string().optional(),
|
|
|
|
weekStart: z.string().optional(),
|
|
|
|
hideBranding: z.boolean().optional(),
|
2022-04-06 17:20:30 +00:00
|
|
|
allowDynamicBooking: z.boolean().optional(),
|
2021-11-16 08:51:46 +00:00
|
|
|
brandColor: z.string().optional(),
|
2022-03-05 15:37:46 +00:00
|
|
|
darkBrandColor: z.string().optional(),
|
2021-10-02 20:16:51 +00:00
|
|
|
theme: z.string().optional().nullable(),
|
2021-09-28 08:57:30 +00:00
|
|
|
completedOnboarding: z.boolean().optional(),
|
|
|
|
locale: z.string().optional(),
|
2022-02-28 16:24:47 +00:00
|
|
|
timeFormat: z.number().optional(),
|
2022-05-25 15:21:18 +00:00
|
|
|
disableImpersonation: z.boolean().optional(),
|
2021-09-28 08:57:30 +00:00
|
|
|
}),
|
|
|
|
async resolve({ input, ctx }) {
|
|
|
|
const { user, prisma } = ctx;
|
|
|
|
const data: Prisma.UserUpdateInput = {
|
|
|
|
...input,
|
|
|
|
};
|
|
|
|
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);
|
2022-07-06 19:31:07 +00:00
|
|
|
if (!response.available) {
|
2021-09-28 08:57:30 +00:00
|
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: response.message });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2021-09-30 20:37:29 +00:00
|
|
|
if (input.avatar) {
|
|
|
|
data.avatar = await resizeBase64Image(input.avatar);
|
|
|
|
}
|
2021-09-28 08:57:30 +00:00
|
|
|
|
2022-07-07 21:24:42 +00:00
|
|
|
const updatedUser = await prisma.user.update({
|
2021-09-28 08:57:30 +00:00
|
|
|
where: {
|
|
|
|
id: user.id,
|
|
|
|
},
|
|
|
|
data,
|
2022-07-07 21:24:42 +00:00
|
|
|
select: {
|
|
|
|
id: true,
|
|
|
|
username: true,
|
|
|
|
email: true,
|
|
|
|
metadata: true,
|
|
|
|
},
|
2021-09-28 08:57:30 +00:00
|
|
|
});
|
2022-07-07 21:24:42 +00:00
|
|
|
|
|
|
|
// 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,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
2021-09-28 08:57:30 +00:00
|
|
|
},
|
2021-11-15 12:25:49 +00:00
|
|
|
})
|
|
|
|
.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,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
2022-01-13 20:05:23 +00:00
|
|
|
})
|
|
|
|
.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({
|
2022-02-02 18:33:27 +00:00
|
|
|
encodedRawMetadata: z.string(),
|
2022-01-13 20:05:23 +00:00
|
|
|
teamId: z.union([z.number(), z.null(), z.undefined()]),
|
|
|
|
}),
|
2022-06-03 23:13:50 +00:00
|
|
|
async resolve({ ctx, input }) {
|
2022-02-02 18:33:27 +00:00
|
|
|
const { encodedRawMetadata, teamId } = input;
|
2022-06-03 23:13:50 +00:00
|
|
|
if (teamId && !(await isTeamOwner(ctx.user?.id, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
2022-01-13 20:05:23 +00:00
|
|
|
const { apiController } = await jackson();
|
|
|
|
|
|
|
|
try {
|
|
|
|
return await apiController.config({
|
2022-02-02 18:33:27 +00:00
|
|
|
encodedRawMetadata,
|
2022-03-26 00:39:38 +00:00
|
|
|
defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/auth/saml/idp`,
|
|
|
|
redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]),
|
2022-01-13 20:05:23 +00:00
|
|
|
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()]),
|
|
|
|
}),
|
2022-06-03 23:13:50 +00:00
|
|
|
async resolve({ ctx, input }) {
|
2022-01-13 20:05:23 +00:00
|
|
|
const { teamId } = input;
|
2022-06-03 23:13:50 +00:00
|
|
|
if (teamId && !(await isTeamOwner(ctx.user?.id, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
2022-01-13 20:05:23 +00:00
|
|
|
|
|
|
|
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" });
|
|
|
|
}
|
|
|
|
},
|
2022-05-24 13:29:39 +00:00
|
|
|
})
|
|
|
|
.mutation("submitFeedback", {
|
|
|
|
input: z.object({
|
|
|
|
rating: z.string(),
|
|
|
|
comment: z.string(),
|
|
|
|
}),
|
|
|
|
async resolve({ input, ctx }) {
|
|
|
|
const { rating, comment } = input;
|
|
|
|
|
|
|
|
const feedback = {
|
2022-06-16 16:40:14 +00:00
|
|
|
username: ctx.user.username || "Nameless",
|
2022-06-07 14:39:47 +00:00
|
|
|
email: ctx.user.email || "No email address",
|
2022-05-24 13:29:39 +00:00
|
|
|
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);
|
|
|
|
},
|
2022-05-27 23:27:41 +00:00
|
|
|
})
|
|
|
|
.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;
|
|
|
|
},
|
2022-06-20 17:52:50 +00:00
|
|
|
})
|
|
|
|
.mutation("deleteCredential", {
|
|
|
|
input: z.object({
|
|
|
|
id: z.number(),
|
2022-07-15 20:28:24 +00:00
|
|
|
externalId: z.string().optional(),
|
2022-06-20 17:52:50 +00:00
|
|
|
}),
|
|
|
|
async resolve({ input, ctx }) {
|
2022-07-15 20:28:24 +00:00
|
|
|
const { id, externalId } = input;
|
2022-06-20 17:52:50 +00:00
|
|
|
|
|
|
|
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: "integrations:daily" };
|
|
|
|
}
|
|
|
|
return location;
|
|
|
|
});
|
|
|
|
|
|
|
|
await prisma.eventType.update({
|
|
|
|
where: {
|
|
|
|
id: eventType.id,
|
|
|
|
},
|
|
|
|
data: {
|
|
|
|
locations: updatedLocations,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-07-15 20:28:24 +00:00
|
|
|
// If it's a calendar, remove the destination calendar from the event type
|
2022-06-20 17:52:50 +00:00
|
|
|
if (credential.app?.categories.includes(AppCategories.calendar)) {
|
|
|
|
if (eventType.destinationCalendar?.integration === credential.type) {
|
2022-07-15 20:28:24 +00:00
|
|
|
const destinationCalendar = await prisma.destinationCalendar.findFirst({
|
2022-06-20 17:52:50 +00:00
|
|
|
where: {
|
2022-07-15 20:28:24 +00:00
|
|
|
id: eventType.destinationCalendar?.id,
|
2022-06-20 17:52:50 +00:00
|
|
|
},
|
|
|
|
});
|
2022-07-15 20:28:24 +00:00
|
|
|
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,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
2022-06-20 17:52:50 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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",
|
|
|
|
});
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-15 20:18:41 +00:00
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-06-20 17:52:50 +00:00
|
|
|
// Validated that credential is user's above
|
|
|
|
await prisma.credential.delete({
|
|
|
|
where: {
|
|
|
|
id: id,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
},
|
2021-09-27 14:47:55 +00:00
|
|
|
});
|
2021-10-14 10:57:49 +00:00
|
|
|
|
2021-10-25 16:15:52 +00:00
|
|
|
export const viewerRouter = createRouter()
|
2022-06-19 15:02:00 +00:00
|
|
|
.merge("public.", publicViewerRouter)
|
2021-10-25 16:15:52 +00:00
|
|
|
.merge(loggedInViewerRouter)
|
2022-05-27 23:27:41 +00:00
|
|
|
.merge("bookings.", bookingsRouter)
|
2022-01-21 21:35:31 +00:00
|
|
|
.merge("eventTypes.", eventTypesRouter)
|
2022-03-17 16:48:23 +00:00
|
|
|
.merge("availability.", availabilityRouter)
|
2021-12-09 23:51:30 +00:00
|
|
|
.merge("teams.", viewerTeamsRouter)
|
2022-04-16 02:58:34 +00:00
|
|
|
.merge("webhook.", webhookRouter)
|
2022-07-14 12:40:53 +00:00
|
|
|
.merge("apiKeys.", apiKeysRouter)
|
|
|
|
.merge("slots.", slotsRouter)
|
2022-07-14 00:10:45 +00:00
|
|
|
.merge("workflows.", workflowsRouter)
|
2022-07-14 12:40:53 +00:00
|
|
|
|
|
|
|
// 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);
|