835 lines
23 KiB
TypeScript
835 lines
23 KiB
TypeScript
|
import { BookingStatus, MembershipRole, Prisma } from "@prisma/client";
|
||
|
import _ from "lodash";
|
||
|
import { JSONObject } from "superjson/dist/types";
|
||
|
import { z } from "zod";
|
||
|
|
||
|
import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername";
|
||
|
|
||
|
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
||
|
import { getCalendarCredentials, getConnectedCalendars } from "@lib/integrations/calendar/CalendarManager";
|
||
|
import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
|
||
|
import jackson from "@lib/jackson";
|
||
|
import {
|
||
|
isSAMLLoginEnabled,
|
||
|
samlTenantID,
|
||
|
samlProductID,
|
||
|
isSAMLAdmin,
|
||
|
hostedCal,
|
||
|
tenantPrefix,
|
||
|
samlTenantProduct,
|
||
|
} from "@lib/saml";
|
||
|
import slugify from "@lib/slugify";
|
||
|
import { Schedule } from "@lib/types/schedule";
|
||
|
|
||
|
import { eventTypesRouter } from "@server/routers/viewer/eventTypes";
|
||
|
import { TRPCError } from "@trpc/server";
|
||
|
|
||
|
import { createProtectedRouter, createRouter } from "../createRouter";
|
||
|
import { resizeBase64Image } from "../lib/resizeBase64Image";
|
||
|
import { viewerTeamsRouter } from "./viewer/teams";
|
||
|
import { webhookRouter } from "./viewer/webhook";
|
||
|
|
||
|
const checkUsername =
|
||
|
process.env.NEXT_PUBLIC_APP_URL === "https://cal.com" ? checkPremiumUsername : checkRegularUsername;
|
||
|
|
||
|
// 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);
|
||
|
},
|
||
|
});
|
||
|
|
||
|
// 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,
|
||
|
avatar: user.avatar,
|
||
|
createdDate: user.createdDate,
|
||
|
completedOnboarding: user.completedOnboarding,
|
||
|
twoFactorEnabled: user.twoFactorEnabled,
|
||
|
identityProvider: user.identityProvider,
|
||
|
brandColor: user.brandColor,
|
||
|
plan: user.plan,
|
||
|
away: user.away,
|
||
|
};
|
||
|
},
|
||
|
})
|
||
|
.mutation("deleteMe", {
|
||
|
async resolve({ ctx }) {
|
||
|
// Remove me from Stripe
|
||
|
|
||
|
// Remove my account
|
||
|
await ctx.prisma.user.delete({
|
||
|
where: {
|
||
|
id: ctx.user.id,
|
||
|
},
|
||
|
});
|
||
|
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>()({
|
||
|
id: true,
|
||
|
title: true,
|
||
|
description: true,
|
||
|
length: true,
|
||
|
schedulingType: true,
|
||
|
slug: true,
|
||
|
hidden: true,
|
||
|
price: true,
|
||
|
currency: true,
|
||
|
position: true,
|
||
|
users: {
|
||
|
select: {
|
||
|
id: true,
|
||
|
username: true,
|
||
|
avatar: true,
|
||
|
name: true,
|
||
|
},
|
||
|
},
|
||
|
});
|
||
|
|
||
|
const user = await prisma.user.findUnique({
|
||
|
where: {
|
||
|
id: ctx.user.id,
|
||
|
},
|
||
|
select: {
|
||
|
id: true,
|
||
|
username: true,
|
||
|
name: true,
|
||
|
startTime: true,
|
||
|
endTime: true,
|
||
|
bufferTime: true,
|
||
|
avatar: 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"];
|
||
|
image: typeof user["avatar"];
|
||
|
};
|
||
|
metadata: {
|
||
|
membershipCount: number;
|
||
|
readOnly: boolean;
|
||
|
};
|
||
|
eventTypes: (typeof user.eventTypes[number] & { $disabled?: boolean })[];
|
||
|
};
|
||
|
|
||
|
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((et, index) => ({
|
||
|
...et,
|
||
|
$disabled: user.plan === "FREE" && index > 0,
|
||
|
}));
|
||
|
|
||
|
eventTypeGroups.push({
|
||
|
teamId: null,
|
||
|
profile: {
|
||
|
slug: user.username,
|
||
|
name: user.name,
|
||
|
image: user.avatar,
|
||
|
},
|
||
|
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,
|
||
|
}))
|
||
|
);
|
||
|
|
||
|
const canAddEvents = user.plan !== "FREE" || eventTypeGroups[0].eventTypes.length < 1;
|
||
|
|
||
|
return {
|
||
|
viewer: {
|
||
|
canAddEvents,
|
||
|
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", "past", "cancelled"]),
|
||
|
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() },
|
||
|
AND: [
|
||
|
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
|
||
|
{ NOT: { status: { equals: 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 } },
|
||
|
],
|
||
|
},
|
||
|
],
|
||
|
};
|
||
|
const bookingListingOrderby: Record<
|
||
|
typeof bookingListingByStatus,
|
||
|
Prisma.BookingOrderByWithAggregationInput
|
||
|
> = {
|
||
|
upcoming: { startTime: "desc" },
|
||
|
past: { startTime: "desc" },
|
||
|
cancelled: { startTime: "desc" },
|
||
|
};
|
||
|
const passedBookingsFilter = bookingListingFilters[bookingListingByStatus];
|
||
|
const orderBy = bookingListingOrderby[bookingListingByStatus];
|
||
|
|
||
|
const bookingsQuery = await prisma.booking.findMany({
|
||
|
where: {
|
||
|
OR: [
|
||
|
{
|
||
|
userId: user.id,
|
||
|
},
|
||
|
{
|
||
|
attendees: {
|
||
|
some: {
|
||
|
email: user.email,
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
],
|
||
|
AND: passedBookingsFilter,
|
||
|
},
|
||
|
select: {
|
||
|
uid: true,
|
||
|
title: true,
|
||
|
description: true,
|
||
|
attendees: true,
|
||
|
confirmed: true,
|
||
|
rejected: true,
|
||
|
id: true,
|
||
|
startTime: true,
|
||
|
endTime: true,
|
||
|
eventType: {
|
||
|
select: {
|
||
|
price: true,
|
||
|
team: {
|
||
|
select: {
|
||
|
name: true,
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
status: true,
|
||
|
paid: true,
|
||
|
},
|
||
|
orderBy,
|
||
|
take: take + 1,
|
||
|
skip,
|
||
|
});
|
||
|
|
||
|
const bookings = bookingsQuery.reverse().map((booking) => {
|
||
|
return {
|
||
|
...booking,
|
||
|
startTime: booking.startTime.toISOString(),
|
||
|
endTime: booking.endTime.toISOString(),
|
||
|
};
|
||
|
});
|
||
|
|
||
|
let nextCursor: typeof skip | null = skip;
|
||
|
if (bookings.length > take) {
|
||
|
bookings.shift();
|
||
|
nextCursor += bookings.length;
|
||
|
} 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);
|
||
|
|
||
|
return {
|
||
|
connectedCalendars,
|
||
|
destinationCalendar: user.destinationCalendar,
|
||
|
};
|
||
|
},
|
||
|
})
|
||
|
.mutation("setDestinationCalendar", {
|
||
|
input: z.object({
|
||
|
integration: z.string(),
|
||
|
externalId: z.string(),
|
||
|
eventTypeId: z.number().optional(),
|
||
|
bookingId: z.number().optional(),
|
||
|
}),
|
||
|
async resolve({ ctx, input }) {
|
||
|
const { user } = ctx;
|
||
|
const { integration, externalId, eventTypeId, bookingId } = input;
|
||
|
const calendarCredentials = getCalendarCredentials(user.credentials, user.id);
|
||
|
const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
|
||
|
const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat();
|
||
|
|
||
|
if (!allCals.find((cal) => cal.externalId === externalId && cal.integration === integration)) {
|
||
|
throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find calendar ${input.externalId}` });
|
||
|
}
|
||
|
|
||
|
let where;
|
||
|
|
||
|
if (eventTypeId) where = { eventTypeId };
|
||
|
else if (bookingId) where = { bookingId };
|
||
|
else where = { userId: user.id };
|
||
|
|
||
|
await ctx.prisma.destinationCalendar.upsert({
|
||
|
where,
|
||
|
update: {
|
||
|
integration,
|
||
|
externalId,
|
||
|
},
|
||
|
create: {
|
||
|
...where,
|
||
|
integration,
|
||
|
externalId,
|
||
|
},
|
||
|
});
|
||
|
},
|
||
|
})
|
||
|
.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) {
|
||
|
return ctx.prisma.credential.update({
|
||
|
where: {
|
||
|
id: web3Credential.id,
|
||
|
},
|
||
|
data: {
|
||
|
key: {
|
||
|
isWeb3Active: !(web3Credential.key as JSONObject).isWeb3Active,
|
||
|
},
|
||
|
},
|
||
|
});
|
||
|
} else {
|
||
|
return ctx.prisma.credential.create({
|
||
|
data: {
|
||
|
type: "metamask_web3",
|
||
|
key: {
|
||
|
isWeb3Active: true,
|
||
|
} as unknown as Prisma.InputJsonObject,
|
||
|
userId: user.id,
|
||
|
},
|
||
|
});
|
||
|
}
|
||
|
},
|
||
|
})
|
||
|
.query("integrations", {
|
||
|
async resolve({ ctx }) {
|
||
|
const { user } = ctx;
|
||
|
const { credentials } = user;
|
||
|
|
||
|
function countActive(items: { credentialIds: unknown[] }[]) {
|
||
|
return items.reduce((acc, item) => acc + item.credentialIds.length, 0);
|
||
|
}
|
||
|
const integrations = ALL_INTEGRATIONS.map((integration) => ({
|
||
|
...integration,
|
||
|
credentialIds: credentials
|
||
|
.filter((credential) => credential.type === integration.type)
|
||
|
.map((credential) => credential.id),
|
||
|
}));
|
||
|
// `flatMap()` these work like `.filter()` but infers the types correctly
|
||
|
const conferencing = integrations.flatMap((item) => (item.variant === "conferencing" ? [item] : []));
|
||
|
const payment = integrations.flatMap((item) => (item.variant === "payment" ? [item] : []));
|
||
|
const calendar = integrations.flatMap((item) => (item.variant === "calendar" ? [item] : []));
|
||
|
|
||
|
return {
|
||
|
conferencing: {
|
||
|
items: conferencing,
|
||
|
numActive: countActive(conferencing),
|
||
|
},
|
||
|
calendar: {
|
||
|
items: calendar,
|
||
|
numActive: countActive(calendar),
|
||
|
},
|
||
|
payment: {
|
||
|
items: payment,
|
||
|
numActive: countActive(payment),
|
||
|
},
|
||
|
};
|
||
|
},
|
||
|
})
|
||
|
.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,
|
||
|
};
|
||
|
},
|
||
|
})
|
||
|
.query("availability", {
|
||
|
async resolve({ ctx }) {
|
||
|
const { prisma, user } = ctx;
|
||
|
const availabilityQuery = await prisma.availability.findMany({
|
||
|
where: {
|
||
|
userId: user.id,
|
||
|
},
|
||
|
});
|
||
|
const schedule = availabilityQuery.reduce(
|
||
|
(schedule: Schedule, availability) => {
|
||
|
availability.days.forEach((day) => {
|
||
|
schedule[day].push({
|
||
|
start: new Date(
|
||
|
Date.UTC(
|
||
|
new Date().getUTCFullYear(),
|
||
|
new Date().getUTCMonth(),
|
||
|
new Date().getUTCDate(),
|
||
|
availability.startTime.getUTCHours(),
|
||
|
availability.startTime.getUTCMinutes()
|
||
|
)
|
||
|
),
|
||
|
end: new Date(
|
||
|
Date.UTC(
|
||
|
new Date().getUTCFullYear(),
|
||
|
new Date().getUTCMonth(),
|
||
|
new Date().getUTCDate(),
|
||
|
availability.endTime.getUTCHours(),
|
||
|
availability.endTime.getUTCMinutes()
|
||
|
)
|
||
|
),
|
||
|
});
|
||
|
});
|
||
|
return schedule;
|
||
|
},
|
||
|
Array.from([...Array(7)]).map(() => [])
|
||
|
);
|
||
|
return {
|
||
|
schedule,
|
||
|
timeZone: user.timeZone,
|
||
|
};
|
||
|
},
|
||
|
})
|
||
|
.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(),
|
||
|
brandColor: z.string().optional(),
|
||
|
theme: z.string().optional().nullable(),
|
||
|
completedOnboarding: z.boolean().optional(),
|
||
|
locale: z.string().optional(),
|
||
|
}),
|
||
|
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);
|
||
|
if (!response.available) {
|
||
|
throw new TRPCError({ code: "BAD_REQUEST", message: response.message });
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
if (input.avatar) {
|
||
|
data.avatar = await resizeBase64Image(input.avatar);
|
||
|
}
|
||
|
|
||
|
await prisma.user.update({
|
||
|
where: {
|
||
|
id: user.id,
|
||
|
},
|
||
|
data,
|
||
|
});
|
||
|
},
|
||
|
})
|
||
|
.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({ input }) {
|
||
|
const { encodedRawMetadata, teamId } = input;
|
||
|
|
||
|
const { apiController } = await jackson();
|
||
|
|
||
|
try {
|
||
|
return await apiController.config({
|
||
|
encodedRawMetadata,
|
||
|
defaultRedirectUrl: `${process.env.BASE_URL}/api/auth/saml/idp`,
|
||
|
redirectUrl: JSON.stringify([`${process.env.BASE_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({ input }) {
|
||
|
const { teamId } = input;
|
||
|
|
||
|
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" });
|
||
|
}
|
||
|
},
|
||
|
});
|
||
|
|
||
|
export const viewerRouter = createRouter()
|
||
|
.merge(publicViewerRouter)
|
||
|
.merge(loggedInViewerRouter)
|
||
|
.merge("eventTypes.", eventTypesRouter)
|
||
|
.merge("teams.", viewerTeamsRouter)
|
||
|
.merge("webhook.", webhookRouter);
|