diff --git a/.vscode/settings.json b/.vscode/settings.json index 919ae19b36..4c07cd934a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,6 @@ }, "typescript.preferences.importModuleSpecifier": "non-relative", "spellright.language": ["en"], - "spellright.documentTypes": ["markdown", "typescript"], + "spellright.documentTypes": ["markdown", "typescript", "typescriptreact"], "tailwindCSS.experimental.classRegex": [["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]] } diff --git a/apps/storybook/.storybook/main.js b/apps/storybook/.storybook/main.js index 9b0d21eada..ae653ae06e 100644 --- a/apps/storybook/.storybook/main.js +++ b/apps/storybook/.storybook/main.js @@ -71,5 +71,5 @@ module.exports = { return config; }, - typescript: { reactDocgen: 'react-docgen' } + typescript: { reactDocgen: "react-docgen" }, }; diff --git a/apps/web/components/booking/AvailableTimes.tsx b/apps/web/components/booking/AvailableTimes.tsx index 19cabe0b85..394225ba3a 100644 --- a/apps/web/components/booking/AvailableTimes.tsx +++ b/apps/web/components/booking/AvailableTimes.tsx @@ -11,7 +11,7 @@ import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import { TimeFormat } from "@calcom/lib/timeFormat"; import { nameOfDay } from "@calcom/lib/weekday"; import { trpc } from "@calcom/trpc/react"; -import type { Slot } from "@calcom/trpc/server/routers/viewer/slots"; +import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types"; import { SkeletonContainer, SkeletonText, ToggleGroup } from "@calcom/ui"; import classNames from "@lib/classNames"; diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 64c7d24b09..0644aa5e0f 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -483,12 +483,12 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings i18n: { language }, } = useLocale(); const now = new Date(); - const recurringCount = recurringDates.filter((date) => { + const recurringCount = recurringDates.filter((recurringDate) => { return ( - date >= now && + recurringDate >= now && !booking.recurringInfo?.bookings[BookingStatus.CANCELLED] .map((date) => date.toDateString()) - .includes(date.toDateString()) + .includes(recurringDate.toDateString()) ); }).length; diff --git a/apps/web/pages/api/link.ts b/apps/web/pages/api/link.ts index 9b692d95de..33a9e87ca2 100644 --- a/apps/web/pages/api/link.ts +++ b/apps/web/pages/api/link.ts @@ -7,7 +7,7 @@ import { defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; import { TRPCError } from "@calcom/trpc/server"; import { createContext } from "@calcom/trpc/server/createContext"; -import { viewerRouter } from "@calcom/trpc/server/routers/viewer"; +import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router"; enum DirectAction { ACCEPT = "accept", @@ -51,7 +51,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { try { /** @see https://trpc.io/docs/server-side-calls */ const ctx = await createContext({ req, res }, sessionGetter); - const caller = viewerRouter.createCaller(ctx); + const caller = viewerRouter.createCaller({ ...ctx, req, res }); + await caller.bookings.confirm({ bookingId: booking.id, recurringEventId: booking.recurringEventId || undefined, diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index ec19757ca2..c00df7e1d5 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -15,15 +15,14 @@ import { v4 as uuidv4 } from "uuid"; import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import type { BookingStatus } from "@calcom/prisma/client"; -import type { Slot } from "@calcom/trpc/server/routers/viewer/slots"; -import { getSchedule } from "@calcom/trpc/server/routers/viewer/slots"; +import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types"; +import { getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util"; import { prismaMock, CalendarManagerMock } from "../../../../tests/config/singleton"; // TODO: Mock properly prismaMock.eventType.findUnique.mockResolvedValue(null); prismaMock.user.findMany.mockResolvedValue([]); -prismaMock.selectedSlots.findMany.mockResolvedValue([]); jest.mock("@calcom/lib/constants", () => ({ IS_PRODUCTION: true, @@ -271,16 +270,13 @@ describe("getSchedule", () => { end: `${plus2DateString}T23:00:00.000Z`, }, ]); - const scheduleForDayWithAGoogleCalendarBooking = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); // As per Google Calendar Availability, only 4PM(4-4:45PM) GMT slot would be available expect(scheduleForDayWithAGoogleCalendarBooking).toHaveTimeSlots([`04:00:00.000Z`], { @@ -357,17 +353,14 @@ describe("getSchedule", () => { }); // Day Plus 2 is completely free - It only has non accepted bookings - const scheduleOnCompletelyFreeDay = await getSchedule( - { - eventTypeId: 1, - // EventTypeSlug doesn't matter for non-dynamic events - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleOnCompletelyFreeDay = await getSchedule({ + eventTypeId: 1, + // EventTypeSlug doesn't matter for non-dynamic events + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); // getSchedule returns timeslots in GMT expect(scheduleOnCompletelyFreeDay).toHaveTimeSlots( @@ -390,16 +383,13 @@ describe("getSchedule", () => { ); // Day plus 3 - const scheduleForDayWithOneBooking = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus2DateString}T18:30:00.000Z`, - endTime: `${plus3DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForDayWithOneBooking = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus2DateString}T18:30:00.000Z`, + endTime: `${plus3DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForDayWithOneBooking).toHaveTimeSlots( [ @@ -455,16 +445,13 @@ describe("getSchedule", () => { }); const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); - const scheduleForEventWith30Length = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventWith30Length = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForEventWith30Length).toHaveTimeSlots( [ `04:00:00.000Z`, @@ -490,16 +477,13 @@ describe("getSchedule", () => { } ); - const scheduleForEventWith30minsLengthAndSlotInterval2hrs = await getSchedule( - { - eventTypeId: 2, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventWith30minsLengthAndSlotInterval2hrs = await getSchedule({ + eventTypeId: 2, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); // `slotInterval` takes precedence over `length` expect(scheduleForEventWith30minsLengthAndSlotInterval2hrs).toHaveTimeSlots( [`04:00:00.000Z`, `06:00:00.000Z`, `08:00:00.000Z`, `10:00:00.000Z`, `12:00:00.000Z`], @@ -553,16 +537,13 @@ describe("getSchedule", () => { }); const { dateString: todayDateString } = getDate(); const { dateString: minus1DateString } = getDate({ dateIncrement: -1 }); - const scheduleForEventWithBookingNotice13Hrs = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${minus1DateString}T18:30:00.000Z`, - endTime: `${todayDateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventWithBookingNotice13Hrs = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${minus1DateString}T18:30:00.000Z`, + endTime: `${todayDateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForEventWithBookingNotice13Hrs).toHaveTimeSlots( [ /*`04:00:00.000Z`, `06:00:00.000Z`, - Minimum time slot is 07:30 UTC*/ `08:00:00.000Z`, @@ -574,16 +555,13 @@ describe("getSchedule", () => { } ); - const scheduleForEventWithBookingNotice10Hrs = await getSchedule( - { - eventTypeId: 2, - eventTypeSlug: "", - startTime: `${minus1DateString}T18:30:00.000Z`, - endTime: `${todayDateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventWithBookingNotice10Hrs = await getSchedule({ + eventTypeId: 2, + eventTypeSlug: "", + startTime: `${minus1DateString}T18:30:00.000Z`, + endTime: `${todayDateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForEventWithBookingNotice10Hrs).toHaveTimeSlots( [ /*`04:00:00.000Z`, - Minimum bookable time slot is 04:30 UTC but next available is 06:00*/ @@ -639,16 +617,13 @@ describe("getSchedule", () => { }, ]); - const scheduleForEventOnADayWithNonCalBooking = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus2DateString}T18:30:00.000Z`, - endTime: `${plus3DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventOnADayWithNonCalBooking = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus2DateString}T18:30:00.000Z`, + endTime: `${plus3DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForEventOnADayWithNonCalBooking).toHaveTimeSlots( [ @@ -714,16 +689,13 @@ describe("getSchedule", () => { }, ]); - const scheduleForEventOnADayWithCalBooking = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventOnADayWithCalBooking = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForEventOnADayWithCalBooking).toHaveTimeSlots( [ @@ -767,16 +739,13 @@ describe("getSchedule", () => { createBookingScenario(scenarioData); - const scheduleForEventOnADayWithDateOverride = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventOnADayWithDateOverride = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForEventOnADayWithDateOverride).toHaveTimeSlots( ["08:30:00.000Z", "09:30:00.000Z", "10:30:00.000Z", "11:30:00.000Z"], @@ -853,16 +822,13 @@ describe("getSchedule", () => { // Requesting this user's availability for their // individual Event Type - const thisUserAvailability = await getSchedule( - { - eventTypeId: 2, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const thisUserAvailability = await getSchedule({ + eventTypeId: 2, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(thisUserAvailability).toHaveTimeSlots( [ @@ -951,16 +917,13 @@ describe("getSchedule", () => { hosts: [], }); - const scheduleForTeamEventOnADayWithNoBooking = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${todayDateString}T18:30:00.000Z`, - endTime: `${plus1DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForTeamEventOnADayWithNoBooking = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${todayDateString}T18:30:00.000Z`, + endTime: `${plus1DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForTeamEventOnADayWithNoBooking).toHaveTimeSlots( [ @@ -981,16 +944,13 @@ describe("getSchedule", () => { } ); - const scheduleForTeamEventOnADayWithOneBookingForEachUser = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForTeamEventOnADayWithOneBookingForEachUser = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); // A user with blocked time in another event, still affects Team Event availability // It's a collective availability, so both user 101 and 102 are considered for timeslots expect(scheduleForTeamEventOnADayWithOneBookingForEachUser).toHaveTimeSlots( @@ -1088,16 +1048,13 @@ describe("getSchedule", () => { ], hosts: [], }); - const scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); // A user with blocked time in another event, still affects Team Event availability expect(scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots).toHaveTimeSlots( [ @@ -1116,16 +1073,13 @@ describe("getSchedule", () => { { dateString: plus2DateString } ); - const scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus2DateString}T18:30:00.000Z`, - endTime: `${plus3DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus2DateString}T18:30:00.000Z`, + endTime: `${plus3DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); // A user with blocked time in another event, still affects Team Event availability expect(scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot).toHaveTimeSlots( [ diff --git a/packages/app-store/rainbow/trpc/balance.handler.ts b/packages/app-store/rainbow/trpc/balance.handler.ts new file mode 100644 index 0000000000..e928ace524 --- /dev/null +++ b/packages/app-store/rainbow/trpc/balance.handler.ts @@ -0,0 +1,25 @@ +import { checkBalance } from "../utils/ethereum"; +import type { TBalanceInputSchema } from "./balance.schema"; + +interface BalanceHandlerOptions { + input: TBalanceInputSchema; +} + +export const balanceHandler = async ({ input }: BalanceHandlerOptions) => { + const { address, tokenAddress, chainId } = input; + try { + const hasBalance = await checkBalance(address, tokenAddress, chainId); + + return { + data: { + hasBalance, + }, + }; + } catch (e) { + return { + data: { + hasBalance: false, + }, + }; + } +}; diff --git a/packages/app-store/rainbow/trpc/balance.schema.ts b/packages/app-store/rainbow/trpc/balance.schema.ts new file mode 100644 index 0000000000..1828df4999 --- /dev/null +++ b/packages/app-store/rainbow/trpc/balance.schema.ts @@ -0,0 +1,19 @@ +import z from "zod"; + +export const ZBalanceInputSchema = z.object({ + address: z.string(), + tokenAddress: z.string(), + chainId: z.number(), +}); + +export const ZBalanceOutputSchema = z.object({ + data: z + .object({ + hasBalance: z.boolean(), + }) + .nullish(), + error: z.string().nullish(), +}); + +export type TBalanceOutputSchema = z.infer; +export type TBalanceInputSchema = z.infer; diff --git a/packages/app-store/rainbow/trpc/contract.handler.ts b/packages/app-store/rainbow/trpc/contract.handler.ts new file mode 100644 index 0000000000..6f5463e0c8 --- /dev/null +++ b/packages/app-store/rainbow/trpc/contract.handler.ts @@ -0,0 +1,42 @@ +import { ethers } from "ethers"; +import { configureChains, createClient } from "wagmi"; + +import abi from "../utils/abi.json"; +import { getProviders, SUPPORTED_CHAINS } from "../utils/ethereum"; +import type { TContractInputSchema } from "./contract.schema"; + +interface ContractHandlerOptions { + input: TContractInputSchema; +} +export const contractHandler = async ({ input }: ContractHandlerOptions) => { + const { address, chainId } = input; + const { provider } = configureChains( + SUPPORTED_CHAINS.filter((chain) => chain.id === chainId), + getProviders() + ); + + const client = createClient({ + provider, + }); + + const contract = new ethers.Contract(address, abi, client.provider); + + try { + const name = await contract.name(); + const symbol = await contract.symbol(); + + return { + data: { + name, + symbol: `$${symbol}`, + }, + }; + } catch (e) { + return { + data: { + name: address, + symbol: "$UNKNOWN", + }, + }; + } +}; diff --git a/packages/app-store/rainbow/trpc/contract.schema.ts b/packages/app-store/rainbow/trpc/contract.schema.ts new file mode 100644 index 0000000000..82e8c5fe9b --- /dev/null +++ b/packages/app-store/rainbow/trpc/contract.schema.ts @@ -0,0 +1,19 @@ +import z from "zod"; + +export const ZContractInputSchema = z.object({ + address: z.string(), + chainId: z.number(), +}); + +export const ZContractOutputSchema = z.object({ + data: z + .object({ + name: z.string(), + symbol: z.string(), + }) + .nullish(), + error: z.string().nullish(), +}); + +export type TContractInputSchema = z.infer; +export type TContractOutputSchema = z.infer; diff --git a/packages/app-store/rainbow/trpc/router.ts b/packages/app-store/rainbow/trpc/router.ts index d07374107d..81f4c49b4f 100644 --- a/packages/app-store/rainbow/trpc/router.ts +++ b/packages/app-store/rainbow/trpc/router.ts @@ -1,100 +1,53 @@ -import { ethers } from "ethers"; -import { configureChains, createClient } from "wagmi"; -import { z } from "zod"; - import { router, publicProcedure } from "@calcom/trpc/server/trpc"; -import abi from "../utils/abi.json"; -import { checkBalance, getProviders, SUPPORTED_CHAINS } from "../utils/ethereum"; +import { ZBalanceInputSchema, ZBalanceOutputSchema } from "./balance.schema"; +import { ZContractInputSchema, ZContractOutputSchema } from "./contract.schema"; + +interface EthRouterHandlersCache { + contract?: typeof import("./contract.handler").contractHandler; + balance?: typeof import("./balance.handler").balanceHandler; +} + +const UNSTABLE_HANDLER_CACHE: EthRouterHandlersCache = {}; const ethRouter = router({ // Fetch contract `name` and `symbol` or error contract: publicProcedure - .input( - z.object({ - address: z.string(), - chainId: z.number(), - }) - ) - .output( - z.object({ - data: z - .object({ - name: z.string(), - symbol: z.string(), - }) - .nullish(), - error: z.string().nullish(), - }) - ) + .input(ZContractInputSchema) + .output(ZContractOutputSchema) .query(async ({ input }) => { - const { address, chainId } = input; - const { provider } = configureChains( - SUPPORTED_CHAINS.filter((chain) => chain.id === chainId), - getProviders() - ); - - const client = createClient({ - provider, - }); - - const contract = new ethers.Contract(address, abi, client.provider); - - try { - const name = await contract.name(); - const symbol = await contract.symbol(); - - return { - data: { - name, - symbol: `$${symbol}`, - }, - }; - } catch (e) { - return { - data: { - name: address, - symbol: "$UNKNOWN", - }, - }; + if (!UNSTABLE_HANDLER_CACHE.contract) { + UNSTABLE_HANDLER_CACHE.contract = await import("./contract.handler").then( + (mod) => mod.contractHandler + ); } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.contract) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.contract({ + input, + }); }), // Fetch user's `balance` of either ERC-20 or ERC-721 compliant token or error balance: publicProcedure - .input( - z.object({ - address: z.string(), - tokenAddress: z.string(), - chainId: z.number(), - }) - ) - .output( - z.object({ - data: z - .object({ - hasBalance: z.boolean(), - }) - .nullish(), - error: z.string().nullish(), - }) - ) + .input(ZBalanceInputSchema) + .output(ZBalanceOutputSchema) .query(async ({ input }) => { - const { address, tokenAddress, chainId } = input; - try { - const hasBalance = await checkBalance(address, tokenAddress, chainId); - - return { - data: { - hasBalance, - }, - }; - } catch (e) { - return { - data: { - hasBalance: false, - }, - }; + if (!UNSTABLE_HANDLER_CACHE.balance) { + UNSTABLE_HANDLER_CACHE.balance = await import("./balance.handler").then((mod) => mod.balanceHandler); } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.balance) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.balance({ + input, + }); }), }); diff --git a/packages/features/bookings/components/event-meta/event.mock.ts b/packages/features/bookings/components/event-meta/event.mock.ts index 1b9e0cad9d..1bbff1ff57 100644 --- a/packages/features/bookings/components/event-meta/event.mock.ts +++ b/packages/features/bookings/components/event-meta/event.mock.ts @@ -1,4 +1,4 @@ -import { RouterOutputs } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; export const mockEvent: RouterOutputs["viewer"]["public"]["event"] = { id: 1, diff --git a/packages/features/bookings/layout/BookingLayout.tsx b/packages/features/bookings/layout/BookingLayout.tsx index fae8f86038..218ad8fd03 100644 --- a/packages/features/bookings/layout/BookingLayout.tsx +++ b/packages/features/bookings/layout/BookingLayout.tsx @@ -1,8 +1,9 @@ -import React, { ComponentProps } from "react"; +import type { ComponentProps } from "react"; +import React from "react"; import Shell from "@calcom/features/shell/Shell"; import { HorizontalTabs } from "@calcom/ui"; -import { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui"; +import type { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui"; import { FiltersContainer } from "../components/FiltersContainer"; diff --git a/packages/features/ee/payments/api/webhook.ts b/packages/features/ee/payments/api/webhook.ts index 797cf1b6b9..9bae3030e9 100644 --- a/packages/features/ee/payments/api/webhook.ts +++ b/packages/features/ee/payments/api/webhook.ts @@ -14,7 +14,7 @@ import { IS_PRODUCTION } from "@calcom/lib/constants"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import { HttpError as HttpCode } from "@calcom/lib/http-error"; import { getTranslation } from "@calcom/lib/server/i18n"; -import prisma, { bookingMinimalSelect } from "@calcom/prisma"; +import { prisma, bookingMinimalSelect } from "@calcom/prisma"; import type { CalendarEvent } from "@calcom/types/Calendar"; export const config = { diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 38cda62918..3e7d8ad1d8 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -1,5 +1,5 @@ import type { Prisma } from "@prisma/client"; -import crypto from "crypto"; +import md5 from "md5"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; @@ -715,7 +715,7 @@ export const insightsRouter = router({ return { userId: booking.userId, user: userHashMap.get(booking.userId), - emailMd5: crypto.createHash("md5").update(user?.email).digest("hex"), + emailMd5: md5(user?.email), count: booking._count.id, }; }); @@ -806,7 +806,7 @@ export const insightsRouter = router({ return { userId: booking.userId, user: userHashMap.get(booking.userId), - emailMd5: crypto.createHash("md5").update(user?.email).digest("hex"), + emailMd5: md5(user?.email), count: booking._count.id, }; }); diff --git a/packages/features/schedules/lib/use-schedule/index.ts b/packages/features/schedules/lib/use-schedule/index.ts index ce81319e9c..2f9ab81a7b 100644 --- a/packages/features/schedules/lib/use-schedule/index.ts +++ b/packages/features/schedules/lib/use-schedule/index.ts @@ -1,4 +1,4 @@ export { useSchedule } from "./useSchedule"; export { useSlotsForDate } from "./useSlotsForDate"; export { useNonEmptyScheduleDays } from "./useNonEmptyScheduleDays"; -export type { Slots } from "./types"; +export type { Slots } from "./types"; diff --git a/packages/features/schedules/lib/use-schedule/types.ts b/packages/features/schedules/lib/use-schedule/types.ts index 72ba9df508..c6f1aa8535 100644 --- a/packages/features/schedules/lib/use-schedule/types.ts +++ b/packages/features/schedules/lib/use-schedule/types.ts @@ -1,3 +1,3 @@ -import { RouterOutputs } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; export type Slots = RouterOutputs["viewer"]["public"]["slots"]["getSchedule"]["slots"]; diff --git a/packages/features/webhooks/pages/webhook-edit-view.tsx b/packages/features/webhooks/pages/webhook-edit-view.tsx index 51e13837c7..511fc8e438 100644 --- a/packages/features/webhooks/pages/webhook-edit-view.tsx +++ b/packages/features/webhooks/pages/webhook-edit-view.tsx @@ -7,7 +7,8 @@ import { trpc } from "@calcom/trpc/react"; import { Meta, showToast, SkeletonContainer } from "@calcom/ui"; import { getLayout } from "../../settings/layouts/SettingsLayout"; -import WebhookForm, { WebhookFormSubmitData } from "../components/WebhookForm"; +import type { WebhookFormSubmitData } from "../components/WebhookForm"; +import WebhookForm from "../components/WebhookForm"; const querySchema = z.object({ id: z.string() }); diff --git a/packages/lib/perf.ts b/packages/lib/perf.ts new file mode 100644 index 0000000000..905d83ffa3 --- /dev/null +++ b/packages/lib/perf.ts @@ -0,0 +1,8 @@ +export const logP = (message: string) => { + const start = performance.now(); + + return () => { + const end = performance.now(); + console.log(`[PERF]: ${message} took ${end - start}ms`); + }; +}; diff --git a/packages/trpc/react/trpc.ts b/packages/trpc/react/trpc.ts index 065aa7d8aa..ea3b579f09 100644 --- a/packages/trpc/react/trpc.ts +++ b/packages/trpc/react/trpc.ts @@ -1,6 +1,7 @@ import type { NextPageContext } from "next/types"; import superjson from "superjson"; +import { httpBatchLink } from "../client/links/httpBatchLink"; import { httpLink } from "../client/links/httpLink"; import { loggerLink } from "../client/links/loggerLink"; import { splitLink } from "../client/links/splitLink"; @@ -8,7 +9,6 @@ import { createTRPCNext } from "../next"; // ℹ️ Type-only import: // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export import type { TRPCClientErrorLike } from "../react"; -import { httpBatchLink } from "../react"; import type { inferRouterInputs, inferRouterOutputs, Maybe } from "../server"; import type { AppRouter } from "../server/routers/_app"; diff --git a/packages/trpc/server/createContext.ts b/packages/trpc/server/createContext.ts index 6880ac9898..ed05bd238a 100644 --- a/packages/trpc/server/createContext.ts +++ b/packages/trpc/server/createContext.ts @@ -77,3 +77,5 @@ export const createContext = async ( res, }; }; + +export type TRPCContext = Awaited>; diff --git a/packages/trpc/server/routers/_app.ts b/packages/trpc/server/routers/_app.ts index f86d18bd4f..3c08010b90 100644 --- a/packages/trpc/server/routers/_app.ts +++ b/packages/trpc/server/routers/_app.ts @@ -2,7 +2,7 @@ * This file contains the root router of your tRPC-backend */ import { router } from "../trpc"; -import { viewerRouter } from "./viewer"; +import { viewerRouter } from "./viewer/_router"; /** * Create your application's root router diff --git a/packages/trpc/server/routers/loggedInViewer/_router.tsx b/packages/trpc/server/routers/loggedInViewer/_router.tsx new file mode 100644 index 0000000000..c4d8414139 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/_router.tsx @@ -0,0 +1,369 @@ +import { authedProcedure, router } from "../../trpc"; +import { ZAppByIdInputSchema } from "./appById.schema"; +import { ZAppCredentialsByTypeInputSchema } from "./appCredentialsByType.schema"; +import { ZAppsInputSchema } from "./apps.schema"; +import { ZAwayInputSchema } from "./away.schema"; +import { ZDeleteCredentialInputSchema } from "./deleteCredential.schema"; +import { ZDeleteMeInputSchema } from "./deleteMe.schema"; +import { ZEventTypeOrderInputSchema } from "./eventTypeOrder.schema"; +import { ZGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schema"; +import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema"; +import { ZIntegrationsInputSchema } from "./integrations.schema"; +import { ZSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema"; +import { ZSubmitFeedbackInputSchema } from "./submitFeedback.schema"; +import { ZUpdateProfileInputSchema } from "./updateProfile.schema"; +import { ZUpdateUserDefaultConferencingAppInputSchema } from "./updateUserDefaultConferencingApp.schema"; + +type AppsRouterHandlerCache = { + me?: typeof import("./me.handler").meHandler; + avatar?: typeof import("./avatar.handler").avatarHandler; + deleteMe?: typeof import("./deleteMe.handler").deleteMeHandler; + deleteMeWithoutPassword?: typeof import("./deleteMeWithoutPassword.handler").deleteMeWithoutPasswordHandler; + away?: typeof import("./away.handler").awayHandler; + connectedCalendars?: typeof import("./connectedCalendars.handler").connectedCalendarsHandler; + setDestinationCalendar?: typeof import("./setDestinationCalendar.handler").setDestinationCalendarHandler; + integrations?: typeof import("./integrations.handler").integrationsHandler; + appById?: typeof import("./appById.handler").appByIdHandler; + apps?: typeof import("./apps.handler").appsHandler; + appCredentialsByType?: typeof import("./appCredentialsByType.handler").appCredentialsByTypeHandler; + stripeCustomer?: typeof import("./stripeCustomer.handler").stripeCustomerHandler; + updateProfile?: typeof import("./updateProfile.handler").updateProfileHandler; + eventTypeOrder?: typeof import("./eventTypeOrder.handler").eventTypeOrderHandler; + submitFeedback?: typeof import("./submitFeedback.handler").submitFeedbackHandler; + locationOptions?: typeof import("./locationOptions.handler").locationOptionsHandler; + deleteCredential?: typeof import("./deleteCredential.handler").deleteCredentialHandler; + bookingUnconfirmedCount?: typeof import("./bookingUnconfirmedCount.handler").bookingUnconfirmedCountHandler; + getCalVideoRecordings?: typeof import("./getCalVideoRecordings.handler").getCalVideoRecordingsHandler; + getDownloadLinkOfCalVideoRecordings?: typeof import("./getDownloadLinkOfCalVideoRecordings.handler").getDownloadLinkOfCalVideoRecordingsHandler; + getUsersDefaultConferencingApp?: typeof import("./getUsersDefaultConferencingApp.handler").getUsersDefaultConferencingAppHandler; + updateUserDefaultConferencingApp?: typeof import("./updateUserDefaultConferencingApp.handler").updateUserDefaultConferencingAppHandler; +}; + +const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {}; + +export const loggedInViewerRouter = router({ + me: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.me) { + UNSTABLE_HANDLER_CACHE.me = (await import("./me.handler")).meHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.me) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.me({ ctx }); + }), + + avatar: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.avatar) { + UNSTABLE_HANDLER_CACHE.avatar = (await import("./avatar.handler")).avatarHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.avatar) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.avatar({ ctx }); + }), + + deleteMe: authedProcedure.input(ZDeleteMeInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.deleteMe) { + UNSTABLE_HANDLER_CACHE.deleteMe = (await import("./deleteMe.handler")).deleteMeHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.deleteMe) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.deleteMe({ ctx, input }); + }), + + deleteMeWithoutPassword: authedProcedure.mutation(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.deleteMeWithoutPassword) { + UNSTABLE_HANDLER_CACHE.deleteMeWithoutPassword = ( + await import("./deleteMeWithoutPassword.handler") + ).deleteMeWithoutPasswordHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.deleteMeWithoutPassword) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.deleteMeWithoutPassword({ ctx }); + }), + + away: authedProcedure.input(ZAwayInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.away) { + UNSTABLE_HANDLER_CACHE.away = (await import("./away.handler")).awayHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.away) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.away({ ctx, input }); + }), + + connectedCalendars: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.connectedCalendars) { + UNSTABLE_HANDLER_CACHE.connectedCalendars = ( + await import("./connectedCalendars.handler") + ).connectedCalendarsHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.connectedCalendars) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.connectedCalendars({ ctx }); + }), + + setDestinationCalendar: authedProcedure + .input(ZSetDestinationCalendarInputSchema) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.setDestinationCalendar) { + UNSTABLE_HANDLER_CACHE.setDestinationCalendar = ( + await import("./setDestinationCalendar.handler") + ).setDestinationCalendarHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.setDestinationCalendar) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.setDestinationCalendar({ ctx, input }); + }), + + integrations: authedProcedure.input(ZIntegrationsInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.integrations) { + UNSTABLE_HANDLER_CACHE.integrations = (await import("./integrations.handler")).integrationsHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.integrations) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.integrations({ ctx, input }); + }), + + appById: authedProcedure.input(ZAppByIdInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.appById) { + UNSTABLE_HANDLER_CACHE.appById = (await import("./appById.handler")).appByIdHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.appById) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.appById({ ctx, input }); + }), + + apps: authedProcedure.input(ZAppsInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.apps) { + UNSTABLE_HANDLER_CACHE.apps = (await import("./apps.handler")).appsHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.apps) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.apps({ ctx, input }); + }), + + appCredentialsByType: authedProcedure + .input(ZAppCredentialsByTypeInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.appCredentialsByType) { + UNSTABLE_HANDLER_CACHE.appCredentialsByType = ( + await import("./appCredentialsByType.handler") + ).appCredentialsByTypeHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.appCredentialsByType) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.appCredentialsByType({ ctx, input }); + }), + + stripeCustomer: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.stripeCustomer) { + UNSTABLE_HANDLER_CACHE.stripeCustomer = ( + await import("./stripeCustomer.handler") + ).stripeCustomerHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.stripeCustomer) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.stripeCustomer({ ctx }); + }), + + updateProfile: authedProcedure.input(ZUpdateProfileInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.updateProfile) { + UNSTABLE_HANDLER_CACHE.updateProfile = (await import("./updateProfile.handler")).updateProfileHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.updateProfile) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.updateProfile({ ctx, input }); + }), + + eventTypeOrder: authedProcedure.input(ZEventTypeOrderInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.eventTypeOrder) { + UNSTABLE_HANDLER_CACHE.eventTypeOrder = ( + await import("./eventTypeOrder.handler") + ).eventTypeOrderHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.eventTypeOrder) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.eventTypeOrder({ ctx, input }); + }), + + //Comment for PR: eventTypePosition is not used anywhere + submitFeedback: authedProcedure.input(ZSubmitFeedbackInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.submitFeedback) { + UNSTABLE_HANDLER_CACHE.submitFeedback = ( + await import("./submitFeedback.handler") + ).submitFeedbackHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.submitFeedback) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.submitFeedback({ ctx, input }); + }), + + locationOptions: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.locationOptions) { + UNSTABLE_HANDLER_CACHE.locationOptions = ( + await import("./locationOptions.handler") + ).locationOptionsHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.locationOptions) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.locationOptions({ ctx }); + }), + + deleteCredential: authedProcedure.input(ZDeleteCredentialInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.deleteCredential) { + UNSTABLE_HANDLER_CACHE.deleteCredential = ( + await import("./deleteCredential.handler") + ).deleteCredentialHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.deleteCredential) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.deleteCredential({ ctx, input }); + }), + + bookingUnconfirmedCount: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.bookingUnconfirmedCount) { + UNSTABLE_HANDLER_CACHE.bookingUnconfirmedCount = ( + await import("./bookingUnconfirmedCount.handler") + ).bookingUnconfirmedCountHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.bookingUnconfirmedCount) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.bookingUnconfirmedCount({ ctx }); + }), + + getCalVideoRecordings: authedProcedure + .input(ZGetCalVideoRecordingsInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getCalVideoRecordings) { + UNSTABLE_HANDLER_CACHE.getCalVideoRecordings = ( + await import("./getCalVideoRecordings.handler") + ).getCalVideoRecordingsHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getCalVideoRecordings) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getCalVideoRecordings({ ctx, input }); + }), + + getDownloadLinkOfCalVideoRecordings: authedProcedure + .input(ZGetDownloadLinkOfCalVideoRecordingsInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getDownloadLinkOfCalVideoRecordings) { + UNSTABLE_HANDLER_CACHE.getDownloadLinkOfCalVideoRecordings = ( + await import("./getDownloadLinkOfCalVideoRecordings.handler") + ).getDownloadLinkOfCalVideoRecordingsHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getDownloadLinkOfCalVideoRecordings) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getDownloadLinkOfCalVideoRecordings({ ctx, input }); + }), + + getUsersDefaultConferencingApp: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getUsersDefaultConferencingApp) { + UNSTABLE_HANDLER_CACHE.getUsersDefaultConferencingApp = ( + await import("./getUsersDefaultConferencingApp.handler") + ).getUsersDefaultConferencingAppHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getUsersDefaultConferencingApp) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getUsersDefaultConferencingApp({ ctx }); + }), + + updateUserDefaultConferencingApp: authedProcedure + .input(ZUpdateUserDefaultConferencingAppInputSchema) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp) { + UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp = ( + await import("./updateUserDefaultConferencingApp.handler") + ).updateUserDefaultConferencingAppHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp({ ctx, input }); + }), +}); diff --git a/packages/trpc/server/routers/loggedInViewer/appById.handler.ts b/packages/trpc/server/routers/loggedInViewer/appById.handler.ts new file mode 100644 index 0000000000..3b154e79f8 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/appById.handler.ts @@ -0,0 +1,31 @@ +import getApps from "@calcom/app-store/utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TAppByIdInputSchema } from "./appById.schema"; + +type AppByIdOptions = { + ctx: { + user: NonNullable; + }; + input: TAppByIdInputSchema; +}; + +export const appByIdHandler = async ({ ctx, input }: AppByIdOptions) => { + const { user } = ctx; + const appId = input.appId; + const { credentials } = user; + const apps = getApps(credentials); + const appFromDb = apps.find((app) => app.slug === appId); + if (!appFromDb) { + throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find app ${appId}` }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { credential: _, credentials: _1, ...app } = appFromDb; + return { + isInstalled: appFromDb.credentials.length, + ...app, + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/appById.schema.ts b/packages/trpc/server/routers/loggedInViewer/appById.schema.ts new file mode 100644 index 0000000000..f02610cd76 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/appById.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZAppByIdInputSchema = z.object({ + appId: z.string(), +}); + +export type TAppByIdInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts new file mode 100644 index 0000000000..6d5f4e2ee9 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts @@ -0,0 +1,15 @@ +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TAppCredentialsByTypeInputSchema } from "./appCredentialsByType.schema"; + +type AppCredentialsByTypeOptions = { + ctx: { + user: NonNullable; + }; + input: TAppCredentialsByTypeInputSchema; +}; + +export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentialsByTypeOptions) => { + const { user } = ctx; + return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.schema.ts b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.schema.ts new file mode 100644 index 0000000000..283997a5a9 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZAppCredentialsByTypeInputSchema = z.object({ + appType: z.string(), +}); + +export type TAppCredentialsByTypeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/apps.handler.ts b/packages/trpc/server/routers/loggedInViewer/apps.handler.ts new file mode 100644 index 0000000000..85e510009a --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/apps.handler.ts @@ -0,0 +1,24 @@ +import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TAppsInputSchema } from "./apps.schema"; + +type AppsOptions = { + ctx: { + user: NonNullable; + }; + input: TAppsInputSchema; +}; + +export const appsHandler = async ({ ctx, input }: AppsOptions) => { + const { user } = ctx; + const { credentials } = user; + + const apps = await getEnabledApps(credentials); + return apps + .filter((app) => app.extendsFeature?.includes(input.extendsFeature)) + .map((app) => ({ + ...app, + isInstalled: !!app.credentials?.length, + })); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/apps.schema.ts b/packages/trpc/server/routers/loggedInViewer/apps.schema.ts new file mode 100644 index 0000000000..3ab2115e8d --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/apps.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZAppsInputSchema = z.object({ + extendsFeature: z.literal("EventType"), +}); + +export type TAppsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/avatar.handler.ts b/packages/trpc/server/routers/loggedInViewer/avatar.handler.ts new file mode 100644 index 0000000000..8a4f0ee74d --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/avatar.handler.ts @@ -0,0 +1,13 @@ +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type AvatarOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const avatarHandler = async ({ ctx }: AvatarOptions) => { + return { + avatar: ctx.user.rawAvatar, + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/avatar.schema.ts b/packages/trpc/server/routers/loggedInViewer/avatar.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/avatar.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/away.handler.ts b/packages/trpc/server/routers/loggedInViewer/away.handler.ts new file mode 100644 index 0000000000..b9787cedf5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/away.handler.ts @@ -0,0 +1,22 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TAwayInputSchema } from "./away.schema"; + +type AwayOptions = { + ctx: { + user: NonNullable; + }; + input: TAwayInputSchema; +}; + +export const awayHandler = async ({ ctx, input }: AwayOptions) => { + await prisma.user.update({ + where: { + email: ctx.user.email, + }, + data: { + away: input.away, + }, + }); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/away.schema.ts b/packages/trpc/server/routers/loggedInViewer/away.schema.ts new file mode 100644 index 0000000000..da84660332 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/away.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZAwayInputSchema = z.object({ + away: z.boolean(), +}); + +export type TAwayInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.handler.ts b/packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.handler.ts new file mode 100644 index 0000000000..5a0e96b9cd --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.handler.ts @@ -0,0 +1,38 @@ +import { BookingStatus } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type BookingUnconfirmedCountOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const bookingUnconfirmedCountHandler = async ({ ctx }: BookingUnconfirmedCountOptions) => { + const { user } = ctx; + const count = await prisma.booking.count({ + where: { + status: BookingStatus.PENDING, + userId: user.id, + endTime: { gt: new Date() }, + }, + }); + const recurringGrouping = await prisma.booking.groupBy({ + by: ["recurringEventId"], + _count: { + recurringEventId: true, + }, + where: { + recurringEventId: { not: { equals: null } }, + status: { equals: "PENDING" }, + userId: user.id, + endTime: { gt: new Date() }, + }, + }); + return recurringGrouping.reduce((prev, current) => { + // recurringEventId is the total number of recurring instances for a booking + // we need to subtract all but one, to represent a single recurring booking + return prev - (current._count?.recurringEventId - 1); + }, count); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.schema.ts b/packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/connectedCalendars.handler.ts b/packages/trpc/server/routers/loggedInViewer/connectedCalendars.handler.ts new file mode 100644 index 0000000000..3a79cf95dc --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/connectedCalendars.handler.ts @@ -0,0 +1,90 @@ +import type { DestinationCalendar } from "@prisma/client"; +import { AppCategories } from "@prisma/client"; + +import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type ConnectedCalendarsOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const connectedCalendarsHandler = async ({ ctx }: ConnectedCalendarsOptions) => { + const { user } = ctx; + + const userCredentials = await prisma.credential.findMany({ + where: { + userId: ctx.user.id, + app: { + categories: { has: AppCategories.calendar }, + enabled: true, + }, + }, + }); + + // get user's credentials + their connected integrations + const calendarCredentials = getCalendarCredentials(userCredentials); + + // get all the connected integrations' calendars (from third party) + const { connectedCalendars, destinationCalendar } = await getConnectedCalendars( + calendarCredentials, + user.selectedCalendars, + user.destinationCalendar?.externalId + ); + + if (connectedCalendars.length === 0) { + /* As there are no connected calendars, delete the destination calendar if it exists */ + if (user.destinationCalendar) { + await 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 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 prisma.destinationCalendar.update({ + where: { userId: user.id }, + data: { + integration, + externalId, + }, + }); + } + } + + return { + connectedCalendars, + destinationCalendar: { + ...(user.destinationCalendar as DestinationCalendar), + ...destinationCalendar, + }, + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/connectedCalendars.schema.ts b/packages/trpc/server/routers/loggedInViewer/connectedCalendars.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/connectedCalendars.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts new file mode 100644 index 0000000000..08c6fffc3e --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts @@ -0,0 +1,345 @@ +import { AppCategories, BookingStatus } from "@prisma/client"; +import z from "zod"; + +import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; +import { DailyLocationType } from "@calcom/core/location"; +import { sendCancelledEmails } from "@calcom/emails"; +import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import getPaymentAppData from "@calcom/lib/getPaymentAppData"; +import { deletePayment } from "@calcom/lib/payment/deletePayment"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { bookingMinimalSelect } from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TDeleteCredentialInputSchema } from "./deleteCredential.schema"; + +type DeleteCredentialOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteCredentialInputSchema; +}; + +export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOptions) => { + const { id, externalId } = input; + + const credential = await prisma.credential.findFirst({ + where: { + id: id, + userId: ctx.user.id, + }, + select: { + key: true, + appId: true, + app: { + select: { + slug: true, + categories: true, + dirName: 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: { + include: { + credential: true, + }, + }, + price: true, + currency: true, + metadata: true, + }, + }); + + // TODO: Improve this uninstallation cleanup per event by keeping a relation of EventType to App which has the data. + 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?.credential?.appId === credential.appId) { + 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, + }, + }, + }); + } + } + } + + const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); + + const stripeAppData = getPaymentAppData({ ...eventType, metadata }); + + // 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 (stripeAppData.price) { + await prisma.$transaction(async () => { + await prisma.eventType.update({ + where: { + id: eventType.id, + }, + data: { + hidden: true, + metadata: { + ...metadata, + apps: { + ...metadata?.apps, + stripe: { + ...metadata?.apps?.stripe, + 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, + responses: 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, + bookingFields: true, + seatsPerTimeSlot: true, + seatsShowAttendees: 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) { + try { + await deletePayment(payment.id, credential); + } catch (e) { + console.error(e); + } + 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), + ...getCalEventResponses({ + bookingFields: booking.eventType?.bookingFields ?? null, + booking, + }), + 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", + seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, + seatsShowAttendees: booking.eventType?.seatsShowAttendees, + }); + } + }); + } + } + } + + // 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: { + userId: ctx.user.id, + 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, + }, + }); + // Revalidate user calendar cache. + if (credential.app?.slug.includes("calendar")) { + await fetch(`${WEBAPP_URL}/api/revalidate-calendar-cache/${ctx?.user?.username}`); + } +}; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteCredential.schema.ts b/packages/trpc/server/routers/loggedInViewer/deleteCredential.schema.ts new file mode 100644 index 0000000000..8814240beb --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/deleteCredential.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZDeleteCredentialInputSchema = z.object({ + id: z.number(), + externalId: z.string().optional(), +}); + +export type TDeleteCredentialInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteMe.handler.ts b/packages/trpc/server/routers/loggedInViewer/deleteMe.handler.ts new file mode 100644 index 0000000000..3dac93e029 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/deleteMe.handler.ts @@ -0,0 +1,87 @@ +import { IdentityProvider } from "@prisma/client"; +import { authenticator } from "otplib"; + +import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer"; +import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; +import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import { deleteWebUser as syncServicesDeleteWebUser } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TDeleteMeInputSchema } from "./deleteMe.schema"; + +type DeleteMeOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteMeInputSchema; +}; + +export const deleteMeHandler = async ({ ctx, input }: DeleteMeOptions) => { + // 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); + } + + // If user has 2fa enabled, check if input.totpCode is correct + const isValidToken = authenticator.check(input.totpCode, secret); + if (!isValidToken) { + throw new Error(ErrorCode.IncorrectTwoFactorCode); + } + } + + // If 2FA is disabled or totpCode is valid then delete the user from stripe and database + await deleteStripeCustomer(user).catch(console.warn); + // Remove my account + const deletedUser = await prisma.user.delete({ + where: { + id: ctx.user.id, + }, + }); + + // Sync Services + syncServicesDeleteWebUser(deletedUser); + return; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteMe.schema.ts b/packages/trpc/server/routers/loggedInViewer/deleteMe.schema.ts new file mode 100644 index 0000000000..1f26c19049 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/deleteMe.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZDeleteMeInputSchema = z.object({ + password: z.string(), + totpCode: z.string().optional(), +}); + +export type TDeleteMeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.handler.ts b/packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.handler.ts new file mode 100644 index 0000000000..55b13ce44b --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.handler.ts @@ -0,0 +1,46 @@ +import { IdentityProvider } from "@prisma/client"; + +import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer"; +import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; +import { deleteWebUser as syncServicesDeleteWebUser } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type DeleteMeWithoutPasswordOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const deleteMeWithoutPasswordHandler = async ({ ctx }: DeleteMeWithoutPasswordOptions) => { + 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.SocialIdentityProviderRequired); + } + + if (user.twoFactorEnabled) { + throw new Error(ErrorCode.SocialIdentityProviderRequired); + } + + // Remove me from Stripe + await deleteStripeCustomer(user).catch(console.warn); + + // Remove my account + const deletedUser = await prisma.user.delete({ + where: { + id: ctx.user.id, + }, + }); + // Sync Services + syncServicesDeleteWebUser(deletedUser); + + return; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.schema.ts b/packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/eventTypeOrder.handler.ts b/packages/trpc/server/routers/loggedInViewer/eventTypeOrder.handler.ts new file mode 100644 index 0000000000..6f9dd4ea62 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/eventTypeOrder.handler.ts @@ -0,0 +1,69 @@ +import { reverse } from "lodash"; + +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TEventTypeOrderInputSchema } from "./eventTypeOrder.schema"; + +type EventTypeOrderOptions = { + ctx: { + user: NonNullable; + }; + input: TEventTypeOrderInputSchema; +}; + +export const eventTypeOrderHandler = async ({ ctx, input }: EventTypeOrderOptions) => { + const { user } = ctx; + + const allEventTypes = await 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, + }, + }); + }) + ); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/eventTypeOrder.schema.ts b/packages/trpc/server/routers/loggedInViewer/eventTypeOrder.schema.ts new file mode 100644 index 0000000000..29faed3e47 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/eventTypeOrder.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZEventTypeOrderInputSchema = z.object({ + ids: z.array(z.number()), +}); + +export type TEventTypeOrderInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.handler.ts b/packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.handler.ts new file mode 100644 index 0000000000..4fac9db381 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.handler.ts @@ -0,0 +1,26 @@ +import { getRecordingsOfCalVideoByRoomName } from "@calcom/core/videoClient"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schema"; + +type GetCalVideoRecordingsOptions = { + ctx: { + user: NonNullable; + }; + input: TGetCalVideoRecordingsInputSchema; +}; + +export const getCalVideoRecordingsHandler = async ({ ctx: _ctx, input }: GetCalVideoRecordingsOptions) => { + const { roomName } = input; + + try { + const res = await getRecordingsOfCalVideoByRoomName(roomName); + return res; + } catch (err) { + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } +}; diff --git a/packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.schema.ts b/packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.schema.ts new file mode 100644 index 0000000000..e151d05402 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetCalVideoRecordingsInputSchema = z.object({ + roomName: z.string(), +}); + +export type TGetCalVideoRecordingsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.handler.ts b/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.handler.ts new file mode 100644 index 0000000000..360184e605 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.handler.ts @@ -0,0 +1,38 @@ +/// +import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient"; +import { IS_SELF_HOSTED } from "@calcom/lib/constants"; + +import { TRPCError } from "@trpc/server"; + +import type { CreateInnerContextOptions } from "../../createContext"; +import type { TGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema"; + +type GetDownloadLinkOfCalVideoRecordingsHandlerOptions = { + ctx: CreateInnerContextOptions; + input: TGetDownloadLinkOfCalVideoRecordingsInputSchema; +}; + +export const getDownloadLinkOfCalVideoRecordingsHandler = async ({ + input, + ctx, +}: GetDownloadLinkOfCalVideoRecordingsHandlerOptions) => { + const { recordingId } = input; + const { session } = ctx; + + const isDownloadAllowed = IS_SELF_HOSTED || session?.user?.belongsToActiveTeam; + + if (!isDownloadAllowed) { + throw new TRPCError({ + code: "FORBIDDEN", + }); + } + + try { + const res = await getDownloadLinkOfCalVideoByRecordingId(recordingId); + return res; + } catch (err) { + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } +}; diff --git a/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.schema.ts b/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.schema.ts new file mode 100644 index 0000000000..fabb8d6af4 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZGetDownloadLinkOfCalVideoRecordingsInputSchema = z.object({ + recordingId: z.string(), +}); + +export type TGetDownloadLinkOfCalVideoRecordingsInputSchema = z.infer< + typeof ZGetDownloadLinkOfCalVideoRecordingsInputSchema +>; diff --git a/packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.handler.ts b/packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.handler.ts new file mode 100644 index 0000000000..bfe662c1fc --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.handler.ts @@ -0,0 +1,14 @@ +import { userMetadata } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type GetUsersDefaultConferencingAppOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const getUsersDefaultConferencingAppHandler = async ({ + ctx, +}: GetUsersDefaultConferencingAppOptions) => { + return userMetadata.parse(ctx.user.metadata)?.defaultConferencingApp; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.schema.ts b/packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts new file mode 100644 index 0000000000..e312c9b2d7 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts @@ -0,0 +1,52 @@ +import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TIntegrationsInputSchema } from "./integrations.schema"; + +type IntegrationsOptions = { + ctx: { + user: NonNullable; + }; + input: TIntegrationsInputSchema; +}; + +export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) => { + const { user } = ctx; + const { variant, exclude, onlyInstalled } = input; + const { credentials } = user; + + const enabledApps = await getEnabledApps(credentials); + //TODO: Refactor this to pick up only needed fields and prevent more leaking + let apps = enabledApps.map( + ({ credentials: _, credential: _1, key: _2 /* don't leak to frontend */, ...app }) => { + const credentialIds = credentials.filter((c) => c.type === app.type).map((c) => c.id); + const invalidCredentialIds = credentials + .filter((c) => c.type === app.type && c.invalid) + .map((c) => c.id); + return { + ...app, + credentialIds, + invalidCredentialIds, + }; + } + ); + + if (variant) { + // `flatMap()` these work like `.filter()` but infers the types correctly + apps = apps + // variant check + .flatMap((item) => (item.variant.startsWith(variant) ? [item] : [])); + } + + if (exclude) { + // exclusion filter + apps = apps.filter((item) => (exclude ? !exclude.includes(item.variant) : true)); + } + + if (onlyInstalled) { + apps = apps.flatMap((item) => (item.credentialIds.length > 0 || item.isGlobal ? [item] : [])); + } + return { + items: apps, + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts b/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts new file mode 100644 index 0000000000..19ac998e71 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZIntegrationsInputSchema = z.object({ + variant: z.string().optional(), + exclude: z.array(z.string()).optional(), + onlyInstalled: z.boolean().optional(), +}); + +export type TIntegrationsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/locationOptions.handler.ts b/packages/trpc/server/routers/loggedInViewer/locationOptions.handler.ts new file mode 100644 index 0000000000..4a09416c3e --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/locationOptions.handler.ts @@ -0,0 +1,35 @@ +import { getLocationGroupedOptions } from "@calcom/app-store/utils"; +import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type LocationOptionsOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const locationOptionsHandler = async ({ ctx }: LocationOptionsOptions) => { + const credentials = await prisma.credential.findMany({ + where: { + userId: ctx.user.id, + }, + select: { + id: true, + type: true, + key: true, + userId: true, + appId: true, + invalid: true, + }, + }); + + const integrations = await getEnabledApps(credentials); + + const t = await getTranslation(ctx.user.locale ?? "en", "common"); + + const locationOptions = getLocationGroupedOptions(integrations, t); + + return locationOptions; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/locationOptions.schema.ts b/packages/trpc/server/routers/loggedInViewer/locationOptions.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/locationOptions.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/me.handler.ts b/packages/trpc/server/routers/loggedInViewer/me.handler.ts new file mode 100644 index 0000000000..be958a5e43 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/me.handler.ts @@ -0,0 +1,41 @@ +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type MeOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const meHandler = async ({ ctx }: MeOptions) => { + const { user } = ctx; + // 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, + defaultScheduleId: user.defaultScheduleId, + completedOnboarding: user.completedOnboarding, + twoFactorEnabled: user.twoFactorEnabled, + disableImpersonation: user.disableImpersonation, + identityProvider: user.identityProvider, + brandColor: user.brandColor, + darkBrandColor: user.darkBrandColor, + away: user.away, + bio: user.bio, + weekStart: user.weekStart, + theme: user.theme, + hideBranding: user.hideBranding, + metadata: user.metadata, + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/me.schema.ts b/packages/trpc/server/routers/loggedInViewer/me.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/me.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts new file mode 100644 index 0000000000..55343b4f32 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts @@ -0,0 +1,65 @@ +import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema"; + +type SetDestinationCalendarOptions = { + ctx: { + user: NonNullable; + }; + input: TSetDestinationCalendarInputSchema; +}; + +export const setDestinationCalendarHandler = async ({ ctx, input }: SetDestinationCalendarOptions) => { + const { user } = ctx; + const { integration, externalId, eventTypeId } = input; + const calendarCredentials = getCalendarCredentials(user.credentials); + 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) { + if ( + !(await prisma.eventType.findFirst({ + where: { + id: eventTypeId, + userId: user.id, + }, + })) + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: `You don't have access to event type ${eventTypeId}`, + }); + } + + where = { eventTypeId }; + } else where = { userId: user.id }; + + await prisma.destinationCalendar.upsert({ + where, + update: { + integration, + externalId, + credentialId, + }, + create: { + ...where, + integration, + externalId, + credentialId, + }, + }); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.schema.ts b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.schema.ts new file mode 100644 index 0000000000..8741b05869 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZSetDestinationCalendarInputSchema = z.object({ + integration: z.string(), + externalId: z.string(), + eventTypeId: z.number().nullish(), + bookingId: z.number().nullish(), +}); + +export type TSetDestinationCalendarInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts b/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts new file mode 100644 index 0000000000..857e865387 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts @@ -0,0 +1,50 @@ +import stripe from "@calcom/app-store/stripepayment/lib/server"; +import { prisma } from "@calcom/prisma"; +import { userMetadata } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +type StripeCustomerOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const stripeCustomerHandler = async ({ ctx }: StripeCustomerOptions) => { + const { + user: { id: userId }, + } = 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); + + if (!metadata?.stripeCustomerId) { + throw new TRPCError({ code: "BAD_REQUEST", message: "No stripe customer id" }); + } + // Fetch stripe customer + const stripeCustomerId = metadata?.stripeCustomerId; + const customer = await stripe.customers.retrieve(stripeCustomerId); + if (customer.deleted) { + throw new TRPCError({ code: "BAD_REQUEST", message: "No stripe customer found" }); + } + + const username = customer?.metadata?.username || null; + + return { + isPremium: !!metadata?.isPremium, + username, + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/stripeCustomer.schema.ts b/packages/trpc/server/routers/loggedInViewer/stripeCustomer.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/stripeCustomer.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/submitFeedback.handler.ts b/packages/trpc/server/routers/loggedInViewer/submitFeedback.handler.ts new file mode 100644 index 0000000000..27db135abb --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/submitFeedback.handler.ts @@ -0,0 +1,35 @@ +import dayjs from "@calcom/dayjs"; +import { sendFeedbackEmail } from "@calcom/emails"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TSubmitFeedbackInputSchema } from "./submitFeedback.schema"; + +type SubmitFeedbackOptions = { + ctx: { + user: NonNullable; + }; + input: TSubmitFeedbackInputSchema; +}; + +export const submitFeedbackHandler = async ({ ctx, input }: SubmitFeedbackOptions) => { + const { rating, comment } = input; + + const feedback = { + username: ctx.user.username || "Nameless", + email: ctx.user.email || "No email address", + rating: rating, + comment: comment, + }; + + await prisma.feedback.create({ + data: { + date: dayjs().toISOString(), + userId: ctx.user.id, + rating: rating, + comment: comment, + }, + }); + + if (process.env.SEND_FEEDBACK_EMAIL && comment) sendFeedbackEmail(feedback); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/submitFeedback.schema.ts b/packages/trpc/server/routers/loggedInViewer/submitFeedback.schema.ts new file mode 100644 index 0000000000..f56091c1ab --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/submitFeedback.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZSubmitFeedbackInputSchema = z.object({ + rating: z.string(), + comment: z.string(), +}); + +export type TSubmitFeedbackInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts new file mode 100644 index 0000000000..7d9debf1e4 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts @@ -0,0 +1,140 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiResponse, GetServerSidePropsContext } from "next"; + +import stripe from "@calcom/app-store/stripepayment/lib/server"; +import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils"; +import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; +import { checkUsername } from "@calcom/lib/server/checkUsername"; +import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; +import slugify from "@calcom/lib/slugify"; +import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import { userMetadata } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TUpdateProfileInputSchema } from "./updateProfile.schema"; + +type UpdateProfileOptions = { + ctx: { + user: NonNullable; + res?: NextApiResponse | GetServerSidePropsContext["res"]; + }; + input: TUpdateProfileInputSchema; +}; + +export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => { + const { user } = ctx; + const data: Prisma.UserUpdateInput = { + ...input, + metadata: input.metadata as Prisma.InputJsonValue, + }; + 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); + + const isPremium = metadata?.isPremium; + if (isPremiumUsername) { + const stripeCustomerId = metadata?.stripeCustomerId; + if (!isPremium || !stripeCustomerId) { + throw new TRPCError({ code: "BAD_REQUEST", message: "User is not premium" }); + } + + const stripeSubscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId }); + + if (!stripeSubscriptions || !stripeSubscriptions.data.length) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "No stripeSubscription found", + }); + } + + // Iterate over subscriptions and look for premium product id and status active + // @TODO: iterate if stripeSubscriptions.hasMore is true + const isPremiumUsernameSubscriptionActive = stripeSubscriptions.data.some( + (subscription) => + subscription.items.data[0].price.product === getPremiumPlanProductId() && + subscription.status === "active" + ); + + if (!isPremiumUsernameSubscriptionActive) { + 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, + 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, + }, + }); + } + // Revalidate booking pages + const res = ctx.res as NextApiResponse; + if (typeof res?.revalidate !== "undefined") { + const eventTypes = await prisma.eventType.findMany({ + where: { + userId: user.id, + team: null, + hidden: false, + }, + select: { + id: true, + slug: true, + }, + }); + // waiting for this isn't needed + Promise.all(eventTypes.map((eventType) => res?.revalidate(`/${ctx.user.username}/${eventType.slug}`))) + .then(() => console.info("Booking pages revalidated")) + .catch((e) => console.error(e)); + } +}; diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts new file mode 100644 index 0000000000..7a3f1f8f46 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; +import { userMetadata } from "@calcom/prisma/zod-utils"; + +export const ZUpdateProfileInputSchema = z.object({ + username: z.string().optional(), + name: z.string().max(FULL_NAME_LENGTH_MAX_LIMIT).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(), + metadata: userMetadata.optional(), +}); + +export type TUpdateProfileInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.handler.ts new file mode 100644 index 0000000000..a34ba812d6 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.handler.ts @@ -0,0 +1,59 @@ +import z from "zod"; + +import getApps from "@calcom/app-store/utils"; +import { prisma } from "@calcom/prisma"; +import { userMetadata } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TUpdateUserDefaultConferencingAppInputSchema } from "./updateUserDefaultConferencingApp.schema"; + +type UpdateUserDefaultConferencingAppOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateUserDefaultConferencingAppInputSchema; +}; + +export const updateUserDefaultConferencingAppHandler = async ({ + ctx, + input, +}: UpdateUserDefaultConferencingAppOptions) => { + const currentMetadata = userMetadata.parse(ctx.user.metadata); + const credentials = ctx.user.credentials; + const foundApp = getApps(credentials).filter((app) => app.slug === input.appSlug)[0]; + const appLocation = foundApp?.appData?.location; + + if (!foundApp || !appLocation) throw new TRPCError({ code: "BAD_REQUEST", message: "App not installed" }); + + if (appLocation.linkType === "static" && !input.appLink) { + throw new TRPCError({ code: "BAD_REQUEST", message: "App link is required" }); + } + + if (appLocation.linkType === "static" && appLocation.urlRegExp) { + const validLink = z + .string() + .regex(new RegExp(appLocation.urlRegExp), "Invalid App Link") + .parse(input.appLink); + if (!validLink) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid app link" }); + } + } + + await prisma.user.update({ + where: { + id: ctx.user.id, + }, + data: { + metadata: { + ...currentMetadata, + defaultConferencingApp: { + appSlug: input.appSlug, + appLink: input.appLink, + }, + }, + }, + }); + return input; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.schema.ts b/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.schema.ts new file mode 100644 index 0000000000..31b7a10179 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZUpdateUserDefaultConferencingAppInputSchema = z.object({ + appSlug: z.string().optional(), + appLink: z.string().optional(), +}); + +export type TUpdateUserDefaultConferencingAppInputSchema = z.infer< + typeof ZUpdateUserDefaultConferencingAppInputSchema +>; diff --git a/packages/trpc/server/routers/publicViewer/_router.tsx b/packages/trpc/server/routers/publicViewer/_router.tsx new file mode 100644 index 0000000000..6db1cca597 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/_router.tsx @@ -0,0 +1,140 @@ +import { publicProcedure, router } from "../../trpc"; +import { slotsRouter } from "../viewer/slots/_router"; +import { ZEventInputSchema } from "./event.schema"; +import { ZSamlTenantProductInputSchema } from "./samlTenantProduct.schema"; +import { ZStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema"; + +type PublicViewerRouterHandlerCache = { + session?: typeof import("./session.handler").sessionHandler; + i18n?: typeof import("./i18n.handler").i18nHandler; + countryCode?: typeof import("./countryCode.handler").countryCodeHandler; + samlTenantProduct?: typeof import("./samlTenantProduct.handler").samlTenantProductHandler; + stripeCheckoutSession?: typeof import("./stripeCheckoutSession.handler").stripeCheckoutSessionHandler; + cityTimezones?: typeof import("./cityTimezones.handler").cityTimezonesHandler; + event?: typeof import("./event.handler").eventHandler; +}; + +const UNSTABLE_HANDLER_CACHE: PublicViewerRouterHandlerCache = {}; + +// things that unauthenticated users can query about themselves +export const publicViewerRouter = router({ + session: publicProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.session) { + UNSTABLE_HANDLER_CACHE.session = await import("./session.handler").then((mod) => mod.sessionHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.session) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.session({ + ctx, + }); + }), + + i18n: publicProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.i18n) { + UNSTABLE_HANDLER_CACHE.i18n = await import("./i18n.handler").then((mod) => mod.i18nHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.i18n) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.i18n({ + ctx, + }); + }), + + countryCode: publicProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.countryCode) { + UNSTABLE_HANDLER_CACHE.countryCode = await import("./countryCode.handler").then( + (mod) => mod.countryCodeHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.countryCode) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.countryCode({ + ctx, + }); + }), + + samlTenantProduct: publicProcedure.input(ZSamlTenantProductInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.samlTenantProduct) { + UNSTABLE_HANDLER_CACHE.samlTenantProduct = await import("./samlTenantProduct.handler").then( + (mod) => mod.samlTenantProductHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.samlTenantProduct) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.samlTenantProduct({ + ctx, + input, + }); + }), + + stripeCheckoutSession: publicProcedure + .input(ZStripeCheckoutSessionInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.stripeCheckoutSession) { + UNSTABLE_HANDLER_CACHE.stripeCheckoutSession = await import("./stripeCheckoutSession.handler").then( + (mod) => mod.stripeCheckoutSessionHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.stripeCheckoutSession) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.stripeCheckoutSession({ + ctx, + input, + }); + }), + + cityTimezones: publicProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.cityTimezones) { + UNSTABLE_HANDLER_CACHE.cityTimezones = await import("./cityTimezones.handler").then( + (mod) => mod.cityTimezonesHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.cityTimezones) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.cityTimezones({ + ctx, + }); + }), + + // REVIEW: This router is part of both the public and private viewer router? + slots: slotsRouter, + event: publicProcedure.input(ZEventInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.event) { + UNSTABLE_HANDLER_CACHE.event = await import("./event.handler").then((mod) => mod.eventHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.event) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.event({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/publicViewer/cityTimezones.handler.ts b/packages/trpc/server/routers/publicViewer/cityTimezones.handler.ts new file mode 100644 index 0000000000..2228fe65f7 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/cityTimezones.handler.ts @@ -0,0 +1,38 @@ +import type { Session } from "next-auth"; + +type CityTimezonesOptions = { + ctx: { + session: Session | null; + }; +}; + +export const cityTimezonesHandler = async ({ ctx: _ctx }: CityTimezonesOptions) => { + /** + * Lazy loads third party dependency to avoid loading 1.5Mb for ALL tRPC procedures. + * Thanks @roae for the tip 🙏 + **/ + const allCities = await import("city-timezones").then((mod) => mod.cityMapping); + /** + * Filter out all cities that have the same "city" key and only use the one with the highest population. + * This way we return a new array of cities without running the risk of having more than one city + * with the same name on the dropdown and prevent users from mistaking the time zone of the desired city. + */ + const topPopulatedCities: { [key: string]: { city: string; timezone: string; pop: number } } = {}; + allCities.forEach((city) => { + const cityPopulationCount = city.pop; + if ( + topPopulatedCities[city.city]?.pop === undefined || + cityPopulationCount > topPopulatedCities[city.city].pop + ) { + topPopulatedCities[city.city] = { city: city.city, timezone: city.timezone, pop: city.pop }; + } + }); + const uniqueCities = Object.values(topPopulatedCities); + /** Add specific overries in here */ + uniqueCities.forEach((city) => { + if (city.city === "London") city.timezone = "Europe/London"; + if (city.city === "Londonderry") city.city = "London"; + }); + + return uniqueCities; +}; diff --git a/packages/trpc/server/routers/publicViewer/cityTimezones.schema.ts b/packages/trpc/server/routers/publicViewer/cityTimezones.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/cityTimezones.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/publicViewer/countryCode.handler.ts b/packages/trpc/server/routers/publicViewer/countryCode.handler.ts new file mode 100644 index 0000000000..ca72cdd95a --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/countryCode.handler.ts @@ -0,0 +1,12 @@ +import type { CreateInnerContextOptions } from "../../createContext"; + +type CountryCodeOptions = { + ctx: CreateInnerContextOptions; +}; + +export const countryCodeHandler = async ({ ctx }: CountryCodeOptions) => { + const { req } = ctx; + + const countryCode: string | string[] = req?.headers?.["x-vercel-ip-country"] ?? ""; + return { countryCode: Array.isArray(countryCode) ? countryCode[0] : countryCode }; +}; diff --git a/packages/trpc/server/routers/publicViewer/countryCode.schema.ts b/packages/trpc/server/routers/publicViewer/countryCode.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/countryCode.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/publicViewer/event.handler.ts b/packages/trpc/server/routers/publicViewer/event.handler.ts new file mode 100644 index 0000000000..8933f38d68 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/event.handler.ts @@ -0,0 +1,15 @@ +import type { PrismaClient } from "@prisma/client"; + +import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent"; + +import type { TEventInputSchema } from "./event.schema"; + +interface EventHandlerOptions { + ctx: { prisma: PrismaClient }; + input: TEventInputSchema; +} + +export const eventHandler = async ({ ctx, input }: EventHandlerOptions) => { + const event = await getPublicEvent(input.username, input.eventSlug, ctx.prisma); + return event; +}; diff --git a/packages/trpc/server/routers/publicViewer/event.schema.ts b/packages/trpc/server/routers/publicViewer/event.schema.ts new file mode 100644 index 0000000000..74e67be447 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/event.schema.ts @@ -0,0 +1,8 @@ +import z from "zod"; + +export const ZEventInputSchema = z.object({ + username: z.string(), + eventSlug: z.string(), +}); + +export type TEventInputSchema = z.infer; diff --git a/packages/trpc/server/routers/publicViewer/i18n.handler.ts b/packages/trpc/server/routers/publicViewer/i18n.handler.ts new file mode 100644 index 0000000000..82953994b7 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/i18n.handler.ts @@ -0,0 +1,15 @@ +import type { CreateInnerContextOptions } from "../../createContext"; +import { getLocale } from "../../trpc"; + +type I18nOptions = { + ctx: CreateInnerContextOptions; +}; + +export const i18nHandler = async ({ ctx }: I18nOptions) => { + const { locale, i18n } = await getLocale(ctx); + + return { + i18n, + locale, + }; +}; diff --git a/packages/trpc/server/routers/publicViewer/i18n.schema.ts b/packages/trpc/server/routers/publicViewer/i18n.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/i18n.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/publicViewer/samlTenantProduct.handler.ts b/packages/trpc/server/routers/publicViewer/samlTenantProduct.handler.ts new file mode 100644 index 0000000000..272760e7d1 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/samlTenantProduct.handler.ts @@ -0,0 +1,19 @@ +import type { Session } from "next-auth"; + +import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml"; +import { prisma } from "@calcom/prisma"; + +import type { TSamlTenantProductInputSchema } from "./samlTenantProduct.schema"; + +type SamlTenantProductOptions = { + ctx: { + session: Session | null; + }; + input: TSamlTenantProductInputSchema; +}; + +export const samlTenantProductHandler = async ({ ctx: _ctx, input }: SamlTenantProductOptions) => { + const { email } = input; + + return await samlTenantProduct(prisma, email); +}; diff --git a/packages/trpc/server/routers/publicViewer/samlTenantProduct.schema.ts b/packages/trpc/server/routers/publicViewer/samlTenantProduct.schema.ts new file mode 100644 index 0000000000..f54d1e47cf --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/samlTenantProduct.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZSamlTenantProductInputSchema = z.object({ + email: z.string().email(), +}); + +export type TSamlTenantProductInputSchema = z.infer; diff --git a/packages/trpc/server/routers/publicViewer/session.handler.ts b/packages/trpc/server/routers/publicViewer/session.handler.ts new file mode 100644 index 0000000000..1b4b4454f0 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/session.handler.ts @@ -0,0 +1,11 @@ +import type { Session } from "next-auth"; + +type SessionOptions = { + ctx: { + session: Session | null; + }; +}; + +export const sessionHandler = async ({ ctx }: SessionOptions) => { + return ctx.session; +}; diff --git a/packages/trpc/server/routers/publicViewer/session.schema.ts b/packages/trpc/server/routers/publicViewer/session.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/session.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts new file mode 100644 index 0000000000..7bc54152a6 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts @@ -0,0 +1,72 @@ +import type { Session } from "next-auth"; + +import stripe from "@calcom/app-store/stripepayment/lib/server"; + +import type { TStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema"; + +type StripeCheckoutSessionOptions = { + ctx: { + session: Session | null; + }; + input: TStripeCheckoutSessionInputSchema; +}; + +export const stripeCheckoutSessionHandler = async ({ input }: StripeCheckoutSessionOptions) => { + 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, + }; + } +}; diff --git a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts new file mode 100644 index 0000000000..5e307112f2 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZStripeCheckoutSessionInputSchema = z.object({ + stripeCustomerId: z.string().optional(), + checkoutSessionId: z.string().optional(), +}); + +export type TStripeCheckoutSessionInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx deleted file mode 100644 index 8ac94e6b4f..0000000000 --- a/packages/trpc/server/routers/viewer.tsx +++ /dev/null @@ -1,1356 +0,0 @@ -import type { DestinationCalendar, Prisma } from "@prisma/client"; -import { AppCategories, BookingStatus, IdentityProvider } from "@prisma/client"; -import { reverse } from "lodash"; -import type { NextApiResponse } from "next"; -import { authenticator } from "otplib"; -import z from "zod"; - -import ethRouter from "@calcom/app-store/rainbow/trpc/router"; -import app_RoutingForms from "@calcom/app-store/routing-forms/trpc-router"; -import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer"; -import stripe from "@calcom/app-store/stripepayment/lib/server"; -import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils"; -import getApps, { getLocationGroupedOptions } 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 { - getDownloadLinkOfCalVideoByRecordingId, - getRecordingsOfCalVideoByRoomName, -} from "@calcom/core/videoClient"; -import dayjs from "@calcom/dayjs"; -import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails"; -import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; -import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; -import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; -import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml"; -import { userAdminRouter } from "@calcom/features/ee/users/server/trpc-router"; -import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent"; -import { featureFlagRouter } from "@calcom/features/flags/server/router"; -import { insightsRouter } from "@calcom/features/insights/server/trpc-router"; -import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; -import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; -import { FULL_NAME_LENGTH_MAX_LIMIT, IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants"; -import { symmetricDecrypt } from "@calcom/lib/crypto"; -import getPaymentAppData from "@calcom/lib/getPaymentAppData"; -import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; -import { deletePayment } from "@calcom/lib/payment/deletePayment"; -import { checkUsername } from "@calcom/lib/server/checkUsername"; -import { getTranslation } from "@calcom/lib/server/i18n"; -import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; -import slugify from "@calcom/lib/slugify"; -import { - deleteWebUser as syncServicesDeleteWebUser, - updateWebUser as syncServicesUpdateWebUser, -} from "@calcom/lib/sync/SyncServiceManager"; -import prisma, { bookingMinimalSelect } from "@calcom/prisma"; -import { EventTypeMetaDataSchema, userMetadata } from "@calcom/prisma/zod-utils"; - -import { TRPCError } from "@trpc/server"; - -import { authedProcedure, getLocale, mergeRouters, publicProcedure, router } from "../trpc"; -import { apiKeysRouter } from "./viewer/apiKeys"; -import { appsRouter } from "./viewer/apps"; -import { authRouter } from "./viewer/auth"; -import { availabilityRouter } from "./viewer/availability"; -import { bookingsRouter } from "./viewer/bookings"; -import { deploymentSetupRouter } from "./viewer/deploymentSetup"; -import { eventTypesRouter } from "./viewer/eventTypes"; -import { paymentsRouter } from "./viewer/payments"; -import { slotsRouter } from "./viewer/slots"; -import { ssoRouter } from "./viewer/sso"; -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 = router({ - session: publicProcedure.query(({ ctx }) => { - return ctx.session; - }), - i18n: publicProcedure.query(async ({ ctx }) => { - const { locale, i18n } = await getLocale(ctx); - - return { - i18n, - locale, - }; - }), - countryCode: publicProcedure.query(({ ctx }) => { - const { req } = ctx; - - const countryCode: string | string[] = req?.headers?.["x-vercel-ip-country"] ?? ""; - return { countryCode: Array.isArray(countryCode) ? countryCode[0] : countryCode }; - }), - samlTenantProduct: publicProcedure - .input( - z.object({ - email: z.string().email(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { prisma } = ctx; - const { email } = input; - - return await samlTenantProduct(prisma, email); - }), - stripeCheckoutSession: publicProcedure - .input( - z.object({ - stripeCustomerId: z.string().optional(), - checkoutSessionId: z.string().optional(), - }) - ) - .query(async ({ 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, - }; - } - }), - slots: slotsRouter, - event: publicProcedure - .input( - z.object({ - username: z.string(), - eventSlug: z.string(), - }) - ) - .query(async ({ ctx, input }) => { - const event = await getPublicEvent(input.username, input.eventSlug, ctx.prisma); - return event; - }), - cityTimezones: publicProcedure.query(async () => { - /** - * Lazy loads third party dependency to avoid loading 1.5Mb for ALL tRPC procedures. - * Thanks @roae for the tip 🙏 - **/ - const allCities = await import("city-timezones").then((mod) => mod.cityMapping); - /** - * Filter out all cities that have the same "city" key and only use the one with the highest population. - * This way we return a new array of cities without running the risk of having more than one city - * with the same name on the dropdown and prevent users from mistaking the time zone of the desired city. - */ - const topPopulatedCities: { [key: string]: { city: string; timezone: string; pop: number } } = {}; - allCities.forEach((city) => { - const cityPopulationCount = city.pop; - if ( - topPopulatedCities[city.city]?.pop === undefined || - cityPopulationCount > topPopulatedCities[city.city].pop - ) { - topPopulatedCities[city.city] = { city: city.city, timezone: city.timezone, pop: city.pop }; - } - }); - const uniqueCities = Object.values(topPopulatedCities); - /** Add specific overries in here */ - uniqueCities.forEach((city) => { - if (city.city === "London") city.timezone = "Europe/London"; - if (city.city === "Londonderry") city.city = "London"; - }); - return uniqueCities; - }), -}); - -// routes only available to authenticated users -const loggedInViewerRouter = router({ - me: authedProcedure.query(async ({ ctx }) => { - const { user } = ctx; - // 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, - defaultScheduleId: user.defaultScheduleId, - completedOnboarding: user.completedOnboarding, - twoFactorEnabled: user.twoFactorEnabled, - disableImpersonation: user.disableImpersonation, - identityProvider: user.identityProvider, - brandColor: user.brandColor, - darkBrandColor: user.darkBrandColor, - away: user.away, - bio: user.bio, - weekStart: user.weekStart, - theme: user.theme, - hideBranding: user.hideBranding, - metadata: user.metadata, - }; - }), - avatar: authedProcedure.query(({ ctx }) => ({ - avatar: ctx.user.rawAvatar, - })), - deleteMe: authedProcedure - .input( - z.object({ - password: z.string(), - totpCode: z.string().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - // 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); - } - - // If user has 2fa enabled, check if input.totpCode is correct - const isValidToken = authenticator.check(input.totpCode, secret); - if (!isValidToken) { - throw new Error(ErrorCode.IncorrectTwoFactorCode); - } - } - - // If 2FA is disabled or totpCode is valid then delete the user from stripe and database - 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; - }), - deleteMeWithoutPassword: authedProcedure.mutation(async ({ ctx }) => { - 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.SocialIdentityProviderRequired); - } - - if (user.twoFactorEnabled) { - throw new Error(ErrorCode.SocialIdentityProviderRequired); - } - - // 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; - }), - away: authedProcedure - .input( - z.object({ - away: z.boolean(), - }) - ) - .mutation(async ({ ctx, input }) => { - await ctx.prisma.user.update({ - where: { - email: ctx.user.email, - }, - data: { - away: input.away, - }, - }); - }), - connectedCalendars: authedProcedure.query(async ({ ctx }) => { - const { user, prisma } = ctx; - - const userCredentials = await prisma.credential.findMany({ - where: { - userId: ctx.user.id, - app: { - categories: { has: AppCategories.calendar }, - enabled: true, - }, - }, - }); - - // get user's credentials + their connected integrations - const calendarCredentials = getCalendarCredentials(userCredentials); - - // get all the connected integrations' calendars (from third party) - const { connectedCalendars, destinationCalendar } = await getConnectedCalendars( - calendarCredentials, - user.selectedCalendars, - user.destinationCalendar?.externalId - ); - - 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, email } = 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 as DestinationCalendar), - ...destinationCalendar, - }, - }; - }), - setDestinationCalendar: authedProcedure - .input( - z.object({ - integration: z.string(), - externalId: z.string(), - eventTypeId: z.number().nullish(), - bookingId: z.number().nullish(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user } = ctx; - const { integration, externalId, eventTypeId } = input; - const calendarCredentials = getCalendarCredentials(user.credentials); - 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) { - if ( - !(await prisma.eventType.findFirst({ - where: { - id: eventTypeId, - userId: user.id, - }, - })) - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: `You don't have access to event type ${eventTypeId}`, - }); - } - - where = { eventTypeId }; - } else where = { userId: user.id }; - - await ctx.prisma.destinationCalendar.upsert({ - where, - update: { - integration, - externalId, - credentialId, - }, - create: { - ...where, - integration, - externalId, - credentialId, - }, - }); - }), - integrations: authedProcedure - .input( - z.object({ - variant: z.string().optional(), - exclude: z.array(z.string()).optional(), - onlyInstalled: z.boolean().optional(), - }) - ) - .query(async ({ ctx, input }) => { - const { user } = ctx; - const { variant, exclude, onlyInstalled } = input; - const { credentials } = user; - - const enabledApps = await getEnabledApps(credentials); - //TODO: Refactor this to pick up only needed fields and prevent more leaking - let apps = enabledApps.map( - ({ credentials: _, credential: _1, key: _2 /* don't leak to frontend */, ...app }) => { - const credentialIds = credentials.filter((c) => c.type === app.type).map((c) => c.id); - const invalidCredentialIds = credentials - .filter((c) => c.type === app.type && c.invalid) - .map((c) => c.id); - return { - ...app, - credentialIds, - invalidCredentialIds, - }; - } - ); - - if (variant) { - // `flatMap()` these work like `.filter()` but infers the types correctly - apps = apps - // variant check - .flatMap((item) => (item.variant.startsWith(variant) ? [item] : [])); - } - - if (exclude) { - // exclusion filter - apps = apps.filter((item) => (exclude ? !exclude.includes(item.variant) : true)); - } - - if (onlyInstalled) { - apps = apps.flatMap((item) => (item.credentialIds.length > 0 || item.isGlobal ? [item] : [])); - } - return { - items: apps, - }; - }), - appById: authedProcedure - .input( - z.object({ - appId: z.string(), - }) - ) - .query(async ({ ctx, input }) => { - const { user } = ctx; - const appId = input.appId; - const { credentials } = user; - const apps = getApps(credentials); - const appFromDb = apps.find((app) => app.slug === appId); - if (!appFromDb) { - throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find app ${appId}` }); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { credential: _, credentials: _1, ...app } = appFromDb; - return { - isInstalled: appFromDb.credentials.length, - ...app, - }; - }), - apps: authedProcedure - .input( - z.object({ - extendsFeature: z.literal("EventType"), - }) - ) - .query(async ({ ctx, input }) => { - const { user } = ctx; - const { credentials } = user; - - const apps = await getEnabledApps(credentials); - return apps - .filter((app) => app.extendsFeature?.includes(input.extendsFeature)) - .map((app) => ({ - ...app, - isInstalled: !!app.credentials?.length, - })); - }), - appCredentialsByType: authedProcedure - .input( - z.object({ - appType: z.string(), - }) - ) - .query(async ({ ctx, input }) => { - const { user } = ctx; - return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id); - }), - stripeCustomer: authedProcedure.query(async ({ 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); - - if (!metadata?.stripeCustomerId) { - throw new TRPCError({ code: "BAD_REQUEST", message: "No stripe customer id" }); - } - // Fetch stripe customer - const stripeCustomerId = metadata?.stripeCustomerId; - const customer = await stripe.customers.retrieve(stripeCustomerId); - if (customer.deleted) { - throw new TRPCError({ code: "BAD_REQUEST", message: "No stripe customer found" }); - } - - const username = customer?.metadata?.username || null; - - return { - isPremium: !!metadata?.isPremium, - username, - }; - }), - updateProfile: authedProcedure - .input( - z.object({ - username: z.string().optional(), - name: z.string().max(FULL_NAME_LENGTH_MAX_LIMIT).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(), - metadata: userMetadata.optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user, prisma } = ctx; - const data: Prisma.UserUpdateInput = { - ...input, - metadata: input.metadata as Prisma.InputJsonValue, - }; - 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); - - const isPremium = metadata?.isPremium; - if (isPremiumUsername) { - const stripeCustomerId = metadata?.stripeCustomerId; - if (!isPremium || !stripeCustomerId) { - throw new TRPCError({ code: "BAD_REQUEST", message: "User is not premium" }); - } - - const stripeSubscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId }); - - if (!stripeSubscriptions || !stripeSubscriptions.data.length) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "No stripeSubscription found", - }); - } - - // Iterate over subscriptions and look for premium product id and status active - // @TODO: iterate if stripeSubscriptions.hasMore is true - const isPremiumUsernameSubscriptionActive = stripeSubscriptions.data.some( - (subscription) => - subscription.items.data[0].price.product === getPremiumPlanProductId() && - subscription.status === "active" - ); - - if (!isPremiumUsernameSubscriptionActive) { - 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, - 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, - }, - }); - } - // Revalidate booking pages - const res = ctx.res as NextApiResponse; - if (typeof res?.revalidate !== "undefined") { - const eventTypes = await prisma.eventType.findMany({ - where: { - userId: user.id, - team: null, - hidden: false, - }, - select: { - id: true, - slug: true, - }, - }); - // waiting for this isn't needed - Promise.all(eventTypes.map((eventType) => res?.revalidate(`/${ctx.user.username}/${eventType.slug}`))) - .then(() => console.info("Booking pages revalidated")) - .catch((e) => console.error(e)); - } - }), - eventTypeOrder: authedProcedure - .input( - z.object({ - ids: z.array(z.number()), - }) - ) - .mutation(async ({ ctx, input }) => { - 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, - }, - }); - }) - ); - }), - //Comment for PR: eventTypePosition is not used anywhere - submitFeedback: authedProcedure - .input( - z.object({ - rating: z.string(), - comment: z.string(), - }) - ) - .mutation(async ({ ctx, input }) => { - 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); - }), - locationOptions: authedProcedure.query(async ({ ctx }) => { - const credentials = await prisma.credential.findMany({ - where: { - userId: ctx.user.id, - }, - select: { - id: true, - type: true, - key: true, - userId: true, - appId: true, - invalid: true, - }, - }); - - const integrations = await getEnabledApps(credentials); - - const t = await getTranslation(ctx.user.locale ?? "en", "common"); - - const locationOptions = getLocationGroupedOptions(integrations, t); - - return locationOptions; - }), - deleteCredential: authedProcedure - .input( - z.object({ - id: z.number(), - externalId: z.string().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id, externalId } = input; - - const credential = await prisma.credential.findFirst({ - where: { - id: id, - userId: ctx.user.id, - }, - select: { - key: true, - appId: true, - app: { - select: { - slug: true, - categories: true, - dirName: 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: { - include: { - credential: true, - }, - }, - price: true, - currency: true, - metadata: true, - }, - }); - - // TODO: Improve this uninstallation cleanup per event by keeping a relation of EventType to App which has the data. - 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?.credential?.appId === credential.appId) { - 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, - }, - }, - }); - } - } - } - - const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); - - const stripeAppData = getPaymentAppData({ ...eventType, metadata }); - - // 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 (stripeAppData.price) { - await prisma.$transaction(async () => { - await prisma.eventType.update({ - where: { - id: eventType.id, - }, - data: { - hidden: true, - metadata: { - ...metadata, - apps: { - ...metadata?.apps, - stripe: { - ...metadata?.apps?.stripe, - 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, - responses: true, - user: { - select: { - id: 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, - bookingFields: true, - seatsPerTimeSlot: true, - seatsShowAttendees: 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) { - try { - await deletePayment(payment.id, credential); - } catch (e) { - console.error(e); - } - 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), - ...getCalEventResponses({ - bookingFields: booking.eventType?.bookingFields ?? null, - booking, - }), - 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", - seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, - seatsShowAttendees: booking.eventType?.seatsShowAttendees, - }); - } - }); - } - } - } - - // 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: { - userId: ctx.user.id, - 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, - }, - }); - // Revalidate user calendar cache. - if (credential.app?.slug.includes("calendar")) { - await fetch(`${WEBAPP_URL}/api/revalidate-calendar-cache/${ctx?.user?.username}`); - } - }), - bookingUnconfirmedCount: authedProcedure.query(async ({ ctx }) => { - const { prisma, user } = ctx; - const count = await prisma.booking.count({ - where: { - status: BookingStatus.PENDING, - userId: user.id, - endTime: { gt: new Date() }, - }, - }); - const recurringGrouping = await prisma.booking.groupBy({ - by: ["recurringEventId"], - _count: { - recurringEventId: true, - }, - where: { - recurringEventId: { not: { equals: null } }, - status: { equals: "PENDING" }, - userId: user.id, - endTime: { gt: new Date() }, - }, - }); - return recurringGrouping.reduce((prev, current) => { - // recurringEventId is the total number of recurring instances for a booking - // we need to subtract all but one, to represent a single recurring booking - return prev - (current._count?.recurringEventId - 1); - }, count); - }), - getCalVideoRecordings: authedProcedure - .input( - z.object({ - roomName: z.string(), - }) - ) - .query(async ({ input }) => { - const { roomName } = input; - - try { - const res = await getRecordingsOfCalVideoByRoomName(roomName); - return res; - } catch (err) { - throw new TRPCError({ - code: "BAD_REQUEST", - }); - } - }), - getDownloadLinkOfCalVideoRecordings: authedProcedure - .input( - z.object({ - recordingId: z.string(), - }) - ) - .query(async ({ input, ctx }) => { - const { recordingId } = input; - const { session } = ctx; - - const isDownloadAllowed = IS_SELF_HOSTED || session.user.belongsToActiveTeam; - - if (!isDownloadAllowed) { - throw new TRPCError({ - code: "FORBIDDEN", - }); - } - - try { - const res = await getDownloadLinkOfCalVideoByRecordingId(recordingId); - return res; - } catch (err) { - throw new TRPCError({ - code: "BAD_REQUEST", - }); - } - }), - getUsersDefaultConferencingApp: authedProcedure.query(async ({ ctx }) => { - return userMetadata.parse(ctx.user.metadata)?.defaultConferencingApp; - }), - updateUserDefaultConferencingApp: authedProcedure - .input( - z.object({ - appSlug: z.string().optional(), - appLink: z.string().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const currentMetadata = userMetadata.parse(ctx.user.metadata); - const credentials = ctx.user.credentials; - const foundApp = getApps(credentials).filter((app) => app.slug === input.appSlug)[0]; - const appLocation = foundApp?.appData?.location; - - if (!foundApp || !appLocation) - throw new TRPCError({ code: "BAD_REQUEST", message: "App not installed" }); - - if (appLocation.linkType === "static" && !input.appLink) { - throw new TRPCError({ code: "BAD_REQUEST", message: "App link is required" }); - } - - if (appLocation.linkType === "static" && appLocation.urlRegExp) { - const validLink = z - .string() - .regex(new RegExp(appLocation.urlRegExp), "Invalid App Link") - .parse(input.appLink); - if (!validLink) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid app link" }); - } - } - - await ctx.prisma.user.update({ - where: { - id: ctx.user.id, - }, - data: { - metadata: { - ...currentMetadata, - defaultConferencingApp: { - appSlug: input.appSlug, - appLink: input.appLink, - }, - }, - }, - }); - return input; - }), -}); - -export const viewerRouter = mergeRouters( - loggedInViewerRouter, - router({ - loggedInViewerRouter, - public: publicViewerRouter, - auth: authRouter, - deploymentSetup: deploymentSetupRouter, - bookings: bookingsRouter, - eventTypes: eventTypesRouter, - availability: availabilityRouter, - teams: viewerTeamsRouter, - webhook: webhookRouter, - apiKeys: apiKeysRouter, - workflows: workflowsRouter, - saml: ssoRouter, - insights: insightsRouter, - // 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. - appRoutingForms: app_RoutingForms, - eth: ethRouter, - features: featureFlagRouter, - payments: paymentsRouter, - appsRouter, - users: userAdminRouter, - }) -); diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx new file mode 100644 index 0000000000..762ffb5bc7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -0,0 +1,50 @@ +import ethRouter from "@calcom/app-store/rainbow/trpc/router"; +import app_RoutingForms from "@calcom/app-store/routing-forms/trpc-router"; +import { userAdminRouter } from "@calcom/features/ee/users/server/trpc-router"; +import { featureFlagRouter } from "@calcom/features/flags/server/router"; +import { insightsRouter } from "@calcom/features/insights/server/trpc-router"; + +import { mergeRouters, router } from "../../trpc"; +import { loggedInViewerRouter } from "../loggedInViewer/_router"; +import { publicViewerRouter } from "../publicViewer/_router"; +import { apiKeysRouter } from "./apiKeys/_router"; +import { appsRouter } from "./apps/_router"; +import { authRouter } from "./auth/_router"; +import { availabilityRouter } from "./availability/_router"; +import { bookingsRouter } from "./bookings/_router"; +import { deploymentSetupRouter } from "./deploymentSetup/_router"; +import { eventTypesRouter } from "./eventTypes/_router"; +import { paymentsRouter } from "./payments/_router"; +import { slotsRouter } from "./slots/_router"; +import { ssoRouter } from "./sso/_router"; +import { viewerTeamsRouter } from "./teams/_router"; +import { webhookRouter } from "./webhook/_router"; +import { workflowsRouter } from "./workflows/_router"; + +export const viewerRouter = mergeRouters( + loggedInViewerRouter, + router({ + loggedInViewerRouter, + public: publicViewerRouter, + auth: authRouter, + deploymentSetup: deploymentSetupRouter, + bookings: bookingsRouter, + eventTypes: eventTypesRouter, + availability: availabilityRouter, + teams: viewerTeamsRouter, + webhook: webhookRouter, + apiKeys: apiKeysRouter, + slots: slotsRouter, + workflows: workflowsRouter, + saml: ssoRouter, + insights: insightsRouter, + payments: paymentsRouter, + // 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. + appRoutingForms: app_RoutingForms, + eth: ethRouter, + features: featureFlagRouter, + appsRouter, + users: userAdminRouter, + }) +); diff --git a/packages/trpc/server/routers/viewer/apiKeys.tsx b/packages/trpc/server/routers/viewer/apiKeys.tsx deleted file mode 100644 index 72f5547e5e..0000000000 --- a/packages/trpc/server/routers/viewer/apiKeys.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { v4 } from "uuid"; -import { z } from "zod"; - -import { generateUniqueAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys"; - -import { router, authedProcedure } from "../../trpc"; - -export const apiKeysRouter = router({ - list: authedProcedure.query(async ({ ctx }) => { - return await ctx.prisma.apiKey.findMany({ - where: { - userId: ctx.user.id, - OR: [ - { - NOT: { - appId: "zapier", - }, - }, - { - appId: null, - }, - ], - }, - orderBy: { createdAt: "desc" }, - }); - }), - findKeyOfType: authedProcedure - .input( - z.object({ - appId: z.string().optional().nullable(), - }) - ) - .query(async ({ ctx, input }) => { - return await ctx.prisma.apiKey.findFirst({ - where: { - AND: [ - { - userId: ctx.user.id, - }, - { - appId: input.appId, - }, - ], - }, - }); - }), - create: authedProcedure - .input( - z.object({ - note: z.string().optional().nullish(), - expiresAt: z.date().optional().nullable(), - neverExpires: z.boolean().optional(), - appId: z.string().optional().nullable(), - }) - ) - .mutation(async ({ ctx, input }) => { - const [hashedApiKey, apiKey] = generateUniqueAPIKey(); - // Here we snap never expires before deleting it so it's not passed to prisma create call. - const neverExpires = input.neverExpires; - delete input.neverExpires; - await ctx.prisma.apiKey.create({ - data: { - id: v4(), - userId: ctx.user.id, - ...input, - // And here we pass a null to expiresAt if never expires is true. otherwise just pass expiresAt from input - expiresAt: neverExpires ? null : input.expiresAt, - hashedKey: hashedApiKey, - }, - }); - const prefixedApiKey = `${process.env.API_KEY_PREFIX ?? "cal_"}${apiKey}`; - return prefixedApiKey; - }), - edit: authedProcedure - .input( - z.object({ - id: z.string(), - note: z.string().optional().nullish(), - expiresAt: z.date().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id, ...data } = input; - const { - apiKeys: [updatedApiKey], - } = await ctx.prisma.user.update({ - where: { - id: ctx.user.id, - }, - data: { - apiKeys: { - update: { - where: { - id, - }, - data, - }, - }, - }, - select: { - apiKeys: { - where: { - id, - }, - }, - }, - }); - return updatedApiKey; - }), - delete: authedProcedure - .input( - z.object({ - id: z.string(), - eventTypeId: z.number().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - - const apiKeyToDelete = await ctx.prisma.apiKey.findFirst({ - where: { - id, - }, - }); - - await ctx.prisma.user.update({ - where: { - id: ctx.user.id, - }, - data: { - apiKeys: { - delete: { - id, - }, - }, - }, - }); - - //remove all existing zapier webhooks, as we always have only one zapier API key and the running zaps won't work any more if this key is deleted - if (apiKeyToDelete && apiKeyToDelete.appId === "zapier") { - await ctx.prisma.webhook.deleteMany({ - where: { - userId: ctx.user.id, - appId: "zapier", - }, - }); - } - - return { - id, - }; - }), -}); diff --git a/packages/trpc/server/routers/viewer/apiKeys/_router.tsx b/packages/trpc/server/routers/viewer/apiKeys/_router.tsx new file mode 100644 index 0000000000..35d5f4b285 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/_router.tsx @@ -0,0 +1,101 @@ +import { authedProcedure, router } from "../../../trpc"; +import { ZCreateInputSchema } from "./create.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZEditInputSchema } from "./edit.schema"; +import { ZFindKeyOfTypeInputSchema } from "./findKeyOfType.schema"; + +type ApiKeysRouterHandlerCache = { + list?: typeof import("./list.handler").listHandler; + findKeyOfType?: typeof import("./findKeyOfType.handler").findKeyOfTypeHandler; + create?: typeof import("./create.handler").createHandler; + edit?: typeof import("./edit.handler").editHandler; + delete?: typeof import("./delete.handler").deleteHandler; +}; + +const UNSTABLE_HANDLER_CACHE: ApiKeysRouterHandlerCache = {}; + +export const apiKeysRouter = router({ + // List keys + list: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.list) { + UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.list) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.list({ + ctx, + }); + }), + + // Find key of type + findKeyOfType: authedProcedure.input(ZFindKeyOfTypeInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.findKeyOfType) { + UNSTABLE_HANDLER_CACHE.findKeyOfType = await import("./findKeyOfType.handler").then( + (mod) => mod.findKeyOfTypeHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.findKeyOfType) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.findKeyOfType({ + ctx, + input, + }); + }), + + // Create a new key + create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.create) { + UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.create) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.create({ + ctx, + input, + }); + }), + + edit: authedProcedure.input(ZEditInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.edit) { + UNSTABLE_HANDLER_CACHE.edit = await import("./edit.handler").then((mod) => mod.editHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.edit) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.edit({ + ctx, + input, + }); + }), + + delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/apiKeys/create.handler.ts b/packages/trpc/server/routers/viewer/apiKeys/create.handler.ts new file mode 100644 index 0000000000..ae5b33b61b --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/create.handler.ts @@ -0,0 +1,38 @@ +import { v4 } from "uuid"; + +import { generateUniqueAPIKey } from "@calcom/ee/api-keys/lib/apiKeys"; +import prisma from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TCreateInputSchema } from "./create.schema"; + +type CreateHandlerOptions = { + ctx: { + user: NonNullable; + }; + input: TCreateInputSchema; +}; + +export const createHandler = async ({ ctx, input }: CreateHandlerOptions) => { + const [hashedApiKey, apiKey] = generateUniqueAPIKey(); + + // Here we snap never expires before deleting it so it's not passed to prisma create call. + const { neverExpires, ...rest } = input; + + await prisma.apiKey.create({ + data: { + id: v4(), + userId: ctx.user.id, + ...rest, + // And here we pass a null to expiresAt if never expires is true. otherwise just pass expiresAt from input + expiresAt: neverExpires ? null : rest.expiresAt, + hashedKey: hashedApiKey, + }, + }); + + const apiKeyPrefix = process.env.API_KEY_PREFIX ?? "cal_"; + + const prefixedApiKey = `${apiKeyPrefix}${apiKey}`; + + return prefixedApiKey; +}; diff --git a/packages/trpc/server/routers/viewer/apiKeys/create.schema.ts b/packages/trpc/server/routers/viewer/apiKeys/create.schema.ts new file mode 100644 index 0000000000..b38efc1957 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/create.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZCreateInputSchema = z.object({ + note: z.string().optional().nullish(), + expiresAt: z.date().optional().nullable(), + neverExpires: z.boolean().optional(), + appId: z.string().optional().nullable(), +}); + +export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apiKeys/delete.handler.ts b/packages/trpc/server/routers/viewer/apiKeys/delete.handler.ts new file mode 100644 index 0000000000..211275a304 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/delete.handler.ts @@ -0,0 +1,48 @@ +import prisma from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { + const { id } = input; + + const apiKeyToDelete = await prisma.apiKey.findFirst({ + where: { + id, + }, + }); + + await prisma.user.update({ + where: { + id: ctx.user.id, + }, + data: { + apiKeys: { + delete: { + id, + }, + }, + }, + }); + + //remove all existing zapier webhooks, as we always have only one zapier API key and the running zaps won't work any more if this key is deleted + if (apiKeyToDelete && apiKeyToDelete.appId === "zapier") { + await prisma.webhook.deleteMany({ + where: { + userId: ctx.user.id, + appId: "zapier", + }, + }); + } + + return { + id, + }; +}; diff --git a/packages/trpc/server/routers/viewer/apiKeys/delete.schema.ts b/packages/trpc/server/routers/viewer/apiKeys/delete.schema.ts new file mode 100644 index 0000000000..6c5df814ce --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/delete.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + id: z.string(), + eventTypeId: z.number().optional(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apiKeys/edit.handler.ts b/packages/trpc/server/routers/viewer/apiKeys/edit.handler.ts new file mode 100644 index 0000000000..a8d1c1be20 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/edit.handler.ts @@ -0,0 +1,42 @@ +import prisma from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TEditInputSchema } from "./edit.schema"; + +type EditOptions = { + ctx: { + user: NonNullable; + }; + input: TEditInputSchema; +}; + +export const editHandler = async ({ ctx, input }: EditOptions) => { + const { id, ...data } = input; + + const { + apiKeys: [updatedApiKey], + } = await prisma.user.update({ + where: { + id: ctx.user.id, + }, + data: { + apiKeys: { + update: { + where: { + id, + }, + data, + }, + }, + }, + select: { + apiKeys: { + where: { + id, + }, + }, + }, + }); + + return updatedApiKey; +}; diff --git a/packages/trpc/server/routers/viewer/apiKeys/edit.schema.ts b/packages/trpc/server/routers/viewer/apiKeys/edit.schema.ts new file mode 100644 index 0000000000..ae4edac3e8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/edit.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZEditInputSchema = z.object({ + id: z.string(), + note: z.string().optional().nullish(), + expiresAt: z.date().optional(), +}); + +export type TEditInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts b/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts new file mode 100644 index 0000000000..2bb04f3b70 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts @@ -0,0 +1,26 @@ +import prisma from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TFindKeyOfTypeInputSchema } from "./findKeyOfType.schema"; + +type FindKeyOfTypeOptions = { + ctx: { + user: NonNullable; + }; + input: TFindKeyOfTypeInputSchema; +}; + +export const findKeyOfTypeHandler = async ({ ctx, input }: FindKeyOfTypeOptions) => { + return await prisma.apiKey.findFirst({ + where: { + AND: [ + { + userId: ctx.user.id, + }, + { + appId: input.appId, + }, + ], + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.schema.ts b/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.schema.ts new file mode 100644 index 0000000000..1f83f53d38 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZFindKeyOfTypeInputSchema = z.object({ + appId: z.string().optional(), +}); + +export type TFindKeyOfTypeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apiKeys/list.handler.ts b/packages/trpc/server/routers/viewer/apiKeys/list.handler.ts new file mode 100644 index 0000000000..1d38c5695f --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/list.handler.ts @@ -0,0 +1,28 @@ +import prisma from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listHandler = async ({ ctx }: ListOptions) => { + return await prisma.apiKey.findMany({ + where: { + userId: ctx.user.id, + OR: [ + { + NOT: { + appId: "zapier", + }, + }, + { + appId: null, + }, + ], + }, + orderBy: { createdAt: "desc" }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/apiKeys/list.schema.ts b/packages/trpc/server/routers/viewer/apiKeys/list.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/list.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/apps.tsx b/packages/trpc/server/routers/viewer/apps.tsx deleted file mode 100644 index ffc6d1895c..0000000000 --- a/packages/trpc/server/routers/viewer/apps.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import { AppCategories } from "@prisma/client"; -import type { Prisma } from "@prisma/client"; -import z from "zod"; - -import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; -import { getLocalAppMetadata, getAppFromSlug } from "@calcom/app-store/utils"; -import { sendDisabledAppEmail } from "@calcom/emails"; -import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType"; -import { getTranslation } from "@calcom/lib/server/i18n"; - -import { TRPCError } from "@trpc/server"; - -import { authedAdminProcedure, authedProcedure, router } from "../../trpc"; - -export const appsRouter = router({ - listLocal: authedAdminProcedure - .input( - z.object({ - category: z.nativeEnum({ ...AppCategories, conferencing: "conferencing" }), - }) - ) - .query(async ({ ctx, input }) => { - const category = input.category === "conferencing" ? "video" : input.category; - const localApps = getLocalAppMetadata(); - - const dbApps = await ctx.prisma.app.findMany({ - where: { - categories: { - has: AppCategories[category as keyof typeof AppCategories], - }, - }, - select: { - slug: true, - keys: true, - enabled: true, - dirName: true, - }, - }); - - return localApps.flatMap((app) => { - // Filter applications that does not belong to the current requested category. - if (!(app.category === category || app.categories?.some((appCategory) => appCategory === category))) { - return []; - } - - // Find app metadata - const dbData = dbApps.find((dbApp) => dbApp.slug === app.slug); - - // If the app already contains keys then return - if (dbData?.keys) { - return { - name: app.name, - slug: app.slug, - logo: app.logo, - title: app.title, - type: app.type, - description: app.description, - // We know that keys are going to be an object or null. Prisma can not type check against JSON fields - keys: dbData.keys as Prisma.JsonObject | null, - dirName: app.dirName || app.slug, - enabled: dbData?.enabled || false, - isTemplate: app.isTemplate, - }; - } - - const keysSchema = appKeysSchemas[app.dirName as keyof typeof appKeysSchemas]; - - const keys: Record = {}; - - // `typeof val === 'undefined'` is always slower than !== undefined comparison - // it is important to avoid string to string comparisons as much as we can - if (keysSchema !== undefined) { - // TODO: Remove the Object.values and reduce to improve the performance. - Object.values(keysSchema.keyof()._def.values).reduce((keysObject, key) => { - keys[key as string] = ""; - return keysObject; - }, {} as Record); - } - - return { - name: app.name, - slug: app.slug, - logo: app.logo, - type: app.type, - title: app.title, - description: app.description, - enabled: dbData?.enabled ?? false, - dirName: app.dirName ?? app.slug, - keys: Object.keys(keys).length === 0 ? null : keys, - }; - }); - }), - toggle: authedAdminProcedure - .input( - z.object({ - slug: z.string(), - enabled: z.boolean(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { prisma } = ctx; - const { enabled } = input; - - // Get app name from metadata - const localApps = getLocalAppMetadata(); - const appMetadata = localApps.find((localApp) => localApp.slug === input.slug); - - if (!appMetadata) - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "App metadata could not be found" }); - - const app = await prisma.app.upsert({ - where: { - slug: input.slug, - }, - update: { - enabled, - dirName: appMetadata?.dirName || appMetadata?.slug || "", - }, - create: { - slug: input.slug, - dirName: appMetadata?.dirName || appMetadata?.slug || "", - categories: - (appMetadata?.categories as AppCategories[]) || - ([appMetadata?.category] as AppCategories[]) || - undefined, - keys: undefined, - enabled, - }, - }); - - // If disabling an app then we need to alert users based on the app type - if (!enabled) { - const translations = new Map(); - - if (app.categories.some((category) => ["calendar", "video"].includes(category))) { - // Find all users with the app credentials - const appCredentials = await prisma.credential.findMany({ - where: { - appId: app.slug, - }, - select: { - user: { - select: { - email: true, - locale: true, - }, - }, - }, - }); - - // TODO: This should be done async probably using a queue. - Promise.all( - appCredentials.map(async (credential) => { - // No need to continue if credential does not have a user - if (!credential.user || !credential.user.email) return; - - const locale = credential.user.locale ?? "en"; - let t = translations.get(locale); - - if (!t) { - t = await getTranslation(locale, "common"); - translations.set(locale, t); - } - - await sendDisabledAppEmail({ - email: credential.user.email, - appName: appMetadata?.name || app.slug, - appType: app.categories, - t, - }); - }) - ); - } else { - const eventTypesWithApp = await prisma.eventType.findMany({ - where: { - metadata: { - path: ["apps", app.slug as string, "enabled"], - equals: true, - }, - }, - select: { - id: true, - title: true, - users: { - select: { - email: true, - locale: true, - }, - }, - metadata: true, - }, - }); - - // TODO: This should be done async probably using a queue. - Promise.all( - eventTypesWithApp.map(async (eventType) => { - // TODO: This update query can be removed by merging it with - // the previous `findMany` query, if that query returns certain values. - await prisma.eventType.update({ - where: { - id: eventType.id, - }, - data: { - metadata: { - ...(eventType.metadata as object), - apps: { - // From this comment we can not type JSON fields in Prisma https://github.com/prisma/prisma/issues/3219#issuecomment-670202980 - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - ...eventType.metadata?.apps, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - [app.slug]: { ...eventType.metadata?.apps[app.slug], enabled: false }, - }, - }, - }, - }); - - return Promise.all( - eventType.users.map(async (user) => { - const locale = user.locale ?? "en"; - let t = translations.get(locale); - - if (!t) { - t = await getTranslation(locale, "common"); - translations.set(locale, t); - } - - await sendDisabledAppEmail({ - email: user.email, - appName: appMetadata?.name || app.slug, - appType: app.categories, - t, - title: eventType.title, - eventTypeId: eventType.id, - }); - }) - ); - }) - ); - } - } - - return app.enabled; - }), - saveKeys: authedAdminProcedure - .input( - z.object({ - slug: z.string(), - dirName: z.string(), - type: z.string(), - // Validate w/ app specific schema - keys: z.unknown(), - fromEnabled: z.boolean().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const keysSchema = appKeysSchemas[input.dirName as keyof typeof appKeysSchemas]; - const keys = keysSchema.parse(input.keys); - - // Get app name from metadata - const localApps = getLocalAppMetadata(); - const appMetadata = localApps.find((localApp) => localApp.slug === input.slug); - - if (!appMetadata?.dirName && appMetadata?.categories) - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "App metadata could not be found" }); - - await ctx.prisma.app.upsert({ - where: { - slug: input.slug, - }, - update: { keys, ...(input.fromEnabled && { enabled: true }) }, - create: { - slug: input.slug, - dirName: appMetadata?.dirName || appMetadata?.slug || "", - categories: - (appMetadata?.categories as AppCategories[]) || - ([appMetadata?.category] as AppCategories[]) || - undefined, - keys: (input.keys as Prisma.InputJsonObject) || undefined, - ...(input.fromEnabled && { enabled: true }), - }, - }); - }), - checkForGCal: authedProcedure.query(async ({ ctx }) => { - const gCalPresent = await ctx.prisma.credential.findFirst({ - where: { - type: "google_calendar", - userId: ctx.user.id, - }, - }); - return !!gCalPresent; - }), - updateAppCredentials: authedProcedure - .input( - z.object({ - credentialId: z.number(), - key: z.object({}).passthrough(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user } = ctx; - - const { key } = input; - - // Find user credential - const credential = await ctx.prisma.credential.findFirst({ - where: { - id: input.credentialId, - userId: user.id, - }, - }); - // Check if credential exists - if (!credential) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Could not find credential ${input.credentialId}`, - }); - } - - const updated = await ctx.prisma.credential.update({ - where: { - id: credential.id, - }, - data: { - key: { - ...(credential.key as Prisma.JsonObject), - ...(key as Prisma.JsonObject), - }, - }, - }); - - return !!updated; - }), - queryForDependencies: authedProcedure.input(z.string().array().optional()).query(async ({ ctx, input }) => { - if (!input) return; - - const dependencyData: { name: string; slug: string; installed: boolean }[] = []; - - await Promise.all( - input.map(async (dependency) => { - const appInstalled = await ctx.prisma.credential.findFirst({ - where: { - appId: dependency, - userId: ctx.user.id, - }, - }); - - const app = await getAppFromSlug(dependency); - - dependencyData.push({ name: app?.name || dependency, slug: dependency, installed: !!appInstalled }); - }) - ); - - return dependencyData; - }), -}); diff --git a/packages/trpc/server/routers/viewer/apps/_router.tsx b/packages/trpc/server/routers/viewer/apps/_router.tsx new file mode 100644 index 0000000000..1589dae1d7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/_router.tsx @@ -0,0 +1,126 @@ +import { authedAdminProcedure, authedProcedure, router } from "../../../trpc"; +import { ZListLocalInputSchema } from "./listLocal.schema"; +import { ZQueryForDependenciesInputSchema } from "./queryForDependencies.schema"; +import { ZSaveKeysInputSchema } from "./saveKeys.schema"; +import { ZToggleInputSchema } from "./toggle.schema"; +import { ZUpdateAppCredentialsInputSchema } from "./updateAppCredentials.schema"; + +type AppsRouterHandlerCache = { + listLocal?: typeof import("./listLocal.handler").listLocalHandler; + toggle?: typeof import("./toggle.handler").toggleHandler; + saveKeys?: typeof import("./saveKeys.handler").saveKeysHandler; + checkForGCal?: typeof import("./checkForGCal.handler").checkForGCalHandler; + updateAppCredentials?: typeof import("./updateAppCredentials.handler").updateAppCredentialsHandler; + queryForDependencies?: typeof import("./queryForDependencies.handler").queryForDependenciesHandler; +}; + +const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {}; + +export const appsRouter = router({ + listLocal: authedAdminProcedure.input(ZListLocalInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.listLocal) { + UNSTABLE_HANDLER_CACHE.listLocal = await import("./listLocal.handler").then( + (mod) => mod.listLocalHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.listLocal) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.listLocal({ + ctx, + input, + }); + }), + + toggle: authedAdminProcedure.input(ZToggleInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.toggle) { + UNSTABLE_HANDLER_CACHE.toggle = await import("./toggle.handler").then((mod) => mod.toggleHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.toggle) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.toggle({ + ctx, + input, + }); + }), + + saveKeys: authedAdminProcedure.input(ZSaveKeysInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.saveKeys) { + UNSTABLE_HANDLER_CACHE.saveKeys = await import("./saveKeys.handler").then((mod) => mod.saveKeysHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.saveKeys) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.saveKeys({ + ctx, + input, + }); + }), + + checkForGCal: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.checkForGCal) { + UNSTABLE_HANDLER_CACHE.checkForGCal = await import("./checkForGCal.handler").then( + (mod) => mod.checkForGCalHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.checkForGCal) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.checkForGCal({ + ctx, + }); + }), + + updateAppCredentials: authedProcedure + .input(ZUpdateAppCredentialsInputSchema) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.updateAppCredentials) { + UNSTABLE_HANDLER_CACHE.updateAppCredentials = await import("./updateAppCredentials.handler").then( + (mod) => mod.updateAppCredentialsHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.updateAppCredentials) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.updateAppCredentials({ + ctx, + input, + }); + }), + + queryForDependencies: authedProcedure + .input(ZQueryForDependenciesInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.queryForDependencies) { + UNSTABLE_HANDLER_CACHE.queryForDependencies = await import("./queryForDependencies.handler").then( + (mod) => mod.queryForDependenciesHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.queryForDependencies) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.queryForDependencies({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/apps/checkForGCal.handler.ts b/packages/trpc/server/routers/viewer/apps/checkForGCal.handler.ts new file mode 100644 index 0000000000..fbff33af54 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/checkForGCal.handler.ts @@ -0,0 +1,20 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type CheckForGCalOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const checkForGCalHandler = async ({ ctx }: CheckForGCalOptions) => { + const gCalPresent = await prisma.credential.findFirst({ + where: { + type: "google_calendar", + userId: ctx.user.id, + }, + }); + + return !!gCalPresent; +}; diff --git a/packages/trpc/server/routers/viewer/apps/checkForGCal.schema.ts b/packages/trpc/server/routers/viewer/apps/checkForGCal.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/checkForGCal.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/apps/listLocal.handler.ts b/packages/trpc/server/routers/viewer/apps/listLocal.handler.ts new file mode 100644 index 0000000000..3771a93206 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/listLocal.handler.ts @@ -0,0 +1,89 @@ +import type { Prisma, PrismaClient } from "@prisma/client"; +import { AppCategories } from "@prisma/client"; + +import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; +import { getLocalAppMetadata } from "@calcom/app-store/utils"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TListLocalInputSchema } from "./listLocal.schema"; + +type ListLocalOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TListLocalInputSchema; +}; + +export const listLocalHandler = async ({ ctx, input }: ListLocalOptions) => { + const { prisma } = ctx; + const category = input.category === "conferencing" ? "video" : input.category; + const localApps = getLocalAppMetadata(); + + const dbApps = await prisma.app.findMany({ + where: { + categories: { + has: AppCategories[category as keyof typeof AppCategories], + }, + }, + select: { + slug: true, + keys: true, + enabled: true, + dirName: true, + }, + }); + + return localApps.flatMap((app) => { + // Filter applications that does not belong to the current requested category. + if (!(app.category === category || app.categories?.some((appCategory) => appCategory === category))) { + return []; + } + + // Find app metadata + const dbData = dbApps.find((dbApp) => dbApp.slug === app.slug); + + // If the app already contains keys then return + if (dbData?.keys) { + return { + name: app.name, + slug: app.slug, + logo: app.logo, + title: app.title, + type: app.type, + description: app.description, + // We know that keys are going to be an object or null. Prisma can not type check against JSON fields + keys: dbData.keys as Prisma.JsonObject | null, + dirName: app.dirName || app.slug, + enabled: dbData?.enabled || false, + isTemplate: app.isTemplate, + }; + } + + const keysSchema = appKeysSchemas[app.dirName as keyof typeof appKeysSchemas]; + + const keys: Record = {}; + + // `typeof val === 'undefined'` is always slower than !== undefined comparison + // it is important to avoid string to string comparisons as much as we can + if (keysSchema !== undefined) { + // TODO: Remove the Object.values and reduce to improve the performance. + Object.values(keysSchema.keyof()._def.values).reduce((keysObject, key) => { + keys[key as string] = ""; + return keysObject; + }, {} as Record); + } + + return { + name: app.name, + slug: app.slug, + logo: app.logo, + type: app.type, + title: app.title, + description: app.description, + enabled: dbData?.enabled ?? false, + dirName: app.dirName ?? app.slug, + keys: Object.keys(keys).length === 0 ? null : keys, + }; + }); +}; diff --git a/packages/trpc/server/routers/viewer/apps/listLocal.schema.ts b/packages/trpc/server/routers/viewer/apps/listLocal.schema.ts new file mode 100644 index 0000000000..03861aa8a1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/listLocal.schema.ts @@ -0,0 +1,8 @@ +import { AppCategories } from "@prisma/client"; +import { z } from "zod"; + +export const ZListLocalInputSchema = z.object({ + category: z.nativeEnum({ ...AppCategories, conferencing: "conferencing" }), +}); + +export type TListLocalInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apps/queryForDependencies.handler.ts b/packages/trpc/server/routers/viewer/apps/queryForDependencies.handler.ts new file mode 100644 index 0000000000..5e78c83c64 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/queryForDependencies.handler.ts @@ -0,0 +1,35 @@ +import { getAppFromSlug } from "@calcom/app-store/utils"; +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TQueryForDependenciesInputSchema } from "./queryForDependencies.schema"; + +type QueryForDependenciesOptions = { + ctx: { + user: NonNullable; + }; + input: TQueryForDependenciesInputSchema; +}; + +export const queryForDependenciesHandler = async ({ ctx, input }: QueryForDependenciesOptions) => { + if (!input) return; + + const dependencyData: { name: string; slug: string; installed: boolean }[] = []; + + await Promise.all( + input.map(async (dependency) => { + const appInstalled = await prisma.credential.findFirst({ + where: { + appId: dependency, + userId: ctx.user.id, + }, + }); + + const app = await getAppFromSlug(dependency); + + dependencyData.push({ name: app?.name || dependency, slug: dependency, installed: !!appInstalled }); + }) + ); + + return dependencyData; +}; diff --git a/packages/trpc/server/routers/viewer/apps/queryForDependencies.schema.ts b/packages/trpc/server/routers/viewer/apps/queryForDependencies.schema.ts new file mode 100644 index 0000000000..8adbf5bfd2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/queryForDependencies.schema.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const ZQueryForDependenciesInputSchema = z.string().array().optional(); + +export type TQueryForDependenciesInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apps/saveKeys.handler.ts b/packages/trpc/server/routers/viewer/apps/saveKeys.handler.ts new file mode 100644 index 0000000000..f293609252 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/saveKeys.handler.ts @@ -0,0 +1,49 @@ +import type { AppCategories } from "@prisma/client"; +import type { Prisma } from "@prisma/client"; +import type { PrismaClient } from "@prisma/client"; + +import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; +import { getLocalAppMetadata } from "@calcom/app-store/utils"; + +// import prisma from "@calcom/prisma"; +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TSaveKeysInputSchema } from "./saveKeys.schema"; + +type SaveKeysOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TSaveKeysInputSchema; +}; + +export const saveKeysHandler = async ({ ctx, input }: SaveKeysOptions) => { + const keysSchema = appKeysSchemas[input.dirName as keyof typeof appKeysSchemas]; + const keys = keysSchema.parse(input.keys); + + // Get app name from metadata + const localApps = getLocalAppMetadata(); + const appMetadata = localApps.find((localApp) => localApp.slug === input.slug); + + if (!appMetadata?.dirName && appMetadata?.categories) + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "App metadata could not be found" }); + + await ctx.prisma.app.upsert({ + where: { + slug: input.slug, + }, + update: { keys, ...(input.fromEnabled && { enabled: true }) }, + create: { + slug: input.slug, + dirName: appMetadata?.dirName || appMetadata?.slug || "", + categories: + (appMetadata?.categories as AppCategories[]) || + ([appMetadata?.category] as AppCategories[]) || + undefined, + keys: (input.keys as Prisma.InputJsonObject) || undefined, + ...(input.fromEnabled && { enabled: true }), + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/apps/saveKeys.schema.ts b/packages/trpc/server/routers/viewer/apps/saveKeys.schema.ts new file mode 100644 index 0000000000..0e1bd59c01 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/saveKeys.schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const ZSaveKeysInputSchema = z.object({ + slug: z.string(), + dirName: z.string(), + type: z.string(), + // Validate w/ app specific schema + keys: z.unknown(), + fromEnabled: z.boolean().optional(), +}); + +export type TSaveKeysInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apps/toggle.handler.ts b/packages/trpc/server/routers/viewer/apps/toggle.handler.ts new file mode 100644 index 0000000000..2c1502955c --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/toggle.handler.ts @@ -0,0 +1,166 @@ +import type { PrismaClient } from "@prisma/client"; + +import { getLocalAppMetadata } from "@calcom/app-store/utils"; +import { sendDisabledAppEmail } from "@calcom/emails"; +import { getTranslation } from "@calcom/lib/server"; +import type { AppCategories } from "@calcom/prisma/client"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TToggleInputSchema } from "./toggle.schema"; + +type ToggleOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TToggleInputSchema; +}; + +export const toggleHandler = async ({ input, ctx }: ToggleOptions) => { + const { prisma } = ctx; + const { enabled, slug } = input; + + // Get app name from metadata + const localApps = getLocalAppMetadata(); + const appMetadata = localApps.find((localApp) => localApp.slug === slug); + + if (!appMetadata) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "App metadata could not be found" }); + } + + const app = await prisma.app.upsert({ + where: { + slug, + }, + update: { + enabled, + dirName: appMetadata?.dirName || appMetadata?.slug || "", + }, + create: { + slug, + dirName: appMetadata?.dirName || appMetadata?.slug || "", + categories: + (appMetadata?.categories as AppCategories[]) || + ([appMetadata?.category] as AppCategories[]) || + undefined, + keys: undefined, + enabled, + }, + }); + + // If disabling an app then we need to alert users basesd on the app type + if (!enabled) { + const translations = new Map(); + if (app.categories.some((category) => ["calendar", "video"].includes(category))) { + // Find all users with the app credentials + const appCredentials = await prisma.credential.findMany({ + where: { + appId: app.slug, + }, + select: { + user: { + select: { + email: true, + locale: true, + }, + }, + }, + }); + + // TODO: This should be done async probably using a queue. + Promise.all( + appCredentials.map(async (credential) => { + // No need to continue if credential does not have a user + if (!credential.user || !credential.user.email) return; + + const locale = credential.user.locale ?? "en"; + let t = translations.get(locale); + + if (!t) { + t = await getTranslation(locale, "common"); + translations.set(locale, t); + } + + await sendDisabledAppEmail({ + email: credential.user.email, + appName: appMetadata?.name || app.slug, + appType: app.categories, + t, + }); + }) + ); + } else { + const eventTypesWithApp = await prisma.eventType.findMany({ + where: { + metadata: { + path: ["apps", app.slug as string, "enabled"], + equals: true, + }, + }, + select: { + id: true, + title: true, + users: { + select: { + email: true, + locale: true, + }, + }, + metadata: true, + }, + }); + + // TODO: This should be done async probably using a queue. + Promise.all( + eventTypesWithApp.map(async (eventType) => { + // TODO: This update query can be removed by merging it with + // the previous `findMany` query, if that query returns certain values. + await prisma.eventType.update({ + where: { + id: eventType.id, + }, + data: { + metadata: { + ...(eventType.metadata as object), + apps: { + // From this comment we can not type JSON fields in Prisma https://github.com/prisma/prisma/issues/3219#issuecomment-670202980 + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + ...eventType.metadata?.apps, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + [app.slug]: { ...eventType.metadata?.apps[app.slug], enabled: false }, + }, + }, + }, + }); + + return Promise.all( + eventType.users.map(async (user) => { + const locale = user.locale ?? "en"; + let t = translations.get(locale); + + if (!t) { + t = await getTranslation(locale, "common"); + translations.set(locale, t); + } + + await sendDisabledAppEmail({ + email: user.email, + appName: appMetadata?.name || app.slug, + appType: app.categories, + t, + title: eventType.title, + eventTypeId: eventType.id, + }); + }) + ); + }) + ); + } + } + + return app.enabled; +}; diff --git a/packages/trpc/server/routers/viewer/apps/toggle.schema.ts b/packages/trpc/server/routers/viewer/apps/toggle.schema.ts new file mode 100644 index 0000000000..8b9c2d2677 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/toggle.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZToggleInputSchema = z.object({ + slug: z.string(), + enabled: z.boolean(), +}); + +export type TToggleInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apps/types.ts b/packages/trpc/server/routers/viewer/apps/types.ts new file mode 100644 index 0000000000..39b5c9a5fa --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/types.ts @@ -0,0 +1,14 @@ +import type { Prisma } from "@calcom/prisma/client"; + +export interface FilteredApp { + name: string; + slug: string; + logo: string; + title?: string; + type: string; + description: string; + dirName: string; + keys: Prisma.JsonObject | null; + enabled: boolean; + isTemplate?: boolean; +} diff --git a/packages/trpc/server/routers/viewer/apps/updateAppCredentials.handler.ts b/packages/trpc/server/routers/viewer/apps/updateAppCredentials.handler.ts new file mode 100644 index 0000000000..48bd3cd8ef --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/updateAppCredentials.handler.ts @@ -0,0 +1,49 @@ +import { prisma } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TUpdateAppCredentialsInputSchema } from "./updateAppCredentials.schema"; + +type UpdateAppCredentialsOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateAppCredentialsInputSchema; +}; + +export const updateAppCredentialsHandler = async ({ ctx, input }: UpdateAppCredentialsOptions) => { + const { user } = ctx; + + const { key } = input; + + // Find user credential + const credential = await prisma.credential.findFirst({ + where: { + id: input.credentialId, + userId: user.id, + }, + }); + // Check if credential exists + if (!credential) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Could not find credential ${input.credentialId}`, + }); + } + + const updated = await prisma.credential.update({ + where: { + id: credential.id, + }, + data: { + key: { + ...(credential.key as Prisma.JsonObject), + ...(key as Prisma.JsonObject), + }, + }, + }); + + return !!updated; +}; diff --git a/packages/trpc/server/routers/viewer/apps/updateAppCredentials.schema.ts b/packages/trpc/server/routers/viewer/apps/updateAppCredentials.schema.ts new file mode 100644 index 0000000000..ad43b5bc50 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/updateAppCredentials.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZUpdateAppCredentialsInputSchema = z.object({ + credentialId: z.number(), + key: z.object({}).passthrough(), +}); + +export type TUpdateAppCredentialsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/auth.tsx b/packages/trpc/server/routers/viewer/auth.tsx deleted file mode 100644 index 158472a7b7..0000000000 --- a/packages/trpc/server/routers/viewer/auth.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { IdentityProvider } from "@prisma/client"; -import { z } from "zod"; - -import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; -import { validPassword } from "@calcom/features/auth/lib/validPassword"; -import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; -import prisma from "@calcom/prisma"; - -import { TRPCError } from "@trpc/server"; - -import { router, authedProcedure } from "../../trpc"; - -export const authRouter = router({ - changePassword: authedProcedure - .input( - z.object({ - oldPassword: z.string(), - newPassword: z.string(), - }) - ) - .mutation(async ({ input, ctx }) => { - const { oldPassword, newPassword } = input; - - const { user } = ctx; - - if (user.identityProvider !== IdentityProvider.CAL) { - throw new TRPCError({ code: "FORBIDDEN", message: "THIRD_PARTY_IDENTITY_PROVIDER_ENABLED" }); - } - - const currentPasswordQuery = await prisma.user.findFirst({ - where: { - id: user.id, - }, - select: { - password: true, - }, - }); - - const currentPassword = currentPasswordQuery?.password; - - if (!currentPassword) { - throw new TRPCError({ code: "NOT_FOUND", message: "MISSING_PASSWORD" }); - } - - const passwordsMatch = await verifyPassword(oldPassword, currentPassword); - if (!passwordsMatch) { - throw new TRPCError({ code: "BAD_REQUEST", message: "incorrect_password" }); - } - - if (oldPassword === newPassword) { - throw new TRPCError({ code: "BAD_REQUEST", message: "new_password_matches_old_password" }); - } - - if (!validPassword(newPassword)) { - throw new TRPCError({ code: "BAD_REQUEST", message: "password_hint_min" }); - } - - const hashedPassword = await hashPassword(newPassword); - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - password: hashedPassword, - }, - }); - }), - verifyPassword: authedProcedure - .input( - z.object({ - passwordInput: z.string(), - }) - ) - .mutation(async ({ input, ctx }) => { - const user = await prisma.user.findUnique({ - where: { - id: ctx.user.id, - }, - }); - - if (!user?.password) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); - } - - const passwordsMatch = await verifyPassword(input.passwordInput, user.password); - - if (!passwordsMatch) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - return; - }), -}); diff --git a/packages/trpc/server/routers/viewer/auth/_router.tsx b/packages/trpc/server/routers/viewer/auth/_router.tsx new file mode 100644 index 0000000000..04f3e72f6b --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/_router.tsx @@ -0,0 +1,48 @@ +import { router, authedProcedure } from "../../../trpc"; +import { ZChangePasswordInputSchema } from "./changePassword.schema"; +import { ZVerifyPasswordInputSchema } from "./verifyPassword.schema"; + +type AuthRouterHandlerCache = { + changePassword?: typeof import("./changePassword.handler").changePasswordHandler; + verifyPassword?: typeof import("./verifyPassword.handler").verifyPasswordHandler; +}; + +const UNSTABLE_HANDLER_CACHE: AuthRouterHandlerCache = {}; + +export const authRouter = router({ + changePassword: authedProcedure.input(ZChangePasswordInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.changePassword) { + UNSTABLE_HANDLER_CACHE.changePassword = await import("./changePassword.handler").then( + (mod) => mod.changePasswordHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.changePassword) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.changePassword({ + ctx, + input, + }); + }), + + verifyPassword: authedProcedure.input(ZVerifyPasswordInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.verifyPassword) { + UNSTABLE_HANDLER_CACHE.verifyPassword = await import("./verifyPassword.handler").then( + (mod) => mod.verifyPasswordHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.verifyPassword) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.verifyPassword({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/auth/changePassword.handler.ts b/packages/trpc/server/routers/viewer/auth/changePassword.handler.ts new file mode 100644 index 0000000000..ac11fb1187 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/changePassword.handler.ts @@ -0,0 +1,66 @@ +import { IdentityProvider } from "@prisma/client"; + +import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; +import { validPassword } from "@calcom/features/auth/lib/validPassword"; +import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TChangePasswordInputSchema } from "./changePassword.schema"; + +type ChangePasswordOptions = { + ctx: { + user: NonNullable; + }; + input: TChangePasswordInputSchema; +}; + +export const changePasswordHandler = async ({ input, ctx }: ChangePasswordOptions) => { + const { oldPassword, newPassword } = input; + + const { user } = ctx; + + if (user.identityProvider !== IdentityProvider.CAL) { + throw new TRPCError({ code: "FORBIDDEN", message: "THIRD_PARTY_IDENTITY_PROVIDER_ENABLED" }); + } + + const currentPasswordQuery = await prisma.user.findFirst({ + where: { + id: user.id, + }, + select: { + password: true, + }, + }); + + const currentPassword = currentPasswordQuery?.password; + + if (!currentPassword) { + throw new TRPCError({ code: "NOT_FOUND", message: "MISSING_PASSWORD" }); + } + + const passwordsMatch = await verifyPassword(oldPassword, currentPassword); + if (!passwordsMatch) { + throw new TRPCError({ code: "BAD_REQUEST", message: "incorrect_password" }); + } + + if (oldPassword === newPassword) { + throw new TRPCError({ code: "BAD_REQUEST", message: "new_password_matches_old_password" }); + } + + if (!validPassword(newPassword)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "password_hint_min" }); + } + + const hashedPassword = await hashPassword(newPassword); + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + password: hashedPassword, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/auth/changePassword.schema.ts b/packages/trpc/server/routers/viewer/auth/changePassword.schema.ts new file mode 100644 index 0000000000..561ba42227 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/changePassword.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZChangePasswordInputSchema = z.object({ + oldPassword: z.string(), + newPassword: z.string(), +}); + +export type TChangePasswordInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/auth/verifyPassword.handler.ts b/packages/trpc/server/routers/viewer/auth/verifyPassword.handler.ts new file mode 100644 index 0000000000..e663141c33 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/verifyPassword.handler.ts @@ -0,0 +1,34 @@ +import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TVerifyPasswordInputSchema } from "./verifyPassword.schema"; + +type VerifyPasswordOptions = { + ctx: { + user: NonNullable; + }; + input: TVerifyPasswordInputSchema; +}; + +export const verifyPasswordHandler = async ({ input, ctx }: VerifyPasswordOptions) => { + const user = await prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + }); + + if (!user?.password) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + + const passwordsMatch = await verifyPassword(input.passwordInput, user.password); + + if (!passwordsMatch) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + return; +}; diff --git a/packages/trpc/server/routers/viewer/auth/verifyPassword.schema.ts b/packages/trpc/server/routers/viewer/auth/verifyPassword.schema.ts new file mode 100644 index 0000000000..5acac97661 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/verifyPassword.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZVerifyPasswordInputSchema = z.object({ + passwordInput: z.string(), +}); + +export type TVerifyPasswordInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability.tsx b/packages/trpc/server/routers/viewer/availability.tsx deleted file mode 100644 index 0c0ab4c46e..0000000000 --- a/packages/trpc/server/routers/viewer/availability.tsx +++ /dev/null @@ -1,499 +0,0 @@ -import type { - Availability as AvailabilityModel, - Prisma, - Schedule as ScheduleModel, - User, -} from "@prisma/client"; -import { z } from "zod"; - -import { getUserAvailability } from "@calcom/core/getUserAvailability"; -import dayjs from "@calcom/dayjs"; -import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule, getWorkingHours } from "@calcom/lib/availability"; -import { yyyymmdd } from "@calcom/lib/date-fns"; -import type { PrismaClient } from "@calcom/prisma/client"; -import { stringOrNumber } from "@calcom/prisma/zod-utils"; -import type { Schedule, TimeRange } from "@calcom/types/schedule"; - -import { TRPCError } from "@trpc/server"; - -import { authedProcedure, router } from "../../trpc"; - -export const availabilityRouter = router({ - list: authedProcedure.query(async ({ ctx }) => { - const { prisma, user } = ctx; - - const schedules = await prisma.schedule.findMany({ - where: { - userId: user.id, - }, - select: { - id: true, - name: true, - availability: true, - timeZone: true, - }, - orderBy: { - id: "asc", - }, - }); - - const defaultScheduleId = await getDefaultScheduleId(user.id, prisma); - if (!user.defaultScheduleId) { - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - defaultScheduleId, - }, - }); - } - - return { - schedules: schedules.map((schedule) => ({ - ...schedule, - isDefault: schedule.id === defaultScheduleId, - })), - }; - }), - user: authedProcedure - .input( - z.object({ - username: z.string(), - dateFrom: z.string(), - dateTo: z.string(), - eventTypeId: stringOrNumber.optional(), - withSource: z.boolean().optional(), - }) - ) - .query(({ input }) => { - return getUserAvailability(input); - }), - schedule: router({ - get: authedProcedure - .input( - z.object({ - scheduleId: z.optional(z.number()), - isManagedEventType: z.optional(z.boolean()), - }) - ) - .query(async ({ ctx, input }) => { - const { prisma, user } = ctx; - const schedule = await prisma.schedule.findUnique({ - where: { - id: input.scheduleId || (await getDefaultScheduleId(user.id, prisma)), - }, - select: { - id: true, - userId: true, - name: true, - availability: true, - timeZone: true, - eventType: { - select: { - _count: true, - id: true, - eventName: true, - }, - }, - }, - }); - if (!schedule || (schedule.userId !== user.id && !input.isManagedEventType)) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - const timeZone = schedule.timeZone || user.timeZone; - - const schedulesCount = await ctx.prisma.schedule.count({ - where: { - userId: ctx.user.id, - }, - }); - return { - id: schedule.id, - name: schedule.name, - isManaged: schedule.userId !== user.id, - workingHours: getWorkingHours( - { timeZone: schedule.timeZone || undefined }, - schedule.availability || [] - ), - schedule: schedule.availability, - availability: convertScheduleToAvailability(schedule).map((a) => - a.map((startAndEnd) => ({ - ...startAndEnd, - // Turn our limited granularity into proper end of day. - end: new Date(startAndEnd.end.toISOString().replace("23:59:00.000Z", "23:59:59.999Z")), - })) - ), - timeZone, - dateOverrides: schedule.availability.reduce((acc, override) => { - // only iff future date override - if (!override.date || dayjs.tz(override.date, timeZone).isBefore(dayjs(), "day")) { - return acc; - } - const newValue = { - start: dayjs - .utc(override.date) - .hour(override.startTime.getUTCHours()) - .minute(override.startTime.getUTCMinutes()) - .toDate(), - end: dayjs - .utc(override.date) - .hour(override.endTime.getUTCHours()) - .minute(override.endTime.getUTCMinutes()) - .toDate(), - }; - const dayRangeIndex = acc.findIndex( - // early return prevents override.date from ever being empty. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (item) => yyyymmdd(item.ranges[0].start) === yyyymmdd(override.date!) - ); - if (dayRangeIndex === -1) { - acc.push({ ranges: [newValue] }); - return acc; - } - acc[dayRangeIndex].ranges.push(newValue); - return acc; - }, [] as { ranges: TimeRange[] }[]), - isDefault: !input.scheduleId || user.defaultScheduleId === schedule.id, - isLastSchedule: schedulesCount <= 1, - }; - }), - create: authedProcedure - .input( - z.object({ - name: z.string(), - schedule: z - .array( - z.array( - z.object({ - start: z.date(), - end: z.date(), - }) - ) - ) - .optional(), - eventTypeId: z.number().optional(), - }) - ) - .mutation(async ({ input, ctx }) => { - const { user, prisma } = ctx; - if (input.eventTypeId) { - const eventType = await prisma.eventType.findUnique({ - where: { - id: input.eventTypeId, - }, - select: { - userId: true, - }, - }); - if (!eventType || eventType.userId !== user.id) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to create a schedule for this event type", - }); - } - } - const data: Prisma.ScheduleCreateInput = { - name: input.name, - user: { - connect: { - id: user.id, - }, - }, - // If an eventTypeId is provided then connect the new schedule to that event type - ...(input.eventTypeId && { eventType: { connect: { id: input.eventTypeId } } }), - }; - - const availability = getAvailabilityFromSchedule(input.schedule || DEFAULT_SCHEDULE); - data.availability = { - createMany: { - data: availability.map((schedule) => ({ - days: schedule.days, - startTime: schedule.startTime, - endTime: schedule.endTime, - })), - }, - }; - - const schedule = await prisma.schedule.create({ - data, - }); - - if (!user.defaultScheduleId) { - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - defaultScheduleId: schedule.id, - }, - }); - } - - return { schedule }; - }), - delete: authedProcedure - .input( - z.object({ - scheduleId: z.number(), - }) - ) - .mutation(async ({ input, ctx }) => { - const { user, prisma } = ctx; - - const scheduleToDelete = await prisma.schedule.findFirst({ - where: { - id: input.scheduleId, - }, - select: { - userId: true, - }, - }); - - if (scheduleToDelete?.userId !== user.id) throw new TRPCError({ code: "UNAUTHORIZED" }); - - if (user.defaultScheduleId === input.scheduleId) { - // set a new default or unset default if no other schedule - const scheduleToSetAsDefault = await prisma.schedule.findFirst({ - where: { - userId: user.id, - NOT: { - id: input.scheduleId, - }, - }, - select: { - id: true, - }, - }); - - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - defaultScheduleId: scheduleToSetAsDefault?.id || null, - }, - }); - } - await prisma.schedule.delete({ - where: { - id: input.scheduleId, - }, - }); - }), - update: authedProcedure - .input( - z.object({ - scheduleId: z.number(), - timeZone: z.string().optional(), - name: z.string().optional(), - isDefault: z.boolean().optional(), - schedule: z - .array( - z.array( - z.object({ - start: z.date(), - end: z.date(), - }) - ) - ) - .optional(), - dateOverrides: z - .array( - z.object({ - start: z.date(), - end: z.date(), - }) - ) - .optional(), - }) - ) - .mutation(async ({ input, ctx }) => { - const { user, prisma } = ctx; - const availability = input.schedule - ? getAvailabilityFromSchedule(input.schedule) - : (input.dateOverrides || []).map((dateOverride) => ({ - startTime: dateOverride.start, - endTime: dateOverride.end, - date: dateOverride.start, - days: [], - })); - - // Not able to update the schedule with userId where clause, so fetch schedule separately and then validate - // Bug: https://github.com/prisma/prisma/issues/7290 - const userSchedule = await prisma.schedule.findUnique({ - where: { - id: input.scheduleId, - }, - select: { - userId: true, - name: true, - id: true, - }, - }); - - if (userSchedule?.userId !== user.id) throw new TRPCError({ code: "UNAUTHORIZED" }); - - if (!userSchedule || userSchedule.userId !== user.id) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - - let updatedUser; - if (input.isDefault) { - const setupDefault = await setupDefaultSchedule(user.id, input.scheduleId, prisma); - updatedUser = setupDefault; - } - - if (!input.name) { - // TODO: Improve - // We don't want to pass the full schedule for just a set as default update - // but in the current logic, this wipes the existing availability. - // Return early to prevent this from happening. - return { - schedule: userSchedule, - isDefault: updatedUser - ? updatedUser.defaultScheduleId === input.scheduleId - : user.defaultScheduleId === input.scheduleId, - }; - } - - const schedule = await prisma.schedule.update({ - where: { - id: input.scheduleId, - }, - data: { - timeZone: input.timeZone, - name: input.name, - availability: { - deleteMany: { - scheduleId: { - equals: input.scheduleId, - }, - }, - createMany: { - data: [ - ...availability, - ...(input.dateOverrides || []).map((override) => ({ - date: override.start, - startTime: override.start, - endTime: override.end, - })), - ], - }, - }, - }, - select: { - id: true, - userId: true, - name: true, - availability: true, - timeZone: true, - eventType: { - select: { - _count: true, - id: true, - eventName: true, - }, - }, - }, - }); - - const userAvailability = convertScheduleToAvailability(schedule); - - return { - schedule, - availability: userAvailability, - timeZone: schedule.timeZone || user.timeZone, - isDefault: updatedUser - ? updatedUser.defaultScheduleId === schedule.id - : user.defaultScheduleId === schedule.id, - prevDefaultId: user.defaultScheduleId, - currentDefaultId: updatedUser ? updatedUser.defaultScheduleId : user.defaultScheduleId, - }; - }), - }), -}); - -export const convertScheduleToAvailability = ( - schedule: Partial & { availability: AvailabilityModel[] } -) => { - return schedule.availability.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(() => []) - ); -}; - -const setupDefaultSchedule = async (userId: number, scheduleId: number, prisma: PrismaClient) => { - return prisma.user.update({ - where: { - id: userId, - }, - data: { - defaultScheduleId: scheduleId, - }, - }); -}; - -const getDefaultScheduleId = async (userId: number, prisma: PrismaClient) => { - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - select: { - defaultScheduleId: true, - }, - }); - - if (user?.defaultScheduleId) { - return user.defaultScheduleId; - } - - // If we're returning the default schedule for the first time then we should set it in the user record - const defaultSchedule = await prisma.schedule.findFirst({ - where: { - userId, - }, - select: { - id: true, - }, - }); - - return defaultSchedule?.id; // TODO: Handle no schedules AT ALL -}; - -const hasDefaultSchedule = async (user: Partial, prisma: PrismaClient) => { - const defaultSchedule = await prisma.schedule.findFirst({ - where: { - userId: user.id, - }, - }); - return !!user.defaultScheduleId || !!defaultSchedule; -}; diff --git a/packages/trpc/server/routers/viewer/availability/_router.tsx b/packages/trpc/server/routers/viewer/availability/_router.tsx new file mode 100644 index 0000000000..e274382ca1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/_router.tsx @@ -0,0 +1,45 @@ +import { authedProcedure, router } from "../../../trpc"; +import { scheduleRouter } from "./schedule/_router"; +import { ZUserInputSchema } from "./user.schema"; + +type AvailabilityRouterHandlerCache = { + list?: typeof import("./list.handler").listHandler; + user?: typeof import("./user.handler").userHandler; +}; + +const UNSTABLE_HANDLER_CACHE: AvailabilityRouterHandlerCache = {}; + +export const availabilityRouter = router({ + list: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.list) { + UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.list) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.list({ + ctx, + }); + }), + + user: authedProcedure.input(ZUserInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.user) { + UNSTABLE_HANDLER_CACHE.user = await import("./user.handler").then((mod) => mod.userHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.user) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.user({ + ctx, + input, + }); + }), + + schedule: scheduleRouter, +}); diff --git a/packages/trpc/server/routers/viewer/availability/list.handler.ts b/packages/trpc/server/routers/viewer/availability/list.handler.ts new file mode 100644 index 0000000000..80a454d9ff --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/list.handler.ts @@ -0,0 +1,49 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import { getDefaultScheduleId } from "./util"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listHandler = async ({ ctx }: ListOptions) => { + const { user } = ctx; + + const schedules = await prisma.schedule.findMany({ + where: { + userId: user.id, + }, + select: { + id: true, + name: true, + availability: true, + timeZone: true, + }, + orderBy: { + id: "asc", + }, + }); + + const defaultScheduleId = await getDefaultScheduleId(user.id, prisma); + + if (!user.defaultScheduleId) { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + defaultScheduleId, + }, + }); + } + + return { + schedules: schedules.map((schedule) => ({ + ...schedule, + isDefault: schedule.id === defaultScheduleId, + })), + }; +}; diff --git a/packages/trpc/server/routers/viewer/availability/list.schema.ts b/packages/trpc/server/routers/viewer/availability/list.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/list.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx b/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx new file mode 100644 index 0000000000..9c1af485ca --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx @@ -0,0 +1,80 @@ +import { authedProcedure, router } from "../../../../trpc"; +import { ZCreateInputSchema } from "./create.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZUpdateInputSchema } from "./update.schema"; + +type ScheduleRouterHandlerCache = { + get?: typeof import("./get.handler").getHandler; + create?: typeof import("./create.handler").createHandler; + delete?: typeof import("./delete.handler").deleteHandler; + update?: typeof import("./update.handler").updateHandler; +}; + +const UNSTABLE_HANDLER_CACHE: ScheduleRouterHandlerCache = {}; + +export const scheduleRouter = router({ + get: authedProcedure.input(ZGetInputSchema).query(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.create) { + UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.create) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.create({ + ctx, + input, + }); + }), + + delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.update) { + UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.update) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.update({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/availability/schedule/create.handler.ts b/packages/trpc/server/routers/viewer/availability/schedule/create.handler.ts new file mode 100644 index 0000000000..4117b996d6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/create.handler.ts @@ -0,0 +1,73 @@ +import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; +import { prisma } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../../trpc"; +import type { TCreateInputSchema } from "./create.schema"; + +type CreateOptions = { + ctx: { + user: NonNullable; + }; + input: TCreateInputSchema; +}; + +export const createHandler = async ({ input, ctx }: CreateOptions) => { + const { user } = ctx; + if (input.eventTypeId) { + const eventType = await prisma.eventType.findUnique({ + where: { + id: input.eventTypeId, + }, + select: { + userId: true, + }, + }); + if (!eventType || eventType.userId !== user.id) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to create a schedule for this event type", + }); + } + } + const data: Prisma.ScheduleCreateInput = { + name: input.name, + user: { + connect: { + id: user.id, + }, + }, + // If an eventTypeId is provided then connect the new schedule to that event type + ...(input.eventTypeId && { eventType: { connect: { id: input.eventTypeId } } }), + }; + + const availability = getAvailabilityFromSchedule(input.schedule || DEFAULT_SCHEDULE); + data.availability = { + createMany: { + data: availability.map((schedule) => ({ + days: schedule.days, + startTime: schedule.startTime, + endTime: schedule.endTime, + })), + }, + }; + + const schedule = await prisma.schedule.create({ + data, + }); + + if (!user.defaultScheduleId) { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + defaultScheduleId: schedule.id, + }, + }); + } + + return { schedule }; +}; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/create.schema.ts b/packages/trpc/server/routers/viewer/availability/schedule/create.schema.ts new file mode 100644 index 0000000000..f029a10fa0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/create.schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const ZCreateInputSchema = z.object({ + name: z.string(), + schedule: z + .array( + z.array( + z.object({ + start: z.date(), + end: z.date(), + }) + ) + ) + .optional(), + eventTypeId: z.number().optional(), +}); + +export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/delete.handler.ts b/packages/trpc/server/routers/viewer/availability/schedule/delete.handler.ts new file mode 100644 index 0000000000..d77aeecb1f --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/delete.handler.ts @@ -0,0 +1,57 @@ +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../../trpc"; +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ input, ctx }: DeleteOptions) => { + const { user } = ctx; + + const scheduleToDelete = await prisma.schedule.findFirst({ + where: { + id: input.scheduleId, + }, + select: { + userId: true, + }, + }); + + if (scheduleToDelete?.userId !== user.id) throw new TRPCError({ code: "UNAUTHORIZED" }); + + if (user.defaultScheduleId === input.scheduleId) { + // set a new default or unset default if no other schedule + const scheduleToSetAsDefault = await prisma.schedule.findFirst({ + where: { + userId: user.id, + NOT: { + id: input.scheduleId, + }, + }, + select: { + id: true, + }, + }); + + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + defaultScheduleId: scheduleToSetAsDefault?.id || null, + }, + }); + } + await prisma.schedule.delete({ + where: { + id: input.scheduleId, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/delete.schema.ts b/packages/trpc/server/routers/viewer/availability/schedule/delete.schema.ts new file mode 100644 index 0000000000..8c8fd5a9b9 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + scheduleId: z.number(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/get.handler.ts b/packages/trpc/server/routers/viewer/availability/schedule/get.handler.ts new file mode 100644 index 0000000000..40a43f0320 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/get.handler.ts @@ -0,0 +1,100 @@ +import dayjs from "@calcom/dayjs"; +import { getWorkingHours } from "@calcom/lib/availability"; +import { yyyymmdd } from "@calcom/lib/date-fns"; +import { prisma } from "@calcom/prisma"; +import type { TimeRange } from "@calcom/types/schedule"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../../trpc"; +import { convertScheduleToAvailability, getDefaultScheduleId } from "../util"; +import type { TGetInputSchema } from "./get.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TGetInputSchema; +}; + +export const getHandler = async ({ ctx, input }: GetOptions) => { + const { user } = ctx; + + const schedule = await prisma.schedule.findUnique({ + where: { + id: input.scheduleId || (await getDefaultScheduleId(user.id, prisma)), + }, + select: { + id: true, + userId: true, + name: true, + availability: true, + timeZone: true, + eventType: { + select: { + _count: true, + id: true, + eventName: true, + }, + }, + }, + }); + if (!schedule || (schedule.userId !== user.id && !input.isManagedEventType)) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + const timeZone = schedule.timeZone || user.timeZone; + + const schedulesCount = await prisma.schedule.count({ + where: { + userId: user.id, + }, + }); + return { + id: schedule.id, + name: schedule.name, + isManaged: schedule.userId !== user.id, + workingHours: getWorkingHours({ timeZone: schedule.timeZone || undefined }, schedule.availability || []), + schedule: schedule.availability, + availability: convertScheduleToAvailability(schedule).map((a) => + a.map((startAndEnd) => ({ + ...startAndEnd, + // Turn our limited granularity into proper end of day. + end: new Date(startAndEnd.end.toISOString().replace("23:59:00.000Z", "23:59:59.999Z")), + })) + ), + timeZone, + dateOverrides: schedule.availability.reduce((acc, override) => { + // only iff future date override + if (!override.date || dayjs.tz(override.date, timeZone).isBefore(dayjs(), "day")) { + return acc; + } + const newValue = { + start: dayjs + .utc(override.date) + .hour(override.startTime.getUTCHours()) + .minute(override.startTime.getUTCMinutes()) + .toDate(), + end: dayjs + .utc(override.date) + .hour(override.endTime.getUTCHours()) + .minute(override.endTime.getUTCMinutes()) + .toDate(), + }; + const dayRangeIndex = acc.findIndex( + // early return prevents override.date from ever being empty. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (item) => yyyymmdd(item.ranges[0].start) === yyyymmdd(override.date!) + ); + if (dayRangeIndex === -1) { + acc.push({ ranges: [newValue] }); + return acc; + } + acc[dayRangeIndex].ranges.push(newValue); + return acc; + }, [] as { ranges: TimeRange[] }[]), + isDefault: !input.scheduleId || user.defaultScheduleId === schedule.id, + isLastSchedule: schedulesCount <= 1, + }; +}; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/get.schema.ts b/packages/trpc/server/routers/viewer/availability/schedule/get.schema.ts new file mode 100644 index 0000000000..3e83bce0fd --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/get.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZGetInputSchema = z.object({ + scheduleId: z.optional(z.number()), + isManagedEventType: z.optional(z.boolean()), +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/update.handler.ts b/packages/trpc/server/routers/viewer/availability/schedule/update.handler.ts new file mode 100644 index 0000000000..e5d7cf4d3f --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/update.handler.ts @@ -0,0 +1,121 @@ +import { getAvailabilityFromSchedule } from "@calcom/lib/availability"; +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../../trpc"; +import { convertScheduleToAvailability, setupDefaultSchedule } from "../util"; +import type { TUpdateInputSchema } from "./update.schema"; + +type UpdateOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateInputSchema; +}; + +export const updateHandler = async ({ input, ctx }: UpdateOptions) => { + const { user } = ctx; + const availability = input.schedule + ? getAvailabilityFromSchedule(input.schedule) + : (input.dateOverrides || []).map((dateOverride) => ({ + startTime: dateOverride.start, + endTime: dateOverride.end, + date: dateOverride.start, + days: [], + })); + + // Not able to update the schedule with userId where clause, so fetch schedule separately and then validate + // Bug: https://github.com/prisma/prisma/issues/7290 + const userSchedule = await prisma.schedule.findUnique({ + where: { + id: input.scheduleId, + }, + select: { + userId: true, + name: true, + id: true, + }, + }); + + if (userSchedule?.userId !== user.id) throw new TRPCError({ code: "UNAUTHORIZED" }); + + if (!userSchedule || userSchedule.userId !== user.id) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + + let updatedUser; + if (input.isDefault) { + const setupDefault = await setupDefaultSchedule(user.id, input.scheduleId, prisma); + updatedUser = setupDefault; + } + + if (!input.name) { + // TODO: Improve + // We don't want to pass the full schedule for just a set as default update + // but in the current logic, this wipes the existing availability. + // Return early to prevent this from happening. + return { + schedule: userSchedule, + isDefault: updatedUser + ? updatedUser.defaultScheduleId === input.scheduleId + : user.defaultScheduleId === input.scheduleId, + }; + } + + const schedule = await prisma.schedule.update({ + where: { + id: input.scheduleId, + }, + data: { + timeZone: input.timeZone, + name: input.name, + availability: { + deleteMany: { + scheduleId: { + equals: input.scheduleId, + }, + }, + createMany: { + data: [ + ...availability, + ...(input.dateOverrides || []).map((override) => ({ + date: override.start, + startTime: override.start, + endTime: override.end, + })), + ], + }, + }, + }, + select: { + id: true, + userId: true, + name: true, + availability: true, + timeZone: true, + eventType: { + select: { + _count: true, + id: true, + eventName: true, + }, + }, + }, + }); + + const userAvailability = convertScheduleToAvailability(schedule); + + return { + schedule, + availability: userAvailability, + timeZone: schedule.timeZone || user.timeZone, + isDefault: updatedUser + ? updatedUser.defaultScheduleId === schedule.id + : user.defaultScheduleId === schedule.id, + prevDefaultId: user.defaultScheduleId, + currentDefaultId: updatedUser ? updatedUser.defaultScheduleId : user.defaultScheduleId, + }; +}; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/update.schema.ts b/packages/trpc/server/routers/viewer/availability/schedule/update.schema.ts new file mode 100644 index 0000000000..e37588842c --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/update.schema.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +export const ZUpdateInputSchema = z.object({ + scheduleId: z.number(), + timeZone: z.string().optional(), + name: z.string().optional(), + isDefault: z.boolean().optional(), + schedule: z + .array( + z.array( + z.object({ + start: z.date(), + end: z.date(), + }) + ) + ) + .optional(), + dateOverrides: z + .array( + z.object({ + start: z.date(), + end: z.date(), + }) + ) + .optional(), +}); + +export type TUpdateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability/user.handler.ts b/packages/trpc/server/routers/viewer/availability/user.handler.ts new file mode 100644 index 0000000000..d45c2d85c0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/user.handler.ts @@ -0,0 +1,12 @@ +import { getUserAvailability } from "@calcom/core/getUserAvailability"; + +import type { TUserInputSchema } from "./user.schema"; + +type UserOptions = { + ctx: Record; + input: TUserInputSchema; +}; + +export const userHandler = async ({ input }: UserOptions) => { + return getUserAvailability(input); +}; diff --git a/packages/trpc/server/routers/viewer/availability/user.schema.ts b/packages/trpc/server/routers/viewer/availability/user.schema.ts new file mode 100644 index 0000000000..c5920e81dd --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/user.schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { stringOrNumber } from "@calcom/prisma/zod-utils"; + +export const ZUserInputSchema = z.object({ + username: z.string(), + dateFrom: z.string(), + dateTo: z.string(), + eventTypeId: stringOrNumber.optional(), + withSource: z.boolean().optional(), +}); + +export type TUserInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability/util.ts b/packages/trpc/server/routers/viewer/availability/util.ts new file mode 100644 index 0000000000..94b3621274 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/util.ts @@ -0,0 +1,84 @@ +import type { Availability as AvailabilityModel, Schedule as ScheduleModel, User } from "@prisma/client"; + +import type { PrismaClient } from "@calcom/prisma/client"; +import type { Schedule } from "@calcom/types/schedule"; + +export const getDefaultScheduleId = async (userId: number, prisma: PrismaClient) => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + defaultScheduleId: true, + }, + }); + + if (user?.defaultScheduleId) { + return user.defaultScheduleId; + } + + // If we're returning the default schedule for the first time then we should set it in the user record + const defaultSchedule = await prisma.schedule.findFirst({ + where: { + userId, + }, + select: { + id: true, + }, + }); + + return defaultSchedule?.id; // TODO: Handle no schedules AT ALL +}; + +export const hasDefaultSchedule = async (user: Partial, prisma: PrismaClient) => { + const defaultSchedule = await prisma.schedule.findFirst({ + where: { + userId: user.id, + }, + }); + return !!user.defaultScheduleId || !!defaultSchedule; +}; + +export const convertScheduleToAvailability = ( + schedule: Partial & { availability: AvailabilityModel[] } +) => { + return schedule.availability.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(() => []) + ); +}; + +export const setupDefaultSchedule = async (userId: number, scheduleId: number, prisma: PrismaClient) => { + return prisma.user.update({ + where: { + id: userId, + }, + data: { + defaultScheduleId: scheduleId, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/bookings.tsx b/packages/trpc/server/routers/viewer/bookings.tsx deleted file mode 100644 index fccc6460ed..0000000000 --- a/packages/trpc/server/routers/viewer/bookings.tsx +++ /dev/null @@ -1,1039 +0,0 @@ -import { BookingStatus, MembershipRole, Prisma, SchedulingType, WorkflowMethods } from "@prisma/client"; -import type { BookingReference, EventType, User, WebhookTriggerEvents } from "@prisma/client"; -import type { TFunction } from "next-i18next"; -import { z } from "zod"; - -import appStore from "@calcom/app-store"; -import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; -import { DailyLocationType } from "@calcom/app-store/locations"; -import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; -import EventManager from "@calcom/core/EventManager"; -import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder"; -import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director"; -import { deleteMeeting } from "@calcom/core/videoClient"; -import dayjs from "@calcom/dayjs"; -import { deleteScheduledEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager"; -import { deleteScheduledSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager"; -import { sendDeclinedEmails, sendLocationChangeEmails, sendRequestRescheduleEmail } from "@calcom/emails"; -import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; -import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; -import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; -import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; -import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; -import logger from "@calcom/lib/logger"; -import { getTranslation } from "@calcom/lib/server"; -import { bookingMinimalSelect } from "@calcom/prisma"; -import { bookingConfirmPatchBodySchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; -import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types/Calendar"; - -import { TRPCError } from "@trpc/server"; - -import { authedProcedure, router } from "../../trpc"; - -export type PersonAttendeeCommonFields = Pick< - User, - "id" | "email" | "name" | "locale" | "timeZone" | "username" ->; - -// Common data for all endpoints under webhook -const commonBookingSchema = z.object({ - bookingId: z.number(), -}); - -const bookingsProcedure = authedProcedure.input(commonBookingSchema).use(async ({ ctx, input, next }) => { - // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input - const { bookingId } = input; - const booking = await ctx.prisma.booking.findFirst({ - where: { - id: bookingId, - AND: [ - { - OR: [ - /* If user is organizer */ - { userId: ctx.user.id }, - /* Or part of a collective booking */ - { - eventType: { - schedulingType: SchedulingType.COLLECTIVE, - users: { - some: { - id: ctx.user.id, - }, - }, - }, - }, - ], - }, - ], - }, - include: { - attendees: true, - eventType: true, - destinationCalendar: true, - references: true, - user: { - include: { - destinationCalendar: true, - credentials: true, - }, - }, - }, - }); - - if (!booking) throw new TRPCError({ code: "UNAUTHORIZED" }); - - return next({ ctx: { booking } }); -}); - -export const bookingsRouter = router({ - get: authedProcedure - .input( - z.object({ - filters: z.object({ - teamIds: z.number().array().optional(), - userIds: z.number().array().optional(), - status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]), - eventTypeIds: z.number().array().optional(), - }), - limit: z.number().min(1).max(100).nullish(), - cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type - }) - ) - .query(async ({ 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.filters.status; - const bookingListingFilters: Record = { - 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: [ - { - recurringEventId: { not: null }, - status: { equals: BookingStatus.ACCEPTED }, - }, - { - 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" }, - }; - - // TODO: Fix record typing - const bookingWhereInputFilters: Record = { - teamIds: { - AND: [ - { - eventType: { - team: { - id: { - in: input.filters?.teamIds, - }, - }, - }, - }, - ], - }, - userIds: { - AND: [ - { - eventType: { - users: { - some: { - id: { - in: input.filters?.userIds, - }, - }, - }, - }, - }, - ], - }, - }; - - const filtersCombined: Prisma.BookingWhereInput[] = - input.filters && - Object.keys(input.filters).map((key) => { - return bookingWhereInputFilters[key]; - }); - - const passedBookingsStatusFilter = bookingListingFilters[bookingListingByStatus]; - const orderBy = bookingListingOrderby[bookingListingByStatus]; - - const [bookingsQuery, recurringInfoBasic, recurringInfoExtended] = await Promise.all([ - prisma.booking.findMany({ - where: { - OR: [ - { - userId: user.id, - }, - { - attendees: { - some: { - email: user.email, - }, - }, - }, - { - eventType: { - team: { - members: { - some: { - userId: user.id, - role: { - in: ["ADMIN", "OWNER"], - }, - }, - }, - }, - }, - }, - { - seatsReferences: { - some: { - attendee: { - email: user.email, - }, - }, - }, - }, - ], - AND: [passedBookingsStatusFilter, ...(filtersCombined ?? [])], - }, - select: { - ...bookingMinimalSelect, - uid: true, - recurringEventId: true, - location: true, - eventType: { - select: { - slug: true, - id: true, - eventName: true, - price: true, - recurringEvent: true, - currency: true, - metadata: true, - team: { - select: { - name: true, - }, - }, - }, - }, - status: true, - paid: true, - payment: { - select: { - paymentOption: true, - amount: true, - currency: true, - success: true, - }, - }, - user: { - select: { - id: true, - name: true, - email: true, - }, - }, - rescheduled: true, - references: true, - isRecorded: true, - seatsReferences: { - where: { - attendee: { - email: user.email, - }, - }, - select: { - referenceUid: true, - attendee: { - select: { - email: true, - }, - }, - }, - }, - }, - orderBy, - take: take + 1, - skip, - }), - prisma.booking.groupBy({ - by: ["recurringEventId"], - _min: { - startTime: true, - }, - _count: { - recurringEventId: true, - }, - where: { - recurringEventId: { - not: { equals: null }, - }, - userId: user.id, - }, - }), - prisma.booking.groupBy({ - by: ["recurringEventId", "status", "startTime"], - _min: { - startTime: true, - }, - where: { - recurringEventId: { - not: { equals: null }, - }, - userId: user.id, - }, - }), - ]); - - const recurringInfo = recurringInfoBasic.map( - ( - info: (typeof recurringInfoBasic)[number] - ): { - recurringEventId: string | null; - count: number; - firstDate: Date | null; - bookings: { - [key: string]: Date[]; - }; - } => { - const bookings = recurringInfoExtended.reduce( - (prev, curr) => { - if (curr.recurringEventId === info.recurringEventId) { - prev[curr.status].push(curr.startTime); - } - return prev; - }, - { ACCEPTED: [], CANCELLED: [], REJECTED: [], PENDING: [] } as { - [key in BookingStatus]: Date[]; - } - ); - return { - recurringEventId: info.recurringEventId, - count: info._count.recurringEventId, - firstDate: info._min.startTime, - bookings, - }; - } - ); - - const bookings = bookingsQuery.map((booking) => { - return { - ...booking, - eventType: { - ...booking.eventType, - recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), - price: booking.eventType?.price || 0, - currency: booking.eventType?.currency || "usd", - metadata: EventTypeMetaDataSchema.parse(booking.eventType?.metadata || {}), - }, - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - }; - }); - - const bookingsFetched = bookings.length; - let nextCursor: typeof skip | null = skip; - if (bookingsFetched > take) { - nextCursor += bookingsFetched; - } else { - nextCursor = null; - } - - return { - bookings, - recurringInfo, - nextCursor, - }; - }), - requestReschedule: authedProcedure - .input( - z.object({ - bookingId: z.string(), - rescheduleReason: z.string().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user, prisma } = ctx; - const { bookingId, rescheduleReason: cancellationReason } = input; - - const bookingToReschedule = await prisma.booking.findFirstOrThrow({ - select: { - id: true, - uid: true, - userId: true, - title: true, - description: true, - startTime: true, - endTime: true, - eventTypeId: true, - eventType: true, - location: true, - attendees: true, - references: true, - customInputs: true, - dynamicEventSlugRef: true, - dynamicGroupSlugRef: true, - destinationCalendar: true, - smsReminderNumber: true, - scheduledJobs: true, - workflowReminders: true, - responses: true, - }, - where: { - uid: bookingId, - NOT: { - status: { - in: [BookingStatus.CANCELLED, BookingStatus.REJECTED], - }, - }, - }, - }); - - if (!bookingToReschedule.userId) { - throw new TRPCError({ code: "FORBIDDEN", message: "Booking to reschedule doesn't have an owner" }); - } - - if (!bookingToReschedule.eventType) { - throw new TRPCError({ code: "FORBIDDEN", message: "EventType not found for current booking." }); - } - - const bookingBelongsToTeam = !!bookingToReschedule.eventType?.teamId; - - const userTeams = await prisma.user.findUniqueOrThrow({ - where: { - id: user.id, - }, - select: { - teams: true, - }, - }); - - if (bookingBelongsToTeam && bookingToReschedule.eventType?.teamId) { - const userTeamIds = userTeams.teams.map((item) => item.teamId); - if (userTeamIds.indexOf(bookingToReschedule?.eventType?.teamId) === -1) { - throw new TRPCError({ code: "FORBIDDEN", message: "User isn't a member on the team" }); - } - } - if (!bookingBelongsToTeam && bookingToReschedule.userId !== user.id) { - throw new TRPCError({ code: "FORBIDDEN", message: "User isn't owner of the current booking" }); - } - - if (bookingToReschedule) { - let event: Partial = {}; - if (bookingToReschedule.eventTypeId) { - event = await prisma.eventType.findFirstOrThrow({ - select: { - title: true, - users: true, - schedulingType: true, - recurringEvent: true, - }, - where: { - id: bookingToReschedule.eventTypeId, - }, - }); - } - await prisma.booking.update({ - where: { - id: bookingToReschedule.id, - }, - data: { - rescheduled: true, - cancellationReason, - status: BookingStatus.CANCELLED, - updatedAt: dayjs().toISOString(), - }, - }); - - // delete scheduled jobs of previous booking - cancelScheduledJobs(bookingToReschedule); - - //cancel workflow reminders of previous booking - bookingToReschedule.workflowReminders.forEach((reminder) => { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.id, reminder.referenceId); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.id, reminder.referenceId); - } - }); - - const [mainAttendee] = bookingToReschedule.attendees; - // @NOTE: Should we assume attendees language? - const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common"); - const usersToPeopleType = ( - users: PersonAttendeeCommonFields[], - selectedLanguage: TFunction - ): Person[] => { - return users?.map((user) => { - return { - email: user.email || "", - name: user.name || "", - username: user?.username || "", - language: { translate: selectedLanguage, locale: user.locale || "en" }, - timeZone: user?.timeZone, - }; - }); - }; - - const userTranslation = await getTranslation(user.locale ?? "en", "common"); - const [userAsPeopleType] = usersToPeopleType([user], userTranslation); - - const builder = new CalendarEventBuilder(); - builder.init({ - title: bookingToReschedule.title, - type: event && event.title ? event.title : bookingToReschedule.title, - startTime: bookingToReschedule.startTime.toISOString(), - endTime: bookingToReschedule.endTime.toISOString(), - attendees: usersToPeopleType( - // username field doesn't exists on attendee but could be in the future - bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], - tAttendees - ), - organizer: userAsPeopleType, - }); - - const director = new CalendarEventDirector(); - director.setBuilder(builder); - director.setExistingBooking(bookingToReschedule); - cancellationReason && director.setCancellationReason(cancellationReason); - if (event) { - await director.buildForRescheduleEmail(); - } else { - await director.buildWithoutEventTypeForRescheduleEmail(); - } - - // Handling calendar and videos cancellation - // This can set previous time as available, until virtual calendar is done - const credentialsMap = new Map(); - user.credentials.forEach((credential) => { - credentialsMap.set(credential.type, credential); - }); - const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter((ref) => - credentialsMap.has(ref.type) - ); - bookingRefsFiltered.forEach(async (bookingRef) => { - if (bookingRef.uid) { - if (bookingRef.type.endsWith("_calendar")) { - const calendar = await getCalendar(credentialsMap.get(bookingRef.type)); - - return calendar?.deleteEvent( - bookingRef.uid, - builder.calendarEvent, - bookingRef.externalCalendarId - ); - } else if (bookingRef.type.endsWith("_video")) { - return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid); - } - } - }); - - // Send emails - await sendRequestRescheduleEmail(builder.calendarEvent, { - rescheduleLink: builder.rescheduleLink, - }); - - const evt: CalendarEvent = { - title: bookingToReschedule?.title, - type: event && event.title ? event.title : bookingToReschedule.title, - description: bookingToReschedule?.description || "", - customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs), - ...getCalEventResponses({ - booking: bookingToReschedule, - bookingFields: bookingToReschedule.eventType?.bookingFields ?? null, - }), - startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "", - endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "", - organizer: userAsPeopleType, - attendees: usersToPeopleType( - // username field doesn't exists on attendee but could be in the future - bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], - tAttendees - ), - uid: bookingToReschedule?.uid, - location: bookingToReschedule?.location, - destinationCalendar: - bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar, - cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this - }; - - // Send webhook - const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; - // Send Webhook call if hooked to BOOKING.CANCELLED - const subscriberOptions = { - userId: bookingToReschedule.userId, - eventTypeId: (bookingToReschedule.eventTypeId as number) || 0, - triggerEvent: eventTrigger, - }; - const webhooks = await getWebhooks(subscriberOptions); - const promises = webhooks.map((webhook) => - sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, { - ...evt, - smsReminderNumber: bookingToReschedule.smsReminderNumber || undefined, - }).catch((e) => { - console.error( - `Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, - e - ); - }) - ); - await Promise.all(promises); - } - }), - editLocation: bookingsProcedure - .input( - commonBookingSchema.extend({ - newLocation: z.string().transform((val) => val || DailyLocationType), - }) - ) - - .mutation(async ({ ctx, input }) => { - const { bookingId, newLocation: location } = input; - const { booking } = ctx; - - try { - const organizer = await ctx.prisma.user.findFirstOrThrow({ - where: { - id: booking.userId || 0, - }, - select: { - name: true, - email: true, - timeZone: true, - locale: true, - }, - }); - - const tOrganizer = await getTranslation(organizer.locale ?? "en", "common"); - - 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 evt: CalendarEvent = { - title: booking.title || "", - type: (booking.eventType?.title as string) || booking?.title || "", - description: booking.description || "", - startTime: booking.startTime ? dayjs(booking.startTime).format() : "", - endTime: booking.endTime ? dayjs(booking.endTime).format() : "", - organizer: { - email: organizer.email, - name: organizer.name ?? "Nameless", - timeZone: organizer.timeZone, - language: { translate: tOrganizer, locale: organizer.locale ?? "en" }, - }, - attendees: attendeesList, - uid: booking.uid, - recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), - location, - destinationCalendar: booking?.destinationCalendar || booking?.user?.destinationCalendar, - seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, - seatsShowAttendees: booking.eventType?.seatsShowAttendees, - }; - - const eventManager = new EventManager(ctx.user); - - const updatedResult = await eventManager.updateLocation(evt, booking); - const results = updatedResult.results; - if (results.length > 0 && results.every((res) => !res.success)) { - const error = { - errorCode: "BookingUpdateLocationFailed", - message: "Updating location failed", - }; - logger.error(`Booking ${ctx.user.username} failed`, error, results); - } else { - await ctx.prisma.booking.update({ - where: { - id: bookingId, - }, - data: { - location, - references: { - create: updatedResult.referencesToCreate, - }, - }, - }); - - const metadata: AdditionalInformation = {}; - if (results.length) { - metadata.hangoutLink = results[0].updatedEvent?.hangoutLink; - metadata.conferenceData = results[0].updatedEvent?.conferenceData; - metadata.entryPoints = results[0].updatedEvent?.entryPoints; - } - try { - await sendLocationChangeEmails({ ...evt, additionalInformation: metadata }); - } catch (error) { - console.log("Error sending LocationChangeEmails"); - } - } - } catch { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); - } - return { message: "Location updated" }; - }), - confirm: bookingsProcedure.input(bookingConfirmPatchBodySchema).mutation(async ({ ctx, input }) => { - const { user, prisma } = ctx; - const { bookingId, recurringEventId, reason: rejectionReason, confirmed } = input; - - const tOrganizer = await getTranslation(user.locale ?? "en", "common"); - - const booking = await prisma.booking.findUniqueOrThrow({ - where: { - id: bookingId, - }, - select: { - title: true, - description: true, - customInputs: true, - startTime: true, - endTime: true, - attendees: true, - eventTypeId: true, - responses: true, - eventType: { - select: { - id: true, - owner: true, - teamId: true, - recurringEvent: true, - title: true, - requiresConfirmation: true, - currency: true, - length: true, - description: true, - price: true, - bookingFields: true, - disableGuests: true, - metadata: true, - workflows: { - include: { - workflow: { - include: { - steps: true, - }, - }, - }, - }, - customInputs: true, - }, - }, - location: true, - userId: true, - id: true, - uid: true, - payment: true, - destinationCalendar: true, - paid: true, - recurringEventId: true, - status: true, - smsReminderNumber: true, - scheduledJobs: true, - }, - }); - - if (booking.userId !== user.id && booking.eventTypeId) { - // Only query database when it is explicitly required. - const eventType = await prisma.eventType.findFirst({ - where: { - id: booking.eventTypeId, - schedulingType: SchedulingType.COLLECTIVE, - }, - select: { - users: true, - }, - }); - - if (eventType && !eventType.users.find((user) => booking.userId === user.id)) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "UNAUTHORIZED" }); - } - } - - // Do not move this before authorization check. - // This is done to avoid exposing extra information to the requester. - if (booking.status === BookingStatus.ACCEPTED) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Booking already confirmed" }); - } - - // If booking requires payment and is not paid, we don't allow confirmation - if (confirmed && booking.payment.length > 0 && !booking.paid) { - await prisma.booking.update({ - where: { - id: bookingId, - }, - data: { - status: BookingStatus.ACCEPTED, - }, - }); - - return { message: "Booking confirmed", status: BookingStatus.ACCEPTED }; - } - - // Cache translations to avoid requesting multiple times. - const translations = new Map(); - const attendeesListPromises = booking.attendees.map(async (attendee) => { - const locale = attendee.locale ?? "en"; - let translate = translations.get(locale); - if (!translate) { - translate = await getTranslation(locale, "common"); - translations.set(locale, translate); - } - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { - translate, - locale, - }, - }; - }); - - const attendeesList = await Promise.all(attendeesListPromises); - - const evt: CalendarEvent = { - type: booking.eventType?.title || booking.title, - title: booking.title, - description: booking.description, - // TODO: Remove the usage of `bookingFields` in computing responses. We can do that by storing `label` with the response. Also, this would allow us to correctly show the label for a field even after the Event Type has been deleted. - ...getCalEventResponses({ - bookingFields: booking.eventType?.bookingFields ?? null, - booking, - }), - customInputs: isPrismaObjOrUndefined(booking.customInputs), - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - organizer: { - email: user.email, - name: user.name || "Unnamed", - username: user.username || undefined, - timeZone: user.timeZone, - language: { translate: tOrganizer, locale: user.locale ?? "en" }, - }, - attendees: attendeesList, - location: booking.location ?? "", - uid: booking.uid, - destinationCalendar: booking?.destinationCalendar || user.destinationCalendar, - requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false, - eventTypeId: booking.eventType?.id, - }; - - const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent); - if (recurringEventId) { - if ( - !(await prisma.booking.findFirst({ - where: { - recurringEventId, - id: booking.id, - }, - select: { - id: true, - }, - })) - ) { - // FIXME: It might be best to retrieve recurringEventId from the booking itself. - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Recurring event id doesn't belong to the booking", - }); - } - } - if (recurringEventId && recurringEvent) { - const groupedRecurringBookings = await prisma.booking.groupBy({ - where: { - recurringEventId: booking.recurringEventId, - }, - by: [Prisma.BookingScalarFieldEnum.recurringEventId], - _count: true, - }); - // Overriding the recurring event configuration count to be the actual number of events booked for - // the recurring event (equal or less than recurring event configuration count) - recurringEvent.count = groupedRecurringBookings[0]._count; - // count changed, parsing again to get the new value in - evt.recurringEvent = parseRecurringEvent(recurringEvent); - } - - if (confirmed) { - await handleConfirmation({ user, evt, recurringEventId, prisma, bookingId, booking }); - } else { - evt.rejectionReason = rejectionReason; - if (recurringEventId) { - // The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related - // bookings as rejected. - await prisma.booking.updateMany({ - where: { - recurringEventId, - status: BookingStatus.PENDING, - }, - data: { - status: BookingStatus.REJECTED, - rejectionReason, - }, - }); - } else { - // handle refunds - if (!!booking.payment.length) { - const successPayment = booking.payment.find((payment) => payment.success); - if (!successPayment) { - // Disable paymentLink for this booking - } else { - let eventTypeOwnerId; - if (booking.eventType?.owner) { - eventTypeOwnerId = booking.eventType.owner.id; - } else if (booking.eventType?.teamId) { - const teamOwner = await prisma.membership.findFirst({ - where: { - teamId: booking.eventType.teamId, - role: MembershipRole.OWNER, - }, - select: { - userId: true, - }, - }); - eventTypeOwnerId = teamOwner?.userId; - } - - if (!eventTypeOwnerId) { - throw new Error("Event Type owner not found for obtaining payment app credentials"); - } - - const paymentAppCredentials = await prisma.credential.findMany({ - where: { - userId: eventTypeOwnerId, - appId: successPayment.appId, - }, - select: { - key: true, - appId: true, - app: { - select: { - categories: true, - dirName: true, - }, - }, - }, - }); - - const paymentAppCredential = paymentAppCredentials.find((credential) => { - return credential.appId === successPayment.appId; - }); - - if (!paymentAppCredential) { - throw new Error("Payment app credentials not found"); - } - - // Posible to refactor TODO: - const paymentApp = await appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore]; - if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) { - console.warn(`payment App service of type ${paymentApp} is not implemented`); - return null; - } - - const PaymentService = paymentApp.lib.PaymentService; - const paymentInstance = new PaymentService(paymentAppCredential); - const paymentData = await paymentInstance.refund(successPayment.id); - if (!paymentData.refunded) { - throw new Error("Payment could not be refunded"); - } - } - } - // end handle refunds. - - await prisma.booking.update({ - where: { - id: bookingId, - }, - data: { - status: BookingStatus.REJECTED, - rejectionReason, - }, - }); - } - - await sendDeclinedEmails(evt); - } - - const message = "Booking " + confirmed ? "confirmed" : "rejected"; - const status = confirmed ? BookingStatus.ACCEPTED : BookingStatus.REJECTED; - - return { message, status }; - }), - getBookingAttendees: authedProcedure - .input(z.object({ seatReferenceUid: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const bookingSeat = await ctx.prisma.bookingSeat.findUniqueOrThrow({ - where: { - referenceUid: input.seatReferenceUid, - }, - select: { - booking: { - select: { - _count: { - select: { - seatsReferences: true, - }, - }, - }, - }, - }, - }); - - if (!bookingSeat) { - throw new Error("Booking not found"); - } - return bookingSeat.booking._count.seatsReferences; - }), -}); diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx new file mode 100644 index 0000000000..e539268686 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -0,0 +1,107 @@ +import { authedProcedure, router } from "../../../trpc"; +import { ZConfirmInputSchema } from "./confirm.schema"; +import { ZEditLocationInputSchema } from "./editLocation.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema"; +import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema"; +import { bookingsProcedure } from "./util"; + +type BookingsRouterHandlerCache = { + get?: typeof import("./get.handler").getHandler; + requestReschedule?: typeof import("./requestReschedule.handler").requestRescheduleHandler; + editLocation?: typeof import("./editLocation.handler").editLocationHandler; + confirm?: typeof import("./confirm.handler").confirmHandler; + getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler; +}; + +const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {}; + +export const bookingsRouter = router({ + get: authedProcedure.input(ZGetInputSchema).query(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + requestReschedule: authedProcedure.input(ZRequestRescheduleInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.requestReschedule) { + UNSTABLE_HANDLER_CACHE.requestReschedule = await import("./requestReschedule.handler").then( + (mod) => mod.requestRescheduleHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.requestReschedule) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.requestReschedule({ + ctx, + input, + }); + }), + + editLocation: bookingsProcedure.input(ZEditLocationInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.editLocation) { + UNSTABLE_HANDLER_CACHE.editLocation = await import("./editLocation.handler").then( + (mod) => mod.editLocationHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.editLocation) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.editLocation({ + ctx, + input, + }); + }), + + confirm: bookingsProcedure.input(ZConfirmInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.confirm) { + UNSTABLE_HANDLER_CACHE.confirm = await import("./confirm.handler").then((mod) => mod.confirmHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.confirm) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.confirm({ + ctx, + input, + }); + }), + + getBookingAttendees: authedProcedure + .input(ZGetBookingAttendeesInputSchema) + .query(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getBookingAttendees) { + UNSTABLE_HANDLER_CACHE.getBookingAttendees = await import("./getBookingAttendees.handler").then( + (mod) => mod.getBookingAttendeesHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getBookingAttendees) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getBookingAttendees({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts new file mode 100644 index 0000000000..cde45e620a --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -0,0 +1,310 @@ +import { BookingStatus, MembershipRole, Prisma, SchedulingType } from "@prisma/client"; + +import appStore from "@calcom/app-store"; +import { sendDeclinedEmails } from "@calcom/emails"; +import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; +import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; +import { getTranslation } from "@calcom/lib/server"; +import { prisma } from "@calcom/prisma"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TConfirmInputSchema } from "./confirm.schema"; +import type { BookingsProcedureContext } from "./util"; + +type ConfirmOptions = { + ctx: { + user: NonNullable; + } & BookingsProcedureContext; + input: TConfirmInputSchema; +}; + +export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { + const { user } = ctx; + const { bookingId, recurringEventId, reason: rejectionReason, confirmed } = input; + + const tOrganizer = await getTranslation(user.locale ?? "en", "common"); + + const booking = await prisma.booking.findUniqueOrThrow({ + where: { + id: bookingId, + }, + select: { + title: true, + description: true, + customInputs: true, + startTime: true, + endTime: true, + attendees: true, + eventTypeId: true, + responses: true, + eventType: { + select: { + id: true, + owner: true, + teamId: true, + recurringEvent: true, + title: true, + requiresConfirmation: true, + currency: true, + length: true, + description: true, + price: true, + bookingFields: true, + disableGuests: true, + metadata: true, + workflows: { + include: { + workflow: { + include: { + steps: true, + }, + }, + }, + }, + customInputs: true, + }, + }, + location: true, + userId: true, + id: true, + uid: true, + payment: true, + destinationCalendar: true, + paid: true, + recurringEventId: true, + status: true, + smsReminderNumber: true, + scheduledJobs: true, + }, + }); + + if (booking.userId !== user.id && booking.eventTypeId) { + // Only query database when it is explicitly required. + const eventType = await prisma.eventType.findFirst({ + where: { + id: booking.eventTypeId, + schedulingType: SchedulingType.COLLECTIVE, + }, + select: { + users: true, + }, + }); + + if (eventType && !eventType.users.find((user) => booking.userId === user.id)) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "UNAUTHORIZED" }); + } + } + + // Do not move this before authorization check. + // This is done to avoid exposing extra information to the requester. + if (booking.status === BookingStatus.ACCEPTED) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Booking already confirmed" }); + } + + // If booking requires payment and is not paid, we don't allow confirmation + if (confirmed && booking.payment.length > 0 && !booking.paid) { + await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + status: BookingStatus.ACCEPTED, + }, + }); + + return { message: "Booking confirmed", status: BookingStatus.ACCEPTED }; + } + + // Cache translations to avoid requesting multiple times. + const translations = new Map(); + const attendeesListPromises = booking.attendees.map(async (attendee) => { + const locale = attendee.locale ?? "en"; + let translate = translations.get(locale); + if (!translate) { + translate = await getTranslation(locale, "common"); + translations.set(locale, translate); + } + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate, + locale, + }, + }; + }); + + const attendeesList = await Promise.all(attendeesListPromises); + + const evt: CalendarEvent = { + type: booking.eventType?.title || booking.title, + title: booking.title, + description: booking.description, + // TODO: Remove the usage of `bookingFields` in computing responses. We can do that by storing `label` with the response. Also, this would allow us to correctly show the label for a field even after the Event Type has been deleted. + ...getCalEventResponses({ + bookingFields: booking.eventType?.bookingFields ?? null, + booking, + }), + customInputs: isPrismaObjOrUndefined(booking.customInputs), + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { + email: user.email, + name: user.name || "Unnamed", + username: user.username || undefined, + timeZone: user.timeZone, + language: { translate: tOrganizer, locale: user.locale ?? "en" }, + }, + attendees: attendeesList, + location: booking.location ?? "", + uid: booking.uid, + destinationCalendar: booking?.destinationCalendar || user.destinationCalendar, + requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false, + eventTypeId: booking.eventType?.id, + }; + + const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent); + if (recurringEventId) { + if ( + !(await prisma.booking.findFirst({ + where: { + recurringEventId, + id: booking.id, + }, + select: { + id: true, + }, + })) + ) { + // FIXME: It might be best to retrieve recurringEventId from the booking itself. + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Recurring event id doesn't belong to the booking", + }); + } + } + if (recurringEventId && recurringEvent) { + const groupedRecurringBookings = await prisma.booking.groupBy({ + where: { + recurringEventId: booking.recurringEventId, + }, + by: [Prisma.BookingScalarFieldEnum.recurringEventId], + _count: true, + }); + // Overriding the recurring event configuration count to be the actual number of events booked for + // the recurring event (equal or less than recurring event configuration count) + recurringEvent.count = groupedRecurringBookings[0]._count; + // count changed, parsing again to get the new value in + evt.recurringEvent = parseRecurringEvent(recurringEvent); + } + + if (confirmed) { + await handleConfirmation({ user, evt, recurringEventId, prisma, bookingId, booking }); + } else { + evt.rejectionReason = rejectionReason; + if (recurringEventId) { + // The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related + // bookings as rejected. + await prisma.booking.updateMany({ + where: { + recurringEventId, + status: BookingStatus.PENDING, + }, + data: { + status: BookingStatus.REJECTED, + rejectionReason, + }, + }); + } else { + // handle refunds + if (!!booking.payment.length) { + const successPayment = booking.payment.find((payment) => payment.success); + if (!successPayment) { + // Disable paymentLink for this booking + } else { + let eventTypeOwnerId; + if (booking.eventType?.owner) { + eventTypeOwnerId = booking.eventType.owner.id; + } else if (booking.eventType?.teamId) { + const teamOwner = await prisma.membership.findFirst({ + where: { + teamId: booking.eventType.teamId, + role: MembershipRole.OWNER, + }, + select: { + userId: true, + }, + }); + eventTypeOwnerId = teamOwner?.userId; + } + + if (!eventTypeOwnerId) { + throw new Error("Event Type owner not found for obtaining payment app credentials"); + } + + const paymentAppCredentials = await prisma.credential.findMany({ + where: { + userId: eventTypeOwnerId, + appId: successPayment.appId, + }, + select: { + key: true, + appId: true, + app: { + select: { + categories: true, + dirName: true, + }, + }, + }, + }); + + const paymentAppCredential = paymentAppCredentials.find((credential) => { + return credential.appId === successPayment.appId; + }); + + if (!paymentAppCredential) { + throw new Error("Payment app credentials not found"); + } + + // Posible to refactor TODO: + const paymentApp = await appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore]; + if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) { + console.warn(`payment App service of type ${paymentApp} is not implemented`); + return null; + } + + const PaymentService = paymentApp.lib.PaymentService; + const paymentInstance = new PaymentService(paymentAppCredential); + const paymentData = await paymentInstance.refund(successPayment.id); + if (!paymentData.refunded) { + throw new Error("Payment could not be refunded"); + } + } + } + // end handle refunds. + + await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + status: BookingStatus.REJECTED, + rejectionReason, + }, + }); + } + + await sendDeclinedEmails(evt); + } + + const message = "Booking " + confirmed ? "confirmed" : "rejected"; + const status = confirmed ? BookingStatus.ACCEPTED : BookingStatus.REJECTED; + + return { message, status }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.schema.ts b/packages/trpc/server/routers/viewer/bookings/confirm.schema.ts new file mode 100644 index 0000000000..dcd8149427 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/confirm.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { bookingConfirmPatchBodySchema } from "@calcom/prisma/zod-utils"; + +export const ZConfirmInputSchema = bookingConfirmPatchBodySchema; + +export type TConfirmInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts new file mode 100644 index 0000000000..098335d87b --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -0,0 +1,116 @@ +import EventManager from "@calcom/core/EventManager"; +import dayjs from "@calcom/dayjs"; +import { sendLocationChangeEmails } from "@calcom/emails"; +import { parseRecurringEvent } from "@calcom/lib"; +import logger from "@calcom/lib/logger"; +import { getTranslation } from "@calcom/lib/server"; +import { prisma } from "@calcom/prisma"; +import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TEditLocationInputSchema } from "./editLocation.schema"; +import type { BookingsProcedureContext } from "./util"; + +type EditLocationOptions = { + ctx: { + user: NonNullable; + } & BookingsProcedureContext; + input: TEditLocationInputSchema; +}; + +export const editLocationHandler = async ({ ctx, input }: EditLocationOptions) => { + const { bookingId, newLocation: location } = input; + const { booking } = ctx; + + try { + const organizer = await prisma.user.findFirstOrThrow({ + where: { + id: booking.userId || 0, + }, + select: { + name: true, + email: true, + timeZone: true, + locale: true, + }, + }); + + const tOrganizer = await getTranslation(organizer.locale ?? "en", "common"); + + 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 evt: CalendarEvent = { + title: booking.title || "", + type: (booking.eventType?.title as string) || booking?.title || "", + description: booking.description || "", + startTime: booking.startTime ? dayjs(booking.startTime).format() : "", + endTime: booking.endTime ? dayjs(booking.endTime).format() : "", + organizer: { + email: organizer.email, + name: organizer.name ?? "Nameless", + timeZone: organizer.timeZone, + language: { translate: tOrganizer, locale: organizer.locale ?? "en" }, + }, + attendees: attendeesList, + uid: booking.uid, + recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), + location, + destinationCalendar: booking?.destinationCalendar || booking?.user?.destinationCalendar, + seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, + seatsShowAttendees: booking.eventType?.seatsShowAttendees, + }; + + const eventManager = new EventManager(ctx.user); + + const updatedResult = await eventManager.updateLocation(evt, booking); + const results = updatedResult.results; + if (results.length > 0 && results.every((res) => !res.success)) { + const error = { + errorCode: "BookingUpdateLocationFailed", + message: "Updating location failed", + }; + logger.error(`Booking ${ctx.user.username} failed`, error, results); + } else { + await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + location, + references: { + create: updatedResult.referencesToCreate, + }, + }, + }); + + const metadata: AdditionalInformation = {}; + if (results.length) { + metadata.hangoutLink = results[0].updatedEvent?.hangoutLink; + metadata.conferenceData = results[0].updatedEvent?.conferenceData; + metadata.entryPoints = results[0].updatedEvent?.entryPoints; + } + try { + await sendLocationChangeEmails({ ...evt, additionalInformation: metadata }); + } catch (error) { + console.log("Error sending LocationChangeEmails"); + } + } + } catch { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + return { message: "Location updated" }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts new file mode 100644 index 0000000000..bde1cd8c3f --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +import { DailyLocationType } from "@calcom/app-store/locations"; + +import { commonBookingSchema } from "./types"; + +export const ZEditLocationInputSchema = commonBookingSchema.extend({ + newLocation: z.string().transform((val) => val || DailyLocationType), +}); + +export type TEditLocationInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts new file mode 100644 index 0000000000..a2424600f4 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -0,0 +1,306 @@ +import { BookingStatus } from "@prisma/client"; + +import { parseRecurringEvent } from "@calcom/lib"; +import { bookingMinimalSelect } from "@calcom/prisma"; +import type { Prisma, PrismaClient } from "@calcom/prisma/client"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TGetInputSchema } from "./get.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TGetInputSchema; +}; + +export const getHandler = async ({ ctx, input }: GetOptions) => { + // 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.filters.status; + const bookingListingFilters: Record = { + 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: [ + { + recurringEventId: { not: null }, + status: { equals: BookingStatus.ACCEPTED }, + }, + { + 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" }, + }; + + // TODO: Fix record typing + const bookingWhereInputFilters: Record = { + teamIds: { + AND: [ + { + eventType: { + team: { + id: { + in: input.filters?.teamIds, + }, + }, + }, + }, + ], + }, + userIds: { + AND: [ + { + eventType: { + users: { + some: { + id: { + in: input.filters?.userIds, + }, + }, + }, + }, + }, + ], + }, + }; + + const filtersCombined: Prisma.BookingWhereInput[] = + input.filters && + Object.keys(input.filters).map((key) => { + return bookingWhereInputFilters[key]; + }); + + const passedBookingsStatusFilter = bookingListingFilters[bookingListingByStatus]; + const orderBy = bookingListingOrderby[bookingListingByStatus]; + + const [bookingsQuery, recurringInfoBasic, recurringInfoExtended] = await Promise.all([ + prisma.booking.findMany({ + where: { + OR: [ + { + userId: user.id, + }, + { + attendees: { + some: { + email: user.email, + }, + }, + }, + { + eventType: { + team: { + members: { + some: { + userId: user.id, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + }, + }, + }, + }, + { + seatsReferences: { + some: { + attendee: { + email: user.email, + }, + }, + }, + }, + ], + AND: [passedBookingsStatusFilter, ...(filtersCombined ?? [])], + }, + select: { + ...bookingMinimalSelect, + uid: true, + recurringEventId: true, + location: true, + eventType: { + select: { + slug: true, + id: true, + eventName: true, + price: true, + recurringEvent: true, + currency: true, + metadata: true, + team: { + select: { + name: true, + }, + }, + }, + }, + status: true, + paid: true, + payment: { + select: { + paymentOption: true, + amount: true, + currency: true, + success: true, + }, + }, + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + rescheduled: true, + references: true, + isRecorded: true, + seatsReferences: { + where: { + attendee: { + email: user.email, + }, + }, + select: { + referenceUid: true, + attendee: { + select: { + email: true, + }, + }, + }, + }, + }, + orderBy, + take: take + 1, + skip, + }), + prisma.booking.groupBy({ + by: ["recurringEventId"], + _min: { + startTime: true, + }, + _count: { + recurringEventId: true, + }, + where: { + recurringEventId: { + not: { equals: null }, + }, + userId: user.id, + }, + }), + prisma.booking.groupBy({ + by: ["recurringEventId", "status", "startTime"], + _min: { + startTime: true, + }, + where: { + recurringEventId: { + not: { equals: null }, + }, + userId: user.id, + }, + }), + ]); + + const recurringInfo = recurringInfoBasic.map( + ( + info: (typeof recurringInfoBasic)[number] + ): { + recurringEventId: string | null; + count: number; + firstDate: Date | null; + bookings: { + [key: string]: Date[]; + }; + } => { + const bookings = recurringInfoExtended.reduce( + (prev, curr) => { + if (curr.recurringEventId === info.recurringEventId) { + prev[curr.status].push(curr.startTime); + } + return prev; + }, + { ACCEPTED: [], CANCELLED: [], REJECTED: [], PENDING: [] } as { + [key in BookingStatus]: Date[]; + } + ); + return { + recurringEventId: info.recurringEventId, + count: info._count.recurringEventId, + firstDate: info._min.startTime, + bookings, + }; + } + ); + + const bookings = bookingsQuery.map((booking) => { + return { + ...booking, + eventType: { + ...booking.eventType, + recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), + price: booking.eventType?.price || 0, + currency: booking.eventType?.currency || "usd", + metadata: EventTypeMetaDataSchema.parse(booking.eventType?.metadata || {}), + }, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + }; + }); + + const bookingsFetched = bookings.length; + let nextCursor: typeof skip | null = skip; + if (bookingsFetched > take) { + nextCursor += bookingsFetched; + } else { + nextCursor = null; + } + + return { + bookings, + recurringInfo, + nextCursor, + }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/get.schema.ts b/packages/trpc/server/routers/viewer/bookings/get.schema.ts new file mode 100644 index 0000000000..c06284ac02 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/get.schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const ZGetInputSchema = z.object({ + filters: z.object({ + teamIds: z.number().array().optional(), + userIds: z.number().array().optional(), + status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]), + eventTypeIds: z.number().array().optional(), + }), + limit: z.number().min(1).max(100).nullish(), + cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/getBookingAttendees.handler.ts b/packages/trpc/server/routers/viewer/bookings/getBookingAttendees.handler.ts new file mode 100644 index 0000000000..2f0589a6ce --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getBookingAttendees.handler.ts @@ -0,0 +1,33 @@ +import { prisma } from "@calcom/prisma"; + +import type { TGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema"; + +type GetBookingAttendeesOptions = { + ctx: Record; + input: TGetBookingAttendeesInputSchema; +}; + +export const getBookingAttendeesHandler = async ({ ctx: _ctx, input }: GetBookingAttendeesOptions) => { + const bookingSeat = await prisma.bookingSeat.findUniqueOrThrow({ + where: { + referenceUid: input.seatReferenceUid, + }, + select: { + booking: { + select: { + _count: { + select: { + seatsReferences: true, + }, + }, + }, + }, + }, + }); + + if (!bookingSeat) { + throw new Error("Booking not found"); + } + + return bookingSeat.booking._count.seatsReferences; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/getBookingAttendees.schema.ts b/packages/trpc/server/routers/viewer/bookings/getBookingAttendees.schema.ts new file mode 100644 index 0000000000..39087267b1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getBookingAttendees.schema.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const ZGetBookingAttendeesInputSchema = z.object({ seatReferenceUid: z.string().uuid() }); + +export type TGetBookingAttendeesInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts new file mode 100644 index 0000000000..75150486a4 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -0,0 +1,254 @@ +import type { BookingReference, EventType, WebhookTriggerEvents } from "@prisma/client"; +import { BookingStatus, WorkflowMethods } from "@prisma/client"; +import type { TFunction } from "next-i18next"; + +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; +import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; +import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder"; +import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director"; +import { deleteMeeting } from "@calcom/core/videoClient"; +import dayjs from "@calcom/dayjs"; +import { deleteScheduledEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager"; +import { deleteScheduledSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager"; +import { sendRequestRescheduleEmail } from "@calcom/emails"; +import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; +import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; +import { isPrismaObjOrUndefined } from "@calcom/lib"; +import { getTranslation } from "@calcom/lib/server"; +import { prisma } from "@calcom/prisma"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TRequestRescheduleInputSchema } from "./requestReschedule.schema"; +import type { PersonAttendeeCommonFields } from "./types"; + +type RequestRescheduleOptions = { + ctx: { + user: NonNullable; + }; + input: TRequestRescheduleInputSchema; +}; + +export const requestRescheduleHandler = async ({ ctx, input }: RequestRescheduleOptions) => { + const { user } = ctx; + const { bookingId, rescheduleReason: cancellationReason } = input; + + const bookingToReschedule = await prisma.booking.findFirstOrThrow({ + select: { + id: true, + uid: true, + userId: true, + title: true, + description: true, + startTime: true, + endTime: true, + eventTypeId: true, + eventType: true, + location: true, + attendees: true, + references: true, + customInputs: true, + dynamicEventSlugRef: true, + dynamicGroupSlugRef: true, + destinationCalendar: true, + smsReminderNumber: true, + scheduledJobs: true, + workflowReminders: true, + responses: true, + }, + where: { + uid: bookingId, + NOT: { + status: { + in: [BookingStatus.CANCELLED, BookingStatus.REJECTED], + }, + }, + }, + }); + + if (!bookingToReschedule.userId) { + throw new TRPCError({ code: "FORBIDDEN", message: "Booking to reschedule doesn't have an owner" }); + } + + if (!bookingToReschedule.eventType) { + throw new TRPCError({ code: "FORBIDDEN", message: "EventType not found for current booking." }); + } + + const bookingBelongsToTeam = !!bookingToReschedule.eventType?.teamId; + + const userTeams = await prisma.user.findUniqueOrThrow({ + where: { + id: user.id, + }, + select: { + teams: true, + }, + }); + + if (bookingBelongsToTeam && bookingToReschedule.eventType?.teamId) { + const userTeamIds = userTeams.teams.map((item) => item.teamId); + if (userTeamIds.indexOf(bookingToReschedule?.eventType?.teamId) === -1) { + throw new TRPCError({ code: "FORBIDDEN", message: "User isn't a member on the team" }); + } + } + if (!bookingBelongsToTeam && bookingToReschedule.userId !== user.id) { + throw new TRPCError({ code: "FORBIDDEN", message: "User isn't owner of the current booking" }); + } + + if (bookingToReschedule) { + let event: Partial = {}; + if (bookingToReschedule.eventTypeId) { + event = await prisma.eventType.findFirstOrThrow({ + select: { + title: true, + users: true, + schedulingType: true, + recurringEvent: true, + }, + where: { + id: bookingToReschedule.eventTypeId, + }, + }); + } + await prisma.booking.update({ + where: { + id: bookingToReschedule.id, + }, + data: { + rescheduled: true, + cancellationReason, + status: BookingStatus.CANCELLED, + updatedAt: dayjs().toISOString(), + }, + }); + + // delete scheduled jobs of previous booking + cancelScheduledJobs(bookingToReschedule); + + //cancel workflow reminders of previous booking + bookingToReschedule.workflowReminders.forEach((reminder) => { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.id, reminder.referenceId); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.id, reminder.referenceId); + } + }); + + const [mainAttendee] = bookingToReschedule.attendees; + // @NOTE: Should we assume attendees language? + const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common"); + const usersToPeopleType = ( + users: PersonAttendeeCommonFields[], + selectedLanguage: TFunction + ): Person[] => { + return users?.map((user) => { + return { + email: user.email || "", + name: user.name || "", + username: user?.username || "", + language: { translate: selectedLanguage, locale: user.locale || "en" }, + timeZone: user?.timeZone, + }; + }); + }; + + const userTranslation = await getTranslation(user.locale ?? "en", "common"); + const [userAsPeopleType] = usersToPeopleType([user], userTranslation); + + const builder = new CalendarEventBuilder(); + builder.init({ + title: bookingToReschedule.title, + type: event && event.title ? event.title : bookingToReschedule.title, + startTime: bookingToReschedule.startTime.toISOString(), + endTime: bookingToReschedule.endTime.toISOString(), + attendees: usersToPeopleType( + // username field doesn't exists on attendee but could be in the future + bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], + tAttendees + ), + organizer: userAsPeopleType, + }); + + const director = new CalendarEventDirector(); + director.setBuilder(builder); + director.setExistingBooking(bookingToReschedule); + cancellationReason && director.setCancellationReason(cancellationReason); + if (event) { + await director.buildForRescheduleEmail(); + } else { + await director.buildWithoutEventTypeForRescheduleEmail(); + } + + // Handling calendar and videos cancellation + // This can set previous time as available, until virtual calendar is done + const credentialsMap = new Map(); + user.credentials.forEach((credential) => { + credentialsMap.set(credential.type, credential); + }); + const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter((ref) => + credentialsMap.has(ref.type) + ); + bookingRefsFiltered.forEach(async (bookingRef) => { + if (bookingRef.uid) { + if (bookingRef.type.endsWith("_calendar")) { + const calendar = await getCalendar(credentialsMap.get(bookingRef.type)); + + return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent, bookingRef.externalCalendarId); + } else if (bookingRef.type.endsWith("_video")) { + return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid); + } + } + }); + + // Send emails + await sendRequestRescheduleEmail(builder.calendarEvent, { + rescheduleLink: builder.rescheduleLink, + }); + + const evt: CalendarEvent = { + title: bookingToReschedule?.title, + type: event && event.title ? event.title : bookingToReschedule.title, + description: bookingToReschedule?.description || "", + customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs), + ...getCalEventResponses({ + booking: bookingToReschedule, + bookingFields: bookingToReschedule.eventType?.bookingFields ?? null, + }), + startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "", + endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "", + organizer: userAsPeopleType, + attendees: usersToPeopleType( + // username field doesn't exists on attendee but could be in the future + bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], + tAttendees + ), + uid: bookingToReschedule?.uid, + location: bookingToReschedule?.location, + destinationCalendar: + bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar, + cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this + }; + + // Send webhook + const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; + // Send Webhook call if hooked to BOOKING.CANCELLED + const subscriberOptions = { + userId: bookingToReschedule.userId, + eventTypeId: (bookingToReschedule.eventTypeId as number) || 0, + triggerEvent: eventTrigger, + }; + const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => + sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, { + ...evt, + smsReminderNumber: bookingToReschedule.smsReminderNumber || undefined, + }).catch((e) => { + console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e); + }) + ); + await Promise.all(promises); + } +}; diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.schema.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.schema.ts new file mode 100644 index 0000000000..32c513e321 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZRequestRescheduleInputSchema = z.object({ + bookingId: z.string(), + rescheduleReason: z.string().optional(), +}); + +export type TRequestRescheduleInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/types.ts b/packages/trpc/server/routers/viewer/bookings/types.ts new file mode 100644 index 0000000000..1cca8e0ef0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/types.ts @@ -0,0 +1,12 @@ +import type { User } from "@prisma/client"; +import { z } from "zod"; + +export type PersonAttendeeCommonFields = Pick< + User, + "id" | "email" | "name" | "locale" | "timeZone" | "username" +>; + +// Common data for all endpoints under webhook +export const commonBookingSchema = z.object({ + bookingId: z.number(), +}); diff --git a/packages/trpc/server/routers/viewer/bookings/util.ts b/packages/trpc/server/routers/viewer/bookings/util.ts new file mode 100644 index 0000000000..cd1e178c79 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/util.ts @@ -0,0 +1,80 @@ +import type { + Attendee, + Booking, + BookingReference, + Credential, + DestinationCalendar, + EventType, + User, +} from "@prisma/client"; +import { SchedulingType } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import { authedProcedure } from "../../../trpc"; +import { commonBookingSchema } from "./types"; + +export const bookingsProcedure = authedProcedure + .input(commonBookingSchema) + .use(async ({ ctx, input, next }) => { + // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input + const { bookingId } = input; + + const booking = await prisma.booking.findFirst({ + where: { + id: bookingId, + AND: [ + { + OR: [ + /* If user is organizer */ + { userId: ctx.user.id }, + /* Or part of a collective booking */ + { + eventType: { + schedulingType: SchedulingType.COLLECTIVE, + users: { + some: { + id: ctx.user.id, + }, + }, + }, + }, + ], + }, + ], + }, + include: { + attendees: true, + eventType: true, + destinationCalendar: true, + references: true, + user: { + include: { + destinationCalendar: true, + credentials: true, + }, + }, + }, + }); + + if (!booking) throw new TRPCError({ code: "UNAUTHORIZED" }); + + return next({ ctx: { booking } }); + }); + +export type BookingsProcedureContext = { + booking: Booking & { + eventType: EventType | null; + destinationCalendar: DestinationCalendar | null; + user: + | (User & { + destinationCalendar: DestinationCalendar | null; + credentials: Credential[]; + }) + | null; + references: BookingReference[]; + attendees: Attendee[]; + }; +}; diff --git a/packages/trpc/server/routers/viewer/deploymentSetup.tsx b/packages/trpc/server/routers/viewer/deploymentSetup.tsx deleted file mode 100644 index 8b1898cfb6..0000000000 --- a/packages/trpc/server/routers/viewer/deploymentSetup.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -import prisma from "@calcom/prisma"; - -import { router, authedAdminProcedure } from "../../trpc"; - -export const deploymentSetupRouter = router({ - update: authedAdminProcedure - .input( - z.object({ - licenseKey: z.string().optional(), - }) - ) - .mutation(async ({ input }) => { - const data = { - agreedLicenseAt: new Date(), - licenseKey: input.licenseKey, - }; - - await prisma.deployment.upsert({ where: { id: 1 }, create: data, update: data }); - - return; - }), -}); diff --git a/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx b/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx new file mode 100644 index 0000000000..c154c1b8f4 --- /dev/null +++ b/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx @@ -0,0 +1,26 @@ +import { router, authedAdminProcedure } from "../../../trpc"; +import { ZUpdateInputSchema } from "./update.schema"; + +type DeploymentSetupRouterHandlerCache = { + update?: typeof import("./update.handler").updateHandler; +}; + +const UNSTABLE_HANDLER_CACHE: DeploymentSetupRouterHandlerCache = {}; + +export const deploymentSetupRouter = router({ + update: authedAdminProcedure.input(ZUpdateInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.update) { + UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.update) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.update({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/deploymentSetup/update.handler.ts b/packages/trpc/server/routers/viewer/deploymentSetup/update.handler.ts new file mode 100644 index 0000000000..12b19e13ad --- /dev/null +++ b/packages/trpc/server/routers/viewer/deploymentSetup/update.handler.ts @@ -0,0 +1,19 @@ +import { prisma } from "@calcom/prisma"; + +import type { TUpdateInputSchema } from "./update.schema"; + +type UpdateOptions = { + ctx: Record; + input: TUpdateInputSchema; +}; + +export const updateHandler = async ({ input }: UpdateOptions) => { + const data = { + agreedLicenseAt: new Date(), + licenseKey: input.licenseKey, + }; + + await prisma.deployment.upsert({ where: { id: 1 }, create: data, update: data }); + + return; +}; diff --git a/packages/trpc/server/routers/viewer/deploymentSetup/update.schema.ts b/packages/trpc/server/routers/viewer/deploymentSetup/update.schema.ts new file mode 100644 index 0000000000..f2f499267d --- /dev/null +++ b/packages/trpc/server/routers/viewer/deploymentSetup/update.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZUpdateInputSchema = z.object({ + licenseKey: z.string().optional(), +}); + +export type TUpdateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes.ts b/packages/trpc/server/routers/viewer/eventTypes.ts deleted file mode 100644 index 6efdf2132e..0000000000 --- a/packages/trpc/server/routers/viewer/eventTypes.ts +++ /dev/null @@ -1,952 +0,0 @@ -import { MembershipRole, PeriodType, Prisma, SchedulingType } from "@prisma/client"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; -import { orderBy } from "lodash"; -import type { NextApiResponse } from "next"; -import { z } from "zod"; - -import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug"; -import type { LocationObject } from "@calcom/app-store/locations"; -import { DailyLocationType } from "@calcom/app-store/locations"; -import getApps, { getAppFromLocationValue, getAppFromSlug } from "@calcom/app-store/utils"; -import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes"; -import { validateIntervalLimitOrder } from "@calcom/lib"; -import { CAL_URL } from "@calcom/lib/constants"; -import getEventTypeById from "@calcom/lib/getEventTypeById"; -import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; -import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma"; -import { _DestinationCalendarModel, _EventTypeModel } from "@calcom/prisma/zod"; -import type { CustomInputSchema } from "@calcom/prisma/zod-utils"; -import { eventTypeLocations as eventTypeLocationsSchema } from "@calcom/prisma/zod-utils"; -import { - customInputSchema, - EventTypeMetaDataSchema, - userMetadata as userMetadataSchema, -} from "@calcom/prisma/zod-utils"; -import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype"; - -import { TRPCError } from "@trpc/server"; - -import { authedProcedure, router } from "../../trpc"; -import { viewerRouter } from "../viewer"; - -function isPeriodType(keyInput: string): keyInput is PeriodType { - return Object.keys(PeriodType).includes(keyInput); -} - -function handlePeriodType(periodType: string | undefined): PeriodType | undefined { - if (typeof periodType !== "string") return undefined; - const passedPeriodType = periodType.toUpperCase(); - if (!isPeriodType(passedPeriodType)) return undefined; - return PeriodType[passedPeriodType]; -} - -function handleCustomInputs(customInputs: CustomInputSchema[], eventTypeId: number) { - const cInputsIdsToDeleteOrUpdated = customInputs.filter((input) => !input.hasToBeCreated); - const cInputsIdsToDelete = cInputsIdsToDeleteOrUpdated.map((e) => e.id); - const cInputsToCreate = customInputs - .filter((input) => input.hasToBeCreated) - .map((input) => ({ - type: input.type, - label: input.label, - required: input.required, - placeholder: input.placeholder, - options: input.options || undefined, - })); - const cInputsToUpdate = cInputsIdsToDeleteOrUpdated.map((input) => ({ - data: { - type: input.type, - label: input.label, - required: input.required, - placeholder: input.placeholder, - options: input.options || undefined, - }, - where: { - id: input.id, - }, - })); - - return { - deleteMany: { - eventTypeId, - NOT: { - id: { in: cInputsIdsToDelete }, - }, - }, - createMany: { - data: cInputsToCreate, - }, - update: cInputsToUpdate, - }; -} - -const EventTypeUpdateInput = _EventTypeModel - /** Optional fields */ - .extend({ - customInputs: z.array(customInputSchema).optional(), - destinationCalendar: _DestinationCalendarModel.pick({ - integration: true, - externalId: true, - }), - children: z - .array( - z.object({ - owner: z.object({ - id: z.number(), - name: z.string(), - email: z.string(), - eventTypeSlugs: z.array(z.string()), - }), - hidden: z.boolean(), - }) - ) - .optional(), - hosts: z - .array( - z.object({ - userId: z.number(), - isFixed: z.boolean().optional(), - }) - ) - .optional(), - schedule: z.number().nullable().optional(), - hashedLink: z.string(), - }) - .partial() - .extend({ - metadata: EventTypeMetaDataSchema.optional(), - }) - .merge( - _EventTypeModel - /** Required fields */ - .pick({ - id: true, - }) - ); - -const EventTypeDuplicateInput = z.object({ - id: z.number(), - slug: z.string(), - title: z.string(), - description: z.string(), - length: z.number(), -}); - -const eventOwnerProcedure = authedProcedure - .input( - z.object({ - id: z.number(), - users: z.array(z.number()).optional().default([]), - }) - ) - .use(async ({ ctx, input, next }) => { - // Prevent non-owners to update/delete a team event - const event = await ctx.prisma.eventType.findUnique({ - where: { id: input.id }, - include: { - users: true, - team: { - select: { - members: { - select: { - userId: true, - role: true, - }, - }, - }, - }, - }, - }); - - if (!event) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const isAuthorized = (function () { - if (event.team) { - return event.team.members - .filter((member) => member.role === MembershipRole.OWNER || member.role === MembershipRole.ADMIN) - .map((member) => member.userId) - .includes(ctx.user.id); - } - return event.userId === ctx.user.id || event.users.find((user) => user.id === ctx.user.id); - })(); - - if (!isAuthorized) { - console.warn(`User ${ctx.user.id} attempted to an access an event ${event.id} they do not own.`); - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const isAllowed = (function () { - if (event.team) { - const allTeamMembers = event.team.members.map((member) => member.userId); - return input.users.every((userId: number) => allTeamMembers.includes(userId)); - } - return input.users.every((userId: number) => userId === ctx.user.id); - })(); - - if (!isAllowed) { - console.warn( - `User ${ctx.user.id} attempted to an create an event for users ${input.users.join(", ")}.` - ); - throw new TRPCError({ code: "FORBIDDEN" }); - } - - return next(); - }); - -export const eventTypesRouter = router({ - // REVIEW: What should we name this procedure? - getByViewer: authedProcedure.query(async ({ ctx }) => { - const { prisma } = ctx; - const eventTypeSelect = Prisma.validator()({ - // Position is required by lodash to sort on it. Don't remove it, TS won't complain but it would silently break reordering - position: true, - hashedLink: true, - locations: true, - destinationCalendar: true, - userId: true, - team: { - select: { - id: true, - name: true, - slug: true, - // logo: true, // Skipping to avoid 4mb limit - bio: true, - hideBranding: true, - }, - }, - metadata: true, - users: { - select: baseUserSelect, - }, - children: { - include: { - users: true, - }, - }, - hosts: { - select: { - user: { - select: baseUserSelect, - }, - }, - }, - seatsPerTimeSlot: 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, - avatar: true, - teams: { - where: { - accepted: true, - }, - select: { - role: true, - team: { - select: { - id: true, - name: true, - slug: 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" }); - } - - const mapEventType = (eventType: (typeof user.eventTypes)[number]) => ({ - ...eventType, - safeDescription: markdownToSafeHTML(eventType.description), - users: !!eventType.hosts?.length ? eventType.hosts.map((host) => host.user) : eventType.users, - metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : undefined, - }); - - const userEventTypes = user.eventTypes.map(mapEventType); - // backwards compatibility, TMP: - const typesRaw = ( - await prisma.eventType.findMany({ - where: { - userId: ctx.user.id, - }, - select: eventTypeSelect, - orderBy: [ - { - position: "desc", - }, - { - id: "asc", - }, - ], - }) - ).map(mapEventType); - - type EventTypeGroup = { - teamId?: number | null; - membershipRole?: MembershipRole | null; - profile: { - slug: (typeof user)["username"]; - name: (typeof user)["name"]; - image?: string; - }; - metadata: { - membershipCount: number; - readOnly: boolean; - }; - eventTypes: typeof userEventTypes; - }; - - let eventTypeGroups: EventTypeGroup[] = []; - const eventTypesHashMap = userEventTypes.concat(typesRaw).reduce((hashMap, newItem) => { - const oldItem = hashMap[newItem.id]; - hashMap[newItem.id] = { ...oldItem, ...newItem }; - return hashMap; - }, {} as Record); - const mergedEventTypes = Object.values(eventTypesHashMap) - .map((eventType) => eventType) - .filter((evType) => evType.schedulingType !== SchedulingType.MANAGED); - eventTypeGroups.push({ - teamId: null, - membershipRole: null, - profile: { - slug: user.username, - name: user.name, - image: user.avatar || undefined, - }, - 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, - membershipRole: membership.role, - profile: { - name: membership.team.name, - image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`, - slug: membership.team.slug ? "team/" + membership.team.slug : null, - }, - metadata: { - membershipCount: membership.team.members.length, - readOnly: membership.role === MembershipRole.MEMBER, - }, - eventTypes: membership.team.eventTypes - .map(mapEventType) - .filter((evType) => evType.userId === null || evType.userId === ctx.user.id) - .filter((evType) => - membership.role === MembershipRole.MEMBER - ? evType.schedulingType !== SchedulingType.MANAGED - : true - ), - })) - ); - return { - // 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, - membershipRole: group.membershipRole, - ...group.profile, - ...group.metadata, - })), - }; - }), - list: authedProcedure.query(async ({ ctx }) => { - return await ctx.prisma.eventType.findMany({ - where: { - userId: ctx.user.id, - team: null, - }, - select: { - id: true, - title: true, - description: true, - length: true, - schedulingType: true, - slug: true, - hidden: true, - metadata: true, - }, - }); - }), - listWithTeam: authedProcedure.query(async ({ ctx }) => { - return await ctx.prisma.eventType.findMany({ - where: { - OR: [ - { userId: ctx.user.id }, - { - team: { - members: { - some: { - userId: ctx.user.id, - }, - }, - }, - }, - ], - }, - select: { - id: true, - team: { - select: { - id: true, - name: true, - }, - }, - title: true, - slug: true, - }, - }); - }), - create: authedProcedure.input(createEventTypeInput).mutation(async ({ ctx, input }) => { - const { schedulingType, teamId, metadata, ...rest } = input; - const userId = ctx.user.id; - const isManagedEventType = schedulingType === SchedulingType.MANAGED; - // Get Users default conferncing app - - const defaultConferencingData = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp; - const appKeys = await getAppKeysFromSlug("daily-video"); - - let locations: { type: string; link?: string }[] = []; - - // If no locations are passed in and the user has a daily api key then default to daily - if ( - (typeof rest?.locations === "undefined" || rest.locations?.length === 0) && - typeof appKeys.api_key === "string" - ) { - locations = [{ type: DailyLocationType }]; - } - - // If its defaulting to daily no point handling compute as its done - if (defaultConferencingData && defaultConferencingData.appSlug !== "daily-video") { - const credentials = ctx.user.credentials; - const foundApp = getApps(credentials).filter((app) => app.slug === defaultConferencingData.appSlug)[0]; // There is only one possible install here so index [0] is the one we are looking for ; - const locationType = foundApp?.locationOption?.value ?? DailyLocationType; // Default to Daily if no location type is found - locations = [{ type: locationType, link: defaultConferencingData.appLink }]; - } - - const data: Prisma.EventTypeCreateInput = { - ...rest, - owner: teamId ? undefined : { connect: { id: userId } }, - metadata: (metadata as Prisma.InputJsonObject) ?? undefined, - // Only connecting the current user for non-managed event type - users: isManagedEventType ? undefined : { connect: { id: userId } }, - locations, - }; - - if (teamId && schedulingType) { - const hasMembership = await ctx.prisma.membership.findFirst({ - where: { - userId, - teamId: teamId, - accepted: true, - }, - }); - - if (!hasMembership?.role || !["ADMIN", "OWNER"].includes(hasMembership.role)) { - console.warn(`User ${userId} does not have permission to create this new event type`); - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - data.team = { - connect: { - id: teamId, - }, - }; - data.schedulingType = schedulingType; - } - - try { - const eventType = await ctx.prisma.eventType.create({ data }); - return { eventType }; - } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { - if (e.code === "P2002" && Array.isArray(e.meta?.target) && e.meta?.target.includes("slug")) { - throw new TRPCError({ code: "BAD_REQUEST", message: "URL Slug already exists for given user." }); - } - } - throw new TRPCError({ code: "BAD_REQUEST" }); - } - }), - get: eventOwnerProcedure - .input( - z.object({ - id: z.number(), - }) - ) - .query(async ({ ctx, input }) => { - return await getEventTypeById({ - eventTypeId: input.id, - userId: ctx.user.id, - prisma: ctx.prisma, - isTrpcCall: true, - }); - }), - update: eventOwnerProcedure.input(EventTypeUpdateInput.strict()).mutation(async ({ ctx, input }) => { - const { - schedule, - periodType, - locations, - bookingLimits, - durationLimits, - destinationCalendar, - customInputs, - recurringEvent, - users, - children, - hosts, - id, - hashedLink, - // Extract this from the input so it doesn't get saved in the db - // eslint-disable-next-line - userId, - // eslint-disable-next-line - teamId, - bookingFields, - ...rest - } = input; - - ensureUniqueBookingFields(bookingFields); - - const data: Prisma.EventTypeUpdateInput = { - ...rest, - bookingFields, - metadata: rest.metadata === null ? Prisma.DbNull : (rest.metadata as Prisma.InputJsonObject), - }; - data.locations = locations ?? undefined; - if (periodType) { - data.periodType = handlePeriodType(periodType); - } - - if (recurringEvent) { - data.recurringEvent = { - dstart: recurringEvent.dtstart as unknown as Prisma.InputJsonObject, - interval: recurringEvent.interval, - count: recurringEvent.count, - freq: recurringEvent.freq, - until: recurringEvent.until as unknown as Prisma.InputJsonObject, - tzid: recurringEvent.tzid, - }; - } else if (recurringEvent === null) { - data.recurringEvent = Prisma.DbNull; - } - - if (destinationCalendar) { - /** We connect or create a destination calendar to the event type instead of the user */ - await viewerRouter.createCaller(ctx).setDestinationCalendar({ - ...destinationCalendar, - eventTypeId: id, - }); - } - - if (customInputs) { - data.customInputs = handleCustomInputs(customInputs, id); - } - - if (bookingLimits) { - const isValid = validateIntervalLimitOrder(bookingLimits); - if (!isValid) - throw new TRPCError({ code: "BAD_REQUEST", message: "Booking limits must be in ascending order." }); - data.bookingLimits = bookingLimits; - } - - if (durationLimits) { - const isValid = validateIntervalLimitOrder(durationLimits); - if (!isValid) - throw new TRPCError({ code: "BAD_REQUEST", message: "Duration limits must be in ascending order." }); - data.durationLimits = durationLimits; - } - - if (schedule) { - // Check that the schedule belongs to the user - const userScheduleQuery = await ctx.prisma.schedule.findFirst({ - where: { - userId: ctx.user.id, - id: schedule, - }, - }); - if (userScheduleQuery) { - data.schedule = { - connect: { - id: schedule, - }, - }; - } - } - // allows unsetting a schedule through { schedule: null, ... } - else if (null === schedule) { - data.schedule = { - disconnect: true, - }; - } - - if (users.length) { - data.users = { - set: [], - connect: users.map((userId: number) => ({ id: userId })), - }; - } - - if (hosts) { - data.hosts = { - deleteMany: {}, - create: hosts.map((host) => ({ - ...host, - isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, - })), - }; - } - - const connectedLink = await ctx.prisma.hashedLink.findFirst({ - where: { - eventTypeId: input.id, - }, - select: { - id: true, - }, - }); - - if (hashedLink) { - // check if hashed connection existed. If it did, do nothing. If it didn't, add a new connection - if (!connectedLink) { - // create a hashed link - await ctx.prisma.hashedLink.upsert({ - where: { - eventTypeId: input.id, - }, - update: { - link: hashedLink, - }, - create: { - link: hashedLink, - eventType: { - connect: { id: input.id }, - }, - }, - }); - } - } else { - // check if hashed connection exists. If it does, disconnect - if (connectedLink) { - await ctx.prisma.hashedLink.delete({ - where: { - eventTypeId: input.id, - }, - }); - } - } - const [oldEventType, eventType] = await ctx.prisma.$transaction([ - ctx.prisma.eventType.findFirst({ - where: { id }, - select: { - children: { - select: { - userId: true, - }, - }, - team: { - select: { - name: true, - }, - }, - }, - }), - ctx.prisma.eventType.update({ - where: { id }, - data, - }), - ]); - - // Handling updates to children event types (managed events types) - await updateChildrenEventTypes({ - eventTypeId: id, - currentUserId: ctx.user.id, - oldEventType, - hashedLink, - connectedLink, - updatedEventType: eventType, - children, - prisma: ctx.prisma, - }); - const res = ctx.res as NextApiResponse; - if (typeof res?.revalidate !== "undefined") { - await res?.revalidate(`/${ctx.user.username}/${eventType.slug}`); - } - return { eventType }; - }), - delete: eventOwnerProcedure - .input( - z.object({ - id: z.number(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - - await ctx.prisma.eventTypeCustomInput.deleteMany({ - where: { - eventTypeId: id, - }, - }); - - await ctx.prisma.eventType.delete({ - where: { - id, - }, - }); - - return { - id, - }; - }), - duplicate: eventOwnerProcedure.input(EventTypeDuplicateInput.strict()).mutation(async ({ ctx, input }) => { - try { - const { - id: originalEventTypeId, - title: newEventTitle, - slug: newSlug, - description: newDescription, - length: newLength, - } = input; - const eventType = await ctx.prisma.eventType.findUnique({ - where: { - id: originalEventTypeId, - }, - include: { - customInputs: true, - schedule: true, - users: true, - team: true, - workflows: true, - webhooks: true, - }, - }); - - if (!eventType) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - // Validate user is owner of event type or in the team - if (eventType.userId !== ctx.user.id) { - if (eventType.teamId) { - const isMember = await ctx.prisma.membership.findFirst({ - where: { - userId: ctx.user.id, - teamId: eventType.teamId, - }, - }); - if (!isMember) { - throw new TRPCError({ code: "FORBIDDEN" }); - } - } - } - - const { - customInputs, - users, - locations, - team, - recurringEvent, - bookingLimits, - durationLimits, - metadata, - workflows, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - id: _id, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - webhooks: _webhooks, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - schedule: _schedule, - // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/ban-ts-comment - // @ts-ignore - descriptionAsSafeHTML is added on the fly using a prisma middleware it shouldn't be used to create event type. Such a property doesn't exist on schema - descriptionAsSafeHTML: _descriptionAsSafeHTML, - ...rest - } = eventType; - - const data: Prisma.EventTypeUncheckedCreateInput = { - ...rest, - title: newEventTitle, - slug: newSlug, - description: newDescription, - length: newLength, - locations: locations ?? undefined, - teamId: team ? team.id : undefined, - users: users ? { connect: users.map((user) => ({ id: user.id })) } : undefined, - recurringEvent: recurringEvent || undefined, - bookingLimits: bookingLimits ?? undefined, - durationLimits: durationLimits ?? undefined, - metadata: metadata === null ? Prisma.DbNull : metadata, - bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields, - }; - - const newEventType = await ctx.prisma.eventType.create({ data }); - - // Create custom inputs - if (customInputs) { - const customInputsData = customInputs.map((customInput) => { - const { id: _, options, ...rest } = customInput; - return { - options: options ?? undefined, - ...rest, - eventTypeId: newEventType.id, - }; - }); - await ctx.prisma.eventTypeCustomInput.createMany({ - data: customInputsData, - }); - } - - if (workflows.length > 0) { - const relationCreateData = workflows.map((workflow) => { - return { eventTypeId: newEventType.id, workflowId: workflow.workflowId }; - }); - - await ctx.prisma.workflowsOnEventTypes.createMany({ - data: relationCreateData, - }); - } - - return { - eventType: newEventType, - }; - } catch (error) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); - } - }), - bulkEventFetch: authedProcedure.query(async ({ ctx }) => { - const eventTypes = await ctx.prisma.eventType.findMany({ - where: { - userId: ctx.user.id, - team: null, - }, - select: { - id: true, - title: true, - locations: true, - }, - }); - - const eventTypesWithLogo = eventTypes.map((eventType) => { - const locationParsed = eventTypeLocationsSchema.safeParse(eventType.locations); - - // some events has null as location for legacy reasons, so this fallbacks to daily video - const app = getAppFromLocationValue( - locationParsed.success && locationParsed.data?.[0]?.type - ? locationParsed.data[0].type - : "integrations:daily" - ); - return { - ...eventType, - logo: app?.logo, - }; - }); - - return { - eventTypes: eventTypesWithLogo, - }; - }), - - bulkUpdateToDefaultLocation: authedProcedure - .input( - z.object({ - eventTypeIds: z.array(z.number()), - }) - ) - .mutation(async ({ ctx, input }) => { - const { eventTypeIds } = input; - const defaultApp = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp; - - if (!defaultApp) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Default conferencing app not set", - }); - } - - const foundApp = getAppFromSlug(defaultApp.appSlug); - const appType = foundApp?.appData?.location?.type; - if (!appType) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Default conferencing app '${defaultApp.appSlug}' doesnt exist.`, - }); - } - - return await ctx.prisma.eventType.updateMany({ - where: { - id: { - in: eventTypeIds, - }, - userId: ctx.user.id, - }, - data: { - locations: [{ type: appType, link: defaultApp.appLink }] as LocationObject[], - }, - }); - }), -}); - -function ensureUniqueBookingFields(fields: z.infer["bookingFields"]) { - if (!fields) { - return; - } - fields.reduce((discoveredFields, field) => { - if (discoveredFields[field.name]) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Duplicate booking field name: ${field.name}`, - }); - } - discoveredFields[field.name] = true; - return discoveredFields; - }, {} as Record); -} diff --git a/packages/trpc/server/routers/viewer/eventTypes/_router.ts b/packages/trpc/server/routers/viewer/eventTypes/_router.ts new file mode 100644 index 0000000000..37fcebc627 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/_router.ts @@ -0,0 +1,207 @@ +import { z } from "zod"; + +import { logP } from "@calcom/lib/perf"; + +import { authedProcedure, router } from "../../../trpc"; +import { ZCreateInputSchema } from "./create.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZDuplicateInputSchema } from "./duplicate.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZUpdateInputSchema } from "./update.schema"; +import { eventOwnerProcedure } from "./util"; + +type BookingsRouterHandlerCache = { + getByViewer?: typeof import("./getByViewer.handler").getByViewerHandler; + list?: typeof import("./list.handler").listHandler; + listWithTeam?: typeof import("./listWithTeam.handler").listWithTeamHandler; + create?: typeof import("./create.handler").createHandler; + get?: typeof import("./get.handler").getHandler; + update?: typeof import("./update.handler").updateHandler; + delete?: typeof import("./delete.handler").deleteHandler; + duplicate?: typeof import("./duplicate.handler").duplicateHandler; + bulkEventFetch?: typeof import("./bulkEventFetch.handler").bulkEventFetchHandler; + bulkUpdateToDefaultLocation?: typeof import("./bulkUpdateToDefaultLocation.handler").bulkUpdateToDefaultLocationHandler; +}; + +const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {}; + +export const eventTypesRouter = router({ + // REVIEW: What should we name this procedure? + getByViewer: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getByViewer) { + UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then( + (mod) => mod.getByViewerHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getByViewer) { + throw new Error("Failed to load handler"); + } + + const timer = logP(`getByViewer(${ctx.user.email})`); + + const result = await UNSTABLE_HANDLER_CACHE.getByViewer({ + ctx, + }); + + timer(); + + return result; + }), + + list: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.list) { + UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.list) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.list({ + ctx, + }); + }), + + listWithTeam: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.listWithTeam) { + UNSTABLE_HANDLER_CACHE.listWithTeam = await import("./listWithTeam.handler").then( + (mod) => mod.listWithTeamHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.listWithTeam) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.listWithTeam({ + ctx, + }); + }), + + create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.create) { + UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.create) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.create({ + ctx, + input, + }); + }), + + get: eventOwnerProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + update: eventOwnerProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.update) { + UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.update) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.update({ + ctx, + input, + }); + }), + + delete: eventOwnerProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + duplicate: eventOwnerProcedure.input(ZDuplicateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.duplicate) { + UNSTABLE_HANDLER_CACHE.duplicate = await import("./duplicate.handler").then( + (mod) => mod.duplicateHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.duplicate) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.duplicate({ + ctx, + input, + }); + }), + + bulkEventFetch: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.bulkEventFetch) { + UNSTABLE_HANDLER_CACHE.bulkEventFetch = await import("./bulkEventFetch.handler").then( + (mod) => mod.bulkEventFetchHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.bulkEventFetch) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.bulkEventFetch({ + ctx, + }); + }), + + bulkUpdateToDefaultLocation: authedProcedure + .input( + z.object({ + eventTypeIds: z.array(z.number()), + }) + ) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.bulkUpdateToDefaultLocation) { + UNSTABLE_HANDLER_CACHE.bulkUpdateToDefaultLocation = await import( + "./bulkUpdateToDefaultLocation.handler" + ).then((mod) => mod.bulkUpdateToDefaultLocationHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.bulkUpdateToDefaultLocation) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.bulkUpdateToDefaultLocation({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.handler.ts new file mode 100644 index 0000000000..107581f947 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.handler.ts @@ -0,0 +1,44 @@ +import { getAppFromLocationValue } from "@calcom/app-store/utils"; +import { prisma } from "@calcom/prisma"; +import { eventTypeLocations as eventTypeLocationsSchema } from "@calcom/prisma/zod-utils"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type BulkEventFetchOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const bulkEventFetchHandler = async ({ ctx }: BulkEventFetchOptions) => { + const eventTypes = await prisma.eventType.findMany({ + where: { + userId: ctx.user.id, + team: null, + }, + select: { + id: true, + title: true, + locations: true, + }, + }); + + const eventTypesWithLogo = eventTypes.map((eventType) => { + const locationParsed = eventTypeLocationsSchema.safeParse(eventType.locations); + + // some events has null as location for legacy reasons, so this fallbacks to daily video + const app = getAppFromLocationValue( + locationParsed.success && locationParsed.data?.[0]?.type + ? locationParsed.data[0].type + : "integrations:daily" + ); + return { + ...eventType, + logo: app?.logo, + }; + }); + + return { + eventTypes: eventTypesWithLogo, + }; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.handler.ts new file mode 100644 index 0000000000..a153720721 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.handler.ts @@ -0,0 +1,52 @@ +import type { LocationObject } from "@calcom/app-store/locations"; +import { getAppFromSlug } from "@calcom/app-store/utils"; +import { prisma } from "@calcom/prisma"; +import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TBulkUpdateToDefaultLocationInputSchema } from "./bulkUpdateToDefaultLocation.schema"; + +type BulkUpdateToDefaultLocationOptions = { + ctx: { + user: NonNullable; + }; + input: TBulkUpdateToDefaultLocationInputSchema; +}; + +export const bulkUpdateToDefaultLocationHandler = async ({ + ctx, + input, +}: BulkUpdateToDefaultLocationOptions) => { + const { eventTypeIds } = input; + const defaultApp = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp; + + if (!defaultApp) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Default conferencing app not set", + }); + } + + const foundApp = getAppFromSlug(defaultApp.appSlug); + const appType = foundApp?.appData?.location?.type; + if (!appType) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Default conferencing app '${defaultApp.appSlug}' doesnt exist.`, + }); + } + + return await prisma.eventType.updateMany({ + where: { + id: { + in: eventTypeIds, + }, + userId: ctx.user.id, + }, + data: { + locations: [{ type: appType, link: defaultApp.appLink }] as LocationObject[], + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.schema.ts new file mode 100644 index 0000000000..4cd2d27eba --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZBulkUpdateToDefaultLocationInputSchema = z.object({ + eventTypeIds: z.array(z.number()), +}); + +export type TBulkUpdateToDefaultLocationInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts new file mode 100644 index 0000000000..10fd91f632 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts @@ -0,0 +1,93 @@ +import type { Prisma } from "@prisma/client"; +import { SchedulingType } from "@prisma/client"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; + +import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug"; +import { DailyLocationType } from "@calcom/app-store/locations"; +import getApps from "@calcom/app-store/utils"; +import type { PrismaClient } from "@calcom/prisma/client"; +import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TCreateInputSchema } from "./create.schema"; + +type CreateOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TCreateInputSchema; +}; + +export const createHandler = async ({ ctx, input }: CreateOptions) => { + const { schedulingType, teamId, metadata, ...rest } = input; + const userId = ctx.user.id; + const isManagedEventType = schedulingType === SchedulingType.MANAGED; + // Get Users default conferncing app + + const defaultConferencingData = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp; + const appKeys = await getAppKeysFromSlug("daily-video"); + + let locations: { type: string; link?: string }[] = []; + + // If no locations are passed in and the user has a daily api key then default to daily + if ( + (typeof rest?.locations === "undefined" || rest.locations?.length === 0) && + typeof appKeys.api_key === "string" + ) { + locations = [{ type: DailyLocationType }]; + } + + // If its defaulting to daily no point handling compute as its done + if (defaultConferencingData && defaultConferencingData.appSlug !== "daily-video") { + const credentials = ctx.user.credentials; + const foundApp = getApps(credentials).filter((app) => app.slug === defaultConferencingData.appSlug)[0]; // There is only one possible install here so index [0] is the one we are looking for ; + const locationType = foundApp?.locationOption?.value ?? DailyLocationType; // Default to Daily if no location type is found + locations = [{ type: locationType, link: defaultConferencingData.appLink }]; + } + + const data: Prisma.EventTypeCreateInput = { + ...rest, + owner: teamId ? undefined : { connect: { id: userId } }, + metadata: (metadata as Prisma.InputJsonObject) ?? undefined, + // Only connecting the current user for non-managed event type + users: isManagedEventType ? undefined : { connect: { id: userId } }, + locations, + }; + + if (teamId && schedulingType) { + const hasMembership = await ctx.prisma.membership.findFirst({ + where: { + userId, + teamId: teamId, + accepted: true, + }, + }); + + if (!hasMembership?.role || !["ADMIN", "OWNER"].includes(hasMembership.role)) { + console.warn(`User ${userId} does not have permission to create this new event type`); + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + data.team = { + connect: { + id: teamId, + }, + }; + data.schedulingType = schedulingType; + } + + try { + const eventType = await ctx.prisma.eventType.create({ data }); + return { eventType }; + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + if (e.code === "P2002" && Array.isArray(e.meta?.target) && e.meta?.target.includes("slug")) { + throw new TRPCError({ code: "BAD_REQUEST", message: "URL Slug already exists for given user." }); + } + } + throw new TRPCError({ code: "BAD_REQUEST" }); + } +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/create.schema.ts new file mode 100644 index 0000000000..fc39339be8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/create.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype"; + +export const ZCreateInputSchema = createEventTypeInput; + +export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts new file mode 100644 index 0000000000..ea275e5443 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts @@ -0,0 +1,31 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx: _ctx, input }: DeleteOptions) => { + const { id } = input; + + await prisma.eventTypeCustomInput.deleteMany({ + where: { + eventTypeId: id, + }, + }); + + await prisma.eventType.delete({ + where: { + id, + }, + }); + + return { + id, + }; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/delete.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/delete.schema.ts new file mode 100644 index 0000000000..411d5e953b --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + id: z.number(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts new file mode 100644 index 0000000000..67ddcc0fc6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts @@ -0,0 +1,130 @@ +import { Prisma } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TDuplicateInputSchema } from "./duplicate.schema"; + +type DuplicateOptions = { + ctx: { + user: NonNullable; + }; + input: TDuplicateInputSchema; +}; + +export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => { + try { + const { + id: originalEventTypeId, + title: newEventTitle, + slug: newSlug, + description: newDescription, + length: newLength, + } = input; + const eventType = await prisma.eventType.findUnique({ + where: { + id: originalEventTypeId, + }, + include: { + customInputs: true, + schedule: true, + users: true, + team: true, + workflows: true, + webhooks: true, + }, + }); + + if (!eventType) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + // Validate user is owner of event type or in the team + if (eventType.userId !== ctx.user.id) { + if (eventType.teamId) { + const isMember = await prisma.membership.findFirst({ + where: { + userId: ctx.user.id, + teamId: eventType.teamId, + }, + }); + if (!isMember) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + } + } + + const { + customInputs, + users, + locations, + team, + recurringEvent, + bookingLimits, + durationLimits, + metadata, + workflows, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + id: _id, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + webhooks: _webhooks, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + schedule: _schedule, + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/ban-ts-comment + // @ts-ignore - descriptionAsSafeHTML is added on the fly using a prisma middleware it shouldn't be used to create event type. Such a property doesn't exist on schema + descriptionAsSafeHTML: _descriptionAsSafeHTML, + ...rest + } = eventType; + + const data: Prisma.EventTypeUncheckedCreateInput = { + ...rest, + title: newEventTitle, + slug: newSlug, + description: newDescription, + length: newLength, + locations: locations ?? undefined, + teamId: team ? team.id : undefined, + users: users ? { connect: users.map((user) => ({ id: user.id })) } : undefined, + recurringEvent: recurringEvent || undefined, + bookingLimits: bookingLimits ?? undefined, + durationLimits: durationLimits ?? undefined, + metadata: metadata === null ? Prisma.DbNull : metadata, + bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields, + }; + + const newEventType = await prisma.eventType.create({ data }); + + // Create custom inputs + if (customInputs) { + const customInputsData = customInputs.map((customInput) => { + const { id: _, options, ...rest } = customInput; + return { + options: options ?? undefined, + ...rest, + eventTypeId: newEventType.id, + }; + }); + await prisma.eventTypeCustomInput.createMany({ + data: customInputsData, + }); + } + + if (workflows.length > 0) { + const relationCreateData = workflows.map((workflow) => { + return { eventTypeId: newEventType.id, workflowId: workflow.workflowId }; + }); + + await prisma.workflowsOnEventTypes.createMany({ + data: relationCreateData, + }); + } + + return { + eventType: newEventType, + }; + } catch (error) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/duplicate.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/duplicate.schema.ts new file mode 100644 index 0000000000..6b6944f1e6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/duplicate.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { EventTypeDuplicateInput } from "./types"; + +export const ZDuplicateInputSchema = EventTypeDuplicateInput.strict(); + +export type TDuplicateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts new file mode 100644 index 0000000000..73e22c9ec8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts @@ -0,0 +1,22 @@ +import getEventTypeById from "@calcom/lib/getEventTypeById"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TGetInputSchema } from "./get.schema"; +import type { PrismaClient } from ".prisma/client"; + +type GetOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TGetInputSchema; +}; + +export const getHandler = ({ ctx, input }: GetOptions) => { + return getEventTypeById({ + eventTypeId: input.id, + userId: ctx.user.id, + prisma: ctx.prisma, + isTrpcCall: true, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/get.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/get.schema.ts new file mode 100644 index 0000000000..d549577697 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/get.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetInputSchema = z.object({ + id: z.number(), +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts new file mode 100644 index 0000000000..cc3133b397 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts @@ -0,0 +1,222 @@ +import { MembershipRole, Prisma, SchedulingType } from "@prisma/client"; +import type { PrismaClient } from "@prisma/client"; +import { orderBy } from "lodash"; + +import { CAL_URL } from "@calcom/lib/constants"; +import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; +import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type GetByViewerOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; +}; + +export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => { + const { prisma } = ctx; + const eventTypeSelect = Prisma.validator()({ + // Position is required by lodash to sort on it. Don't remove it, TS won't complain but it would silently break reordering + position: true, + hashedLink: true, + locations: true, + destinationCalendar: true, + userId: true, + team: { + select: { + id: true, + name: true, + slug: true, + // logo: true, // Skipping to avoid 4mb limit + bio: true, + hideBranding: true, + }, + }, + metadata: true, + users: { + select: baseUserSelect, + }, + children: { + include: { + users: true, + }, + }, + hosts: { + select: { + user: { + select: baseUserSelect, + }, + }, + }, + seatsPerTimeSlot: 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, + avatar: true, + teams: { + where: { + accepted: true, + }, + select: { + role: true, + team: { + select: { + id: true, + name: true, + slug: 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" }); + } + + const mapEventType = (eventType: (typeof user.eventTypes)[number]) => ({ + ...eventType, + safeDescription: markdownToSafeHTML(eventType.description), + users: !!eventType.hosts?.length ? eventType.hosts.map((host) => host.user) : eventType.users, + metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : undefined, + }); + + const userEventTypes = user.eventTypes.map(mapEventType); + // backwards compatibility, TMP: + const typesRaw = ( + await prisma.eventType.findMany({ + where: { + userId: ctx.user.id, + }, + select: eventTypeSelect, + orderBy: [ + { + position: "desc", + }, + { + id: "asc", + }, + ], + }) + ).map(mapEventType); + + type EventTypeGroup = { + teamId?: number | null; + membershipRole?: MembershipRole | null; + profile: { + slug: (typeof user)["username"]; + name: (typeof user)["name"]; + image?: string; + }; + metadata: { + membershipCount: number; + readOnly: boolean; + }; + eventTypes: typeof userEventTypes; + }; + + let eventTypeGroups: EventTypeGroup[] = []; + const eventTypesHashMap = userEventTypes.concat(typesRaw).reduce((hashMap, newItem) => { + const oldItem = hashMap[newItem.id]; + hashMap[newItem.id] = { ...oldItem, ...newItem }; + return hashMap; + }, {} as Record); + const mergedEventTypes = Object.values(eventTypesHashMap) + .map((eventType) => eventType) + .filter((evType) => evType.schedulingType !== SchedulingType.MANAGED); + eventTypeGroups.push({ + teamId: null, + membershipRole: null, + profile: { + slug: user.username, + name: user.name, + image: user.avatar || undefined, + }, + 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, + membershipRole: membership.role, + profile: { + name: membership.team.name, + image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`, + slug: membership.team.slug ? "team/" + membership.team.slug : null, + }, + metadata: { + membershipCount: membership.team.members.length, + readOnly: membership.role === MembershipRole.MEMBER, + }, + eventTypes: membership.team.eventTypes + .map(mapEventType) + .filter((evType) => evType.userId === null || evType.userId === ctx.user.id) + .filter((evType) => + membership.role === MembershipRole.MEMBER ? evType.schedulingType !== SchedulingType.MANAGED : true + ), + })) + ); + return { + // 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, + membershipRole: group.membershipRole, + ...group.profile, + ...group.metadata, + })), + }; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/list.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/list.handler.ts new file mode 100644 index 0000000000..f17398fdbe --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/list.handler.ts @@ -0,0 +1,28 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listHandler = async ({ ctx }: ListOptions) => { + return await prisma.eventType.findMany({ + where: { + userId: ctx.user.id, + team: null, + }, + select: { + id: true, + title: true, + description: true, + length: true, + schedulingType: true, + slug: true, + hidden: true, + metadata: true, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/list.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/list.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/list.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts new file mode 100644 index 0000000000..0897773bba --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts @@ -0,0 +1,39 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type ListWithTeamOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listWithTeamHandler = async ({ ctx }: ListWithTeamOptions) => { + return await prisma.eventType.findMany({ + where: { + OR: [ + { userId: ctx.user.id }, + { + team: { + members: { + some: { + userId: ctx.user.id, + }, + }, + }, + }, + ], + }, + select: { + id: true, + team: { + select: { + id: true, + name: true, + }, + }, + title: true, + slug: true, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts new file mode 100644 index 0000000000..65b34f009f --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +import { _DestinationCalendarModel, _EventTypeModel } from "@calcom/prisma/zod"; +import { customInputSchema, EventTypeMetaDataSchema, stringOrNumber } from "@calcom/prisma/zod-utils"; + +export const EventTypeUpdateInput = _EventTypeModel + /** Optional fields */ + .extend({ + customInputs: z.array(customInputSchema).optional(), + destinationCalendar: _DestinationCalendarModel.pick({ + integration: true, + externalId: true, + }), + users: z.array(stringOrNumber).optional(), + children: z + .array( + z.object({ + owner: z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + eventTypeSlugs: z.array(z.string()), + }), + hidden: z.boolean(), + }) + ) + .optional(), + hosts: z + .array( + z.object({ + userId: z.number(), + isFixed: z.boolean().optional(), + }) + ) + .optional(), + schedule: z.number().nullable().optional(), + hashedLink: z.string(), + }) + .partial() + .extend({ + metadata: EventTypeMetaDataSchema.optional(), + }) + .merge( + _EventTypeModel + /** Required fields */ + .pick({ + id: true, + }) + ); + +export const EventTypeDuplicateInput = z.object({ + id: z.number(), + slug: z.string(), + title: z.string(), + description: z.string(), + length: z.number(), +}); diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts new file mode 100644 index 0000000000..a6c3067300 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -0,0 +1,240 @@ +import type { PrismaClient } from "@prisma/client"; +import { Prisma, SchedulingType } from "@prisma/client"; +import type { NextApiResponse, GetServerSidePropsContext } from "next"; + +import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server"; +import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes"; +import { validateIntervalLimitOrder } from "@calcom/lib"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import { setDestinationCalendarHandler } from "../../loggedInViewer/setDestinationCalendar.handler"; +import type { TUpdateInputSchema } from "./update.schema"; +import { ensureUniqueBookingFields, handleCustomInputs, handlePeriodType } from "./util"; + +type UpdateOptions = { + ctx: { + user: NonNullable; + res?: NextApiResponse | GetServerSidePropsContext["res"]; + prisma: PrismaClient; + }; + input: TUpdateInputSchema; +}; + +export const updateHandler = async ({ ctx, input }: UpdateOptions) => { + const { + schedule, + periodType, + locations, + bookingLimits, + durationLimits, + destinationCalendar, + customInputs, + recurringEvent, + users, + children, + hosts, + id, + hashedLink, + // Extract this from the input so it doesn't get saved in the db + // eslint-disable-next-line + userId, + // eslint-disable-next-line + teamId, + bookingFields, + ...rest + } = input; + + ensureUniqueBookingFields(bookingFields); + + const data: Prisma.EventTypeUpdateInput = { + ...rest, + bookingFields, + metadata: rest.metadata === null ? Prisma.DbNull : (rest.metadata as Prisma.InputJsonObject), + }; + data.locations = locations ?? undefined; + if (periodType) { + data.periodType = handlePeriodType(periodType); + } + + if (recurringEvent) { + data.recurringEvent = { + dstart: recurringEvent.dtstart as unknown as Prisma.InputJsonObject, + interval: recurringEvent.interval, + count: recurringEvent.count, + freq: recurringEvent.freq, + until: recurringEvent.until as unknown as Prisma.InputJsonObject, + tzid: recurringEvent.tzid, + }; + } else if (recurringEvent === null) { + data.recurringEvent = Prisma.DbNull; + } + + if (destinationCalendar) { + /** We connect or create a destination calendar to the event type instead of the user */ + await setDestinationCalendarHandler({ + ctx, + input: { + ...destinationCalendar, + eventTypeId: id, + }, + }); + } + + if (customInputs) { + data.customInputs = handleCustomInputs(customInputs, id); + } + + if (bookingLimits) { + const isValid = validateIntervalLimitOrder(bookingLimits); + if (!isValid) + throw new TRPCError({ code: "BAD_REQUEST", message: "Booking limits must be in ascending order." }); + data.bookingLimits = bookingLimits; + } + + if (durationLimits) { + const isValid = validateIntervalLimitOrder(durationLimits); + if (!isValid) + throw new TRPCError({ code: "BAD_REQUEST", message: "Duration limits must be in ascending order." }); + data.durationLimits = durationLimits; + } + + if (schedule) { + // Check that the schedule belongs to the user + const userScheduleQuery = await ctx.prisma.schedule.findFirst({ + where: { + userId: ctx.user.id, + id: schedule, + }, + }); + if (userScheduleQuery) { + data.schedule = { + connect: { + id: schedule, + }, + }; + } + } + // allows unsetting a schedule through { schedule: null, ... } + else if (null === schedule) { + data.schedule = { + disconnect: true, + }; + } + + if (users?.length) { + data.users = { + set: [], + connect: users.map((userId: number) => ({ id: userId })), + }; + } + + if (hosts) { + data.hosts = { + deleteMany: {}, + create: hosts.map((host) => ({ + ...host, + isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, + })), + }; + } + + if (input?.price || input.metadata?.apps?.stripe?.price) { + data.price = input.price || input.metadata?.apps?.stripe?.price; + const paymentCredential = await ctx.prisma.credential.findFirst({ + where: { + userId: ctx.user.id, + type: { + contains: "_payment", + }, + }, + select: { + type: true, + key: true, + }, + }); + + if (paymentCredential?.type === "stripe_payment") { + const { default_currency } = stripeDataSchema.parse(paymentCredential.key); + data.currency = default_currency; + } + } + + const connectedLink = await ctx.prisma.hashedLink.findFirst({ + where: { + eventTypeId: input.id, + }, + select: { + id: true, + }, + }); + + if (hashedLink) { + // check if hashed connection existed. If it did, do nothing. If it didn't, add a new connection + if (!connectedLink) { + // create a hashed link + await ctx.prisma.hashedLink.upsert({ + where: { + eventTypeId: input.id, + }, + update: { + link: hashedLink, + }, + create: { + link: hashedLink, + eventType: { + connect: { id: input.id }, + }, + }, + }); + } + } else { + // check if hashed connection exists. If it does, disconnect + if (connectedLink) { + await ctx.prisma.hashedLink.delete({ + where: { + eventTypeId: input.id, + }, + }); + } + } + const [oldEventType, eventType] = await ctx.prisma.$transaction([ + ctx.prisma.eventType.findFirst({ + where: { id }, + select: { + children: { + select: { + userId: true, + }, + }, + team: { + select: { + name: true, + }, + }, + }, + }), + ctx.prisma.eventType.update({ + where: { id }, + data, + }), + ]); + + // Handling updates to children event types (managed events types) + await updateChildrenEventTypes({ + eventTypeId: id, + currentUserId: ctx.user.id, + oldEventType, + hashedLink, + connectedLink, + updatedEventType: eventType, + children, + prisma: ctx.prisma, + }); + const res = ctx.res as NextApiResponse; + if (typeof res?.revalidate !== "undefined") { + await res?.revalidate(`/${ctx.user.username}/${eventType.slug}`); + } + return { eventType }; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/update.schema.ts new file mode 100644 index 0000000000..4e69d8d4be --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/update.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { EventTypeUpdateInput } from "./types"; + +export const ZUpdateInputSchema = EventTypeUpdateInput.strict(); + +export type TUpdateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/util.ts b/packages/trpc/server/routers/viewer/eventTypes/util.ts new file mode 100644 index 0000000000..8a4407b980 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/util.ts @@ -0,0 +1,141 @@ +import { MembershipRole, PeriodType } from "@prisma/client"; +import { z } from "zod"; + +import type { CustomInputSchema } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +import { authedProcedure } from "../../../trpc"; +import type { EventTypeUpdateInput } from "./types"; + +export const eventOwnerProcedure = authedProcedure + .input( + z.object({ + id: z.number(), + users: z.array(z.number()).optional().default([]), + }) + ) + .use(async ({ ctx, input, next }) => { + // Prevent non-owners to update/delete a team event + const event = await ctx.prisma.eventType.findUnique({ + where: { id: input.id }, + include: { + users: true, + team: { + select: { + members: { + select: { + userId: true, + role: true, + }, + }, + }, + }, + }, + }); + + if (!event) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + const isAuthorized = (function () { + if (event.team) { + return event.team.members + .filter((member) => member.role === MembershipRole.OWNER || member.role === MembershipRole.ADMIN) + .map((member) => member.userId) + .includes(ctx.user.id); + } + return event.userId === ctx.user.id || event.users.find((user) => user.id === ctx.user.id); + })(); + + if (!isAuthorized) { + console.warn(`User ${ctx.user.id} attempted to an access an event ${event.id} they do not own.`); + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const isAllowed = (function () { + if (event.team) { + const allTeamMembers = event.team.members.map((member) => member.userId); + return input.users.every((userId: number) => allTeamMembers.includes(userId)); + } + return input.users.every((userId: number) => userId === ctx.user.id); + })(); + + if (!isAllowed) { + console.warn( + `User ${ctx.user.id} attempted to an create an event for users ${input.users.join(", ")}.` + ); + throw new TRPCError({ code: "FORBIDDEN" }); + } + + return next(); + }); + +export function isPeriodType(keyInput: string): keyInput is PeriodType { + return Object.keys(PeriodType).includes(keyInput); +} + +export function handlePeriodType(periodType: string | undefined): PeriodType | undefined { + if (typeof periodType !== "string") return undefined; + const passedPeriodType = periodType.toUpperCase(); + if (!isPeriodType(passedPeriodType)) return undefined; + return PeriodType[passedPeriodType]; +} + +export function handleCustomInputs(customInputs: CustomInputSchema[], eventTypeId: number) { + const cInputsIdsToDeleteOrUpdated = customInputs.filter((input) => !input.hasToBeCreated); + const cInputsIdsToDelete = cInputsIdsToDeleteOrUpdated.map((e) => e.id); + const cInputsToCreate = customInputs + .filter((input) => input.hasToBeCreated) + .map((input) => ({ + type: input.type, + label: input.label, + required: input.required, + placeholder: input.placeholder, + options: input.options || undefined, + })); + const cInputsToUpdate = cInputsIdsToDeleteOrUpdated.map((input) => ({ + data: { + type: input.type, + label: input.label, + required: input.required, + placeholder: input.placeholder, + options: input.options || undefined, + }, + where: { + id: input.id, + }, + })); + + return { + deleteMany: { + eventTypeId, + NOT: { + id: { in: cInputsIdsToDelete }, + }, + }, + createMany: { + data: cInputsToCreate, + }, + update: cInputsToUpdate, + }; +} + +export function ensureUniqueBookingFields(fields: z.infer["bookingFields"]) { + if (!fields) { + return; + } + + fields.reduce((discoveredFields, field) => { + if (discoveredFields[field.name]) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Duplicate booking field name: ${field.name}`, + }); + } + + discoveredFields[field.name] = true; + + return discoveredFields; + }, {} as Record); +} diff --git a/packages/trpc/server/routers/viewer/payments/_router.ts b/packages/trpc/server/routers/viewer/payments/_router.ts new file mode 100644 index 0000000000..f78f105c4b --- /dev/null +++ b/packages/trpc/server/routers/viewer/payments/_router.ts @@ -0,0 +1,28 @@ +import { router, authedProcedure } from "../../../trpc"; +import { ZChargerCardInputSchema } from "./chargeCard.schema"; + +interface PaymentsRouterHandlerCache { + chargeCard?: typeof import("./chargeCard.handler").chargeCardHandler; +} + +const UNSTABLE_HANDLER_CACHE: PaymentsRouterHandlerCache = {}; + +export const paymentsRouter = router({ + chargeCard: authedProcedure.input(ZChargerCardInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.chargeCard) { + UNSTABLE_HANDLER_CACHE.chargeCard = await import("./chargeCard.handler").then( + (mod) => mod.chargeCardHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.chargeCard) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.chargeCard({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts b/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts new file mode 100644 index 0000000000..24f29f6f25 --- /dev/null +++ b/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts @@ -0,0 +1,121 @@ +import appStore from "@calcom/app-store"; +import dayjs from "@calcom/dayjs"; +import { sendNoShowFeeChargedEmail } from "@calcom/emails"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import type { PrismaClient } from "@calcom/prisma/client"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TChargeCardInputSchema } from "./chargeCard.schema"; + +interface ChargeCardHandlerOptions { + ctx: { user: NonNullable; prisma: PrismaClient }; + input: TChargeCardInputSchema; +} +export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions) => { + const { prisma } = ctx; + + const booking = await prisma.booking.findFirst({ + where: { + id: input.bookingId, + }, + include: { + payment: true, + user: true, + attendees: true, + eventType: true, + }, + }); + + if (!booking) { + throw new Error("Booking not found"); + } + + if (booking.payment[0].success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `The no show fee for ${booking.id} has already been charged.`, + }); + } + + const tOrganizer = await getTranslation(booking.user?.locale ?? "en", "common"); + + const attendeesListPromises = []; + + for (const attendee of booking.attendees) { + const attendeeObject = { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate: await getTranslation(attendee.locale ?? "en", "common"), + locale: attendee.locale ?? "en", + }, + }; + + attendeesListPromises.push(attendeeObject); + } + + const attendeesList = await Promise.all(attendeesListPromises); + + const evt: CalendarEvent = { + type: (booking?.eventType?.title as string) || booking?.title, + title: booking.title, + startTime: dayjs(booking.startTime).format(), + endTime: dayjs(booking.endTime).format(), + organizer: { + email: booking.user?.email || "", + name: booking.user?.name || "Nameless", + timeZone: booking.user?.timeZone || "", + language: { translate: tOrganizer, locale: booking.user?.locale ?? "en" }, + }, + attendees: attendeesList, + paymentInfo: { + amount: booking.payment[0].amount, + currency: booking.payment[0].currency, + paymentOption: booking.payment[0].paymentOption, + }, + }; + + const paymentCredential = await prisma.credential.findFirst({ + where: { + userId: ctx.user.id, + appId: booking.payment[0].appId, + }, + include: { + app: true, + }, + }); + + if (!paymentCredential?.app) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid payment credential" }); + } + + const paymentApp = await appStore[paymentCredential?.app?.dirName as keyof typeof appStore]; + + if (!("lib" in paymentApp && "PaymentService" in paymentApp.lib)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Payment service not found" }); + } + + const PaymentService = paymentApp.lib.PaymentService; + const paymentInstance = new PaymentService(paymentCredential); + + try { + const paymentData = await paymentInstance.chargeCard(booking.payment[0]); + + if (!paymentData) { + throw new TRPCError({ code: "NOT_FOUND", message: `Could not generate payment data` }); + } + + await sendNoShowFeeChargedEmail(attendeesListPromises[0], evt); + + return paymentData; + } catch (err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error processing payment with error ${err}`, + }); + } +}; diff --git a/packages/trpc/server/routers/viewer/payments/chargeCard.schema.ts b/packages/trpc/server/routers/viewer/payments/chargeCard.schema.ts new file mode 100644 index 0000000000..0f2cabaac1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/payments/chargeCard.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { ChargerCardSchema } from "./type"; + +export const ZChargerCardInputSchema = ChargerCardSchema; + +export type TChargeCardInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/payments/type.ts b/packages/trpc/server/routers/viewer/payments/type.ts new file mode 100644 index 0000000000..356e019393 --- /dev/null +++ b/packages/trpc/server/routers/viewer/payments/type.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ChargerCardSchema = z.object({ + bookingId: z.number(), +}); + +export type TChargeCardSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/slots/_router.tsx b/packages/trpc/server/routers/viewer/slots/_router.tsx new file mode 100644 index 0000000000..bd1c9619dc --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/_router.tsx @@ -0,0 +1,59 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { router, publicProcedure } from "../../../trpc"; +import { ZGetScheduleInputSchema } from "./getSchedule.schema"; +import { ZReserveSlotInputSchema } from "./reserveSlot.schema"; + +type SlotsRouterHandlerCache = { + getSchedule?: typeof import("./getSchedule.handler").getScheduleHandler; + reserveSlot?: typeof import("./reserveSlot.handler").reserveSlotHandler; +}; + +const UNSTABLE_HANDLER_CACHE: SlotsRouterHandlerCache = {}; + +/** This should be called getAvailableSlots */ +export const slotsRouter = router({ + getSchedule: publicProcedure.input(ZGetScheduleInputSchema).query(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getSchedule) { + UNSTABLE_HANDLER_CACHE.getSchedule = await import("./getSchedule.handler").then( + (mod) => mod.getScheduleHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getSchedule) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getSchedule({ + ctx, + input, + }); + }), + reserveSlot: publicProcedure.input(ZReserveSlotInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.reserveSlot) { + UNSTABLE_HANDLER_CACHE.reserveSlot = await import("./reserveSlot.handler").then( + (mod) => mod.reserveSlotHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.reserveSlot) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.reserveSlot({ + ctx: { ...ctx, req: ctx.req as NextApiRequest, res: ctx.res as NextApiResponse }, + input, + }); + }), + // This endpoint has no dependencies, it doesn't need its own file + removeSelectedSlotMark: publicProcedure.mutation(async ({ ctx }) => { + const { req, prisma } = ctx; + const uid = req?.cookies?.uid; + if (uid) { + await prisma.selectedSlots.deleteMany({ where: { uid: { equals: uid } } }); + } + return; + }), +}); diff --git a/packages/trpc/server/routers/viewer/slots/getSchedule.handler.ts b/packages/trpc/server/routers/viewer/slots/getSchedule.handler.ts new file mode 100644 index 0000000000..43fc975b03 --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/getSchedule.handler.ts @@ -0,0 +1,11 @@ +import type { TGetScheduleInputSchema } from "./getSchedule.schema"; +import { getSchedule } from "./util"; + +type GetScheduleOptions = { + ctx: Record; + input: TGetScheduleInputSchema; +}; + +export const getScheduleHandler = async ({ input }: GetScheduleOptions) => { + return await getSchedule(input); +}; diff --git a/packages/trpc/server/routers/viewer/slots/getSchedule.schema.ts b/packages/trpc/server/routers/viewer/slots/getSchedule.schema.ts new file mode 100644 index 0000000000..89ccc014d7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/getSchedule.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { getScheduleSchema } from "./types"; + +export const ZGetScheduleInputSchema = getScheduleSchema; + +export type TGetScheduleInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts new file mode 100644 index 0000000000..9f98448535 --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts @@ -0,0 +1,61 @@ +import { serialize } from "cookie"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 as uuid } from "uuid"; + +import dayjs from "@calcom/dayjs"; +import { MINUTES_TO_BOOK } from "@calcom/lib/constants"; +import type { PrismaClient } from "@calcom/prisma/client"; + +import { TRPCError } from "@trpc/server"; + +import type { TReserveSlotInputSchema } from "./reserveSlot.schema"; + +interface ReserveSlotOptions { + ctx: { + prisma: PrismaClient; + req?: NextApiRequest | undefined; + res?: NextApiResponse | undefined; + }; + input: TReserveSlotInputSchema; +} +export const reserveSlotHandler = async ({ ctx, input }: ReserveSlotOptions) => { + const { prisma, req, res } = ctx; + const uid = req?.cookies?.uid || uuid(); + const { slotUtcStartDate, slotUtcEndDate, eventTypeId } = input; + const releaseAt = dayjs.utc().add(parseInt(MINUTES_TO_BOOK), "minutes").format(); + const eventType = await prisma.eventType.findUnique({ + where: { id: eventTypeId }, + select: { users: { select: { id: true } }, seatsPerTimeSlot: true }, + }); + if (eventType) { + await Promise.all( + eventType.users.map((user) => + prisma.selectedSlots.upsert({ + where: { selectedSlotUnique: { userId: user.id, slotUtcStartDate, slotUtcEndDate, uid } }, + update: { + slotUtcStartDate, + slotUtcEndDate, + releaseAt, + eventTypeId, + }, + create: { + userId: user.id, + eventTypeId, + slotUtcStartDate, + slotUtcEndDate, + uid, + releaseAt, + isSeat: eventType.seatsPerTimeSlot !== null, + }, + }) + ) + ); + } else { + throw new TRPCError({ + message: "Event type not found", + code: "NOT_FOUND", + }); + } + res?.setHeader("Set-Cookie", serialize("uid", uid, { path: "/", sameSite: "lax" })); + return; +}; diff --git a/packages/trpc/server/routers/viewer/slots/reserveSlot.schema.ts b/packages/trpc/server/routers/viewer/slots/reserveSlot.schema.ts new file mode 100644 index 0000000000..74e9fc9942 --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/reserveSlot.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { reserveSlotSchema } from "./types"; + +export const ZReserveSlotInputSchema = reserveSlotSchema; + +export type TReserveSlotInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts new file mode 100644 index 0000000000..f387f7059e --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +export const getScheduleSchema = z + .object({ + // startTime ISOString + startTime: z.string(), + // endTime ISOString + endTime: z.string(), + // Event type ID + eventTypeId: z.number().int().optional(), + // Event type slug + eventTypeSlug: z.string(), + // invitee timezone + timeZone: z.string().optional(), + // or list of users (for dynamic events) + usernameList: z.array(z.string()).optional(), + debug: z.boolean().optional(), + // to handle event types with multiple duration options + duration: z + .string() + .optional() + .transform((val) => val && parseInt(val)), + }) + .refine( + (data) => !!data.eventTypeId || !!data.usernameList, + "Either usernameList or eventTypeId should be filled in." + ); + +export const reserveSlotSchema = z + .object({ + eventTypeId: z.number().int(), + // startTime ISOString + slotUtcStartDate: z.string(), + // endTime ISOString + slotUtcEndDate: z.string(), + }) + .refine( + (data) => !!data.eventTypeId || !!data.slotUtcStartDate || !!data.slotUtcEndDate, + "Either slotUtcStartDate, slotUtcEndDate or eventTypeId should be filled in." + ); + +export type Slot = { + time: string; + userIds?: number[]; + attendees?: number; + bookingUid?: string; + users?: string[]; +}; diff --git a/packages/trpc/server/routers/viewer/slots.ts b/packages/trpc/server/routers/viewer/slots/util.ts similarity index 77% rename from packages/trpc/server/routers/viewer/slots.ts rename to packages/trpc/server/routers/viewer/slots/util.ts index 787d613109..13959a741e 100644 --- a/packages/trpc/server/routers/viewer/slots.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -1,77 +1,27 @@ import { SchedulingType } from "@prisma/client"; -import { serialize } from "cookie"; import { countBy } from "lodash"; import { v4 as uuid } from "uuid"; -import { z } from "zod"; import { getAggregateWorkingHours } from "@calcom/core/getAggregateWorkingHours"; import type { CurrentSeats } from "@calcom/core/getUserAvailability"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; -import { MINUTES_TO_BOOK } from "@calcom/lib/constants"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import isTimeOutOfBounds from "@calcom/lib/isOutOfBounds"; import logger from "@calcom/lib/logger"; import { performance } from "@calcom/lib/server/perfObserver"; import getTimeSlots from "@calcom/lib/slots"; -import type prisma from "@calcom/prisma"; import { availabilityUserSelect } from "@calcom/prisma"; +import prisma from "@calcom/prisma"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { EventBusyDate } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; -import { publicProcedure, router } from "../../trpc"; +import type { TGetScheduleInputSchema } from "./getSchedule.schema"; -const getScheduleSchema = z - .object({ - // startTime ISOString - startTime: z.string(), - // endTime ISOString - endTime: z.string(), - // Event type ID - eventTypeId: z.number().int().optional(), - // Event type slug - eventTypeSlug: z.string(), - // invitee timezone - timeZone: z.string().optional(), - // or list of users (for dynamic events) - usernameList: z.array(z.string()).optional(), - debug: z.boolean().optional(), - // to handle event types with multiple duration options - duration: z - .string() - .optional() - .transform((val) => val && parseInt(val)), - }) - .refine( - (data) => !!data.eventTypeId || !!data.usernameList, - "Either usernameList or eventTypeId should be filled in." - ); - -const reverveSlotSchema = z - .object({ - eventTypeId: z.number().int(), - // startTime ISOString - slotUtcStartDate: z.string(), - // endTime ISOString - slotUtcEndDate: z.string(), - }) - .refine( - (data) => !!data.eventTypeId || !!data.slotUtcStartDate || !!data.slotUtcEndDate, - "Either slotUtcStartDate, slotUtcEndDate or eventTypeId should be filled in." - ); - -export type Slot = { - time: string; - userIds?: number[]; - attendees?: number; - bookingUid?: string; - users?: string[]; -}; - -const checkIfIsAvailable = ({ +export const checkIfIsAvailable = ({ time, busy, eventLength, @@ -120,64 +70,8 @@ const checkIfIsAvailable = ({ }); }; -/** This should be called getAvailableSlots */ -export const slotsRouter = router({ - getSchedule: publicProcedure.input(getScheduleSchema).query(async ({ input, ctx }) => { - return await getSchedule(input, ctx); - }), - reserveSlot: publicProcedure.input(reverveSlotSchema).mutation(async ({ ctx, input }) => { - const { prisma, req, res } = ctx; - const uid = req?.cookies?.uid || uuid(); - const { slotUtcStartDate, slotUtcEndDate, eventTypeId } = input; - const releaseAt = dayjs.utc().add(parseInt(MINUTES_TO_BOOK), "minutes").format(); - const eventType = await prisma.eventType.findUnique({ - where: { id: eventTypeId }, - select: { users: { select: { id: true } }, seatsPerTimeSlot: true }, - }); - if (eventType) { - await Promise.all( - eventType.users.map((user) => - prisma.selectedSlots.upsert({ - where: { selectedSlotUnique: { userId: user.id, slotUtcStartDate, slotUtcEndDate, uid } }, - update: { - slotUtcStartDate, - slotUtcEndDate, - releaseAt, - eventTypeId, - }, - create: { - userId: user.id, - eventTypeId, - slotUtcStartDate, - slotUtcEndDate, - uid, - releaseAt, - isSeat: eventType.seatsPerTimeSlot !== null, - }, - }) - ) - ); - } else { - throw new TRPCError({ - message: "Event type not found", - code: "NOT_FOUND", - }); - } - res?.setHeader("Set-Cookie", serialize("uid", uid, { path: "/", sameSite: "lax" })); - return; - }), - removeSelectedSlotMark: publicProcedure.mutation(async ({ ctx }) => { - const { req, prisma } = ctx; - const uid = req?.cookies?.uid; - if (uid) { - await prisma.selectedSlots.deleteMany({ where: { uid: { equals: uid } } }); - } - return; - }), -}); - -async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer) { - const eventType = await ctx.prisma.eventType.findUnique({ +export async function getEventType(input: TGetScheduleInputSchema) { + const eventType = await prisma.eventType.findUnique({ where: { id: input.eventTypeId, }, @@ -243,10 +137,10 @@ async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer) { +export async function getDynamicEventType(input: TGetScheduleInputSchema) { // For dynamic booking, we need to get and update user credentials, schedule and availability in the eventTypeObject as they're required in the new availability logic const dynamicEventType = getDefaultEvent(input.eventTypeSlug); - const users = await ctx.prisma.user.findMany({ + const users = await prisma.user.findMany({ where: { username: { in: input.usernameList, @@ -270,16 +164,13 @@ async function getDynamicEventType(ctx: { prisma: typeof prisma }, input: z.infe }); } -function getRegularOrDynamicEventType( - ctx: { prisma: typeof prisma }, - input: z.infer -) { +export function getRegularOrDynamicEventType(input: TGetScheduleInputSchema) { const isDynamicBooking = !input.eventTypeId; - return isDynamicBooking ? getDynamicEventType(ctx, input) : getEventType(ctx, input); + return isDynamicBooking ? getDynamicEventType(input) : getEventType(input); } /** This should be called getAvailableSlots */ -export async function getSchedule(input: z.infer, ctx: { prisma: typeof prisma }) { +export async function getSchedule(input: TGetScheduleInputSchema) { if (input.debug === true) { logger.setSettings({ minLevel: "debug" }); } @@ -287,7 +178,7 @@ export async function getSchedule(input: z.infer, ctx: logger.setSettings({ minLevel: "silly" }); } const startPrismaEventTypeGet = performance.now(); - const eventType = await getRegularOrDynamicEventType(ctx, input); + const eventType = await getRegularOrDynamicEventType(input); const endPrismaEventTypeGet = performance.now(); logger.debug( `Prisma eventType get took ${endPrismaEventTypeGet - startPrismaEventTypeGet}ms for event:${ @@ -401,7 +292,7 @@ export async function getSchedule(input: z.infer, ctx: // Load cached busy slots const selectedSlots = /* FIXME: For some reason this returns undefined while testing in Jest */ - (await ctx.prisma.selectedSlots.findMany({ + (await prisma.selectedSlots.findMany({ where: { userId: { in: usersWithCredentials.map((user) => user.id) }, releaseAt: { gt: dayjs.utc().format() }, @@ -415,7 +306,7 @@ export async function getSchedule(input: z.infer, ctx: eventTypeId: true, }, })) || []; - await ctx.prisma.selectedSlots.deleteMany({ + await prisma.selectedSlots.deleteMany({ where: { eventTypeId: { equals: eventType.id }, id: { notIn: selectedSlots.map((item) => item.id) } }, }); diff --git a/packages/trpc/server/routers/viewer/sso.tsx b/packages/trpc/server/routers/viewer/sso.tsx deleted file mode 100644 index d4dfe3a44c..0000000000 --- a/packages/trpc/server/routers/viewer/sso.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { z } from "zod"; - -import jackson from "@calcom/features/ee/sso/lib/jackson"; -import { - samlProductID, - samlTenantID, - tenantPrefix, - canAccess, - oidcPath, -} from "@calcom/features/ee/sso/lib/saml"; - -import { TRPCError } from "@trpc/server"; - -import { router, authedProcedure } from "../../trpc"; - -export const ssoRouter = router({ - // Retrieve SSO Connection - get: authedProcedure - .input( - z.object({ - teamId: z.union([z.number(), z.null()]), - }) - ) - .query(async ({ ctx, input }) => { - const { teamId } = input; - - const { message, access } = await canAccess(ctx.user, teamId); - - if (!access) { - throw new TRPCError({ - code: "BAD_REQUEST", - message, - }); - } - - const { connectionController, samlSPConfig } = await jackson(); - - // Retrieve the SP SAML Config - const SPConfig = await samlSPConfig.get(); - - try { - const connections = await connectionController.getConnections({ - tenant: teamId ? tenantPrefix + teamId : samlTenantID, - product: samlProductID, - }); - - if (connections.length === 0) { - return null; - } - - const type = "idpMetadata" in connections[0] ? "saml" : "oidc"; - - return { - ...connections[0], - type, - acsUrl: type === "saml" ? SPConfig.acsUrl : null, - entityId: type === "saml" ? SPConfig.entityId : null, - callbackUrl: type === "oidc" ? `${process.env.NEXT_PUBLIC_WEBAPP_URL}${oidcPath}` : null, - }; - } catch (err) { - console.error("Error getting SSO connection", err); - throw new TRPCError({ code: "BAD_REQUEST", message: "Fetching SSO connection failed." }); - } - }), - // Update the SAML Connection - update: authedProcedure - .input( - z.object({ - encodedRawMetadata: z.string(), - teamId: z.union([z.number(), z.null()]), - }) - ) - .mutation(async ({ ctx, input }) => { - const { connectionController } = await jackson(); - - const { encodedRawMetadata, teamId } = input; - - const { message, access } = await canAccess(ctx.user, teamId); - - if (!access) { - throw new TRPCError({ - code: "BAD_REQUEST", - message, - }); - } - - try { - return await connectionController.createSAMLConnection({ - encodedRawMetadata, - defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/saml-idp`, - redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]), - tenant: teamId ? tenantPrefix + teamId : samlTenantID, - product: samlProductID, - }); - } catch (err) { - console.error("Error updating SAML connection", err); - throw new TRPCError({ code: "BAD_REQUEST", message: "Updating SAML Connection failed." }); - } - }), - // Delete the SAML Connection - delete: authedProcedure - .input( - z.object({ - teamId: z.union([z.number(), z.null()]), - }) - ) - .mutation(async ({ ctx, input }) => { - const { connectionController } = await jackson(); - - const { teamId } = input; - - const { message, access } = await canAccess(ctx.user, teamId); - - if (!access) { - throw new TRPCError({ - code: "BAD_REQUEST", - message, - }); - } - - try { - return await connectionController.deleteConnections({ - tenant: teamId ? tenantPrefix + teamId : samlTenantID, - product: samlProductID, - }); - } catch (err) { - console.error("Error deleting SAML connection", err); - throw new TRPCError({ code: "BAD_REQUEST", message: "Deleting SAML Connection failed." }); - } - }), - - // Update the OIDC Connection - updateOIDC: authedProcedure - .input( - z.object({ - teamId: z.union([z.number(), z.null()]), - clientId: z.string(), - clientSecret: z.string(), - wellKnownUrl: z.string(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { teamId, clientId, clientSecret, wellKnownUrl } = input; - - const { message, access } = await canAccess(ctx.user, teamId); - - if (!access) { - throw new TRPCError({ - code: "BAD_REQUEST", - message, - }); - } - - const { connectionController } = await jackson(); - - try { - return await connectionController.createOIDCConnection({ - 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, - oidcClientId: clientId, - oidcClientSecret: clientSecret, - oidcDiscoveryUrl: wellKnownUrl, - }); - } catch (err) { - console.error("Error updating OIDC connection", err); - throw new TRPCError({ code: "BAD_REQUEST", message: "Updating OIDC Connection failed." }); - } - }), -}); diff --git a/packages/trpc/server/routers/viewer/sso/_router.tsx b/packages/trpc/server/routers/viewer/sso/_router.tsx new file mode 100644 index 0000000000..0751262350 --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/_router.tsx @@ -0,0 +1,86 @@ +import { router, authedProcedure } from "../../../trpc"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZUpdateInputSchema } from "./update.schema"; +import { ZUpdateOIDCInputSchema } from "./updateOIDC.schema"; + +type SSORouterHandlerCache = { + get?: typeof import("./get.handler").getHandler; + update?: typeof import("./update.handler").updateHandler; + delete?: typeof import("./delete.handler").deleteHandler; + updateOIDC?: typeof import("./updateOIDC.handler").updateOIDCHandler; +}; + +const UNSTABLE_HANDLER_CACHE: SSORouterHandlerCache = {}; + +export const ssoRouter = router({ + // Retrieve SSO Connection + get: authedProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + // Update the SAML Connection + update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.update) { + UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.update) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.update({ + ctx, + input, + }); + }), + + // Delete the SAML Connection + delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + // Update the OIDC Connection + updateOIDC: authedProcedure.input(ZUpdateOIDCInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.updateOIDC) { + UNSTABLE_HANDLER_CACHE.updateOIDC = await import("./updateOIDC.handler").then( + (mod) => mod.updateOIDCHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.updateOIDC) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.updateOIDC({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/sso/delete.handler.ts b/packages/trpc/server/routers/viewer/sso/delete.handler.ts new file mode 100644 index 0000000000..7e94cf2a90 --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/delete.handler.ts @@ -0,0 +1,39 @@ +import jackson from "@calcom/features/ee/sso/lib/jackson"; +import { canAccess, samlProductID, samlTenantID, tenantPrefix } from "@calcom/features/ee/sso/lib/saml"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { + const { connectionController } = await jackson(); + + const { teamId } = input; + + const { message, access } = await canAccess(ctx.user, teamId); + + if (!access) { + throw new TRPCError({ + code: "BAD_REQUEST", + message, + }); + } + + try { + return await connectionController.deleteConnections({ + tenant: teamId ? tenantPrefix + teamId : samlTenantID, + product: samlProductID, + }); + } catch (err) { + console.error("Error deleting SAML connection", err); + throw new TRPCError({ code: "BAD_REQUEST", message: "Deleting SAML Connection failed." }); + } +}; diff --git a/packages/trpc/server/routers/viewer/sso/delete.schema.ts b/packages/trpc/server/routers/viewer/sso/delete.schema.ts new file mode 100644 index 0000000000..0ad5d3c40f --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + teamId: z.union([z.number(), z.null()]), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/sso/get.handler.ts b/packages/trpc/server/routers/viewer/sso/get.handler.ts new file mode 100644 index 0000000000..7e6aa0153d --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/get.handler.ts @@ -0,0 +1,62 @@ +import jackson from "@calcom/features/ee/sso/lib/jackson"; +import { + canAccess, + oidcPath, + samlProductID, + samlTenantID, + tenantPrefix, +} from "@calcom/features/ee/sso/lib/saml"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TGetInputSchema } from "./get.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TGetInputSchema; +}; + +export const getHandler = async ({ ctx, input }: GetOptions) => { + const { teamId } = input; + + const { message, access } = await canAccess(ctx.user, teamId); + + if (!access) { + throw new TRPCError({ + code: "BAD_REQUEST", + message, + }); + } + + const { connectionController, samlSPConfig } = await jackson(); + + // Retrieve the SP SAML Config + const SPConfig = await samlSPConfig.get(); + + try { + const connections = await connectionController.getConnections({ + tenant: teamId ? tenantPrefix + teamId : samlTenantID, + product: samlProductID, + }); + + if (connections.length === 0) { + return null; + } + + const type = "idpMetadata" in connections[0] ? "saml" : "oidc"; + + return { + ...connections[0], + type, + acsUrl: type === "saml" ? SPConfig.acsUrl : null, + entityId: type === "saml" ? SPConfig.entityId : null, + callbackUrl: type === "oidc" ? `${process.env.NEXT_PUBLIC_WEBAPP_URL}${oidcPath}` : null, + }; + } catch (err) { + console.error("Error getting SSO connection", err); + throw new TRPCError({ code: "BAD_REQUEST", message: "Fetching SSO connection failed." }); + } +}; diff --git a/packages/trpc/server/routers/viewer/sso/get.schema.ts b/packages/trpc/server/routers/viewer/sso/get.schema.ts new file mode 100644 index 0000000000..044011e4e8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/get.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetInputSchema = z.object({ + teamId: z.union([z.number(), z.null()]), +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/sso/update.handler.ts b/packages/trpc/server/routers/viewer/sso/update.handler.ts new file mode 100644 index 0000000000..2b150c4764 --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/update.handler.ts @@ -0,0 +1,42 @@ +import jackson from "@calcom/features/ee/sso/lib/jackson"; +import { canAccess, samlProductID, samlTenantID, tenantPrefix } from "@calcom/features/ee/sso/lib/saml"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TUpdateInputSchema } from "./update.schema"; + +type UpdateOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateInputSchema; +}; + +export const updateHandler = async ({ ctx, input }: UpdateOptions) => { + const { connectionController } = await jackson(); + + const { encodedRawMetadata, teamId } = input; + + const { message, access } = await canAccess(ctx.user, teamId); + + if (!access) { + throw new TRPCError({ + code: "BAD_REQUEST", + message, + }); + } + + try { + return await connectionController.createSAMLConnection({ + encodedRawMetadata, + defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/saml-idp`, + redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]), + tenant: teamId ? tenantPrefix + teamId : samlTenantID, + product: samlProductID, + }); + } catch (err) { + console.error("Error updating SAML connection", err); + throw new TRPCError({ code: "BAD_REQUEST", message: "Updating SAML Connection failed." }); + } +}; diff --git a/packages/trpc/server/routers/viewer/sso/update.schema.ts b/packages/trpc/server/routers/viewer/sso/update.schema.ts new file mode 100644 index 0000000000..e5e456c9d9 --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/update.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZUpdateInputSchema = z.object({ + encodedRawMetadata: z.string(), + teamId: z.union([z.number(), z.null()]), +}); + +export type TUpdateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/sso/updateOIDC.handler.ts b/packages/trpc/server/routers/viewer/sso/updateOIDC.handler.ts new file mode 100644 index 0000000000..8ecd5d76de --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/updateOIDC.handler.ts @@ -0,0 +1,44 @@ +import jackson from "@calcom/features/ee/sso/lib/jackson"; +import { canAccess, samlProductID, samlTenantID, tenantPrefix } from "@calcom/features/ee/sso/lib/saml"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TUpdateOIDCInputSchema } from "./updateOIDC.schema"; + +type UpdateOIDCOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateOIDCInputSchema; +}; + +export const updateOIDCHandler = async ({ ctx, input }: UpdateOIDCOptions) => { + const { teamId, clientId, clientSecret, wellKnownUrl } = input; + + const { message, access } = await canAccess(ctx.user, teamId); + + if (!access) { + throw new TRPCError({ + code: "BAD_REQUEST", + message, + }); + } + + const { connectionController } = await jackson(); + + try { + return await connectionController.createOIDCConnection({ + 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, + oidcClientId: clientId, + oidcClientSecret: clientSecret, + oidcDiscoveryUrl: wellKnownUrl, + }); + } catch (err) { + console.error("Error updating OIDC connection", err); + throw new TRPCError({ code: "BAD_REQUEST", message: "Updating OIDC Connection failed." }); + } +}; diff --git a/packages/trpc/server/routers/viewer/sso/updateOIDC.schema.ts b/packages/trpc/server/routers/viewer/sso/updateOIDC.schema.ts new file mode 100644 index 0000000000..9adefc1f7d --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/updateOIDC.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZUpdateOIDCInputSchema = z.object({ + teamId: z.union([z.number(), z.null()]), + clientId: z.string(), + clientSecret: z.string(), + wellKnownUrl: z.string(), +}); + +export type TUpdateOIDCInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams.tsx b/packages/trpc/server/routers/viewer/teams.tsx deleted file mode 100644 index 31c6f4ad62..0000000000 --- a/packages/trpc/server/routers/viewer/teams.tsx +++ /dev/null @@ -1,739 +0,0 @@ -import { MembershipRole, Prisma } from "@prisma/client"; -import { randomBytes } from "crypto"; -import { z } from "zod"; - -import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; -import { getUserAvailability } from "@calcom/core/getUserAvailability"; -import { sendTeamInviteEmail } from "@calcom/emails"; -import { - cancelTeamSubscriptionFromStripe, - purchaseTeamSubscription, - updateQuantitySubscriptionFromStripe, -} from "@calcom/features/ee/teams/lib/payments"; -import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; -import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; -import { getTranslation } from "@calcom/lib/server/i18n"; -import { getTeamWithMembers, isTeamAdmin, isTeamMember, isTeamOwner } from "@calcom/lib/server/queries/teams"; -import slugify from "@calcom/lib/slugify"; -import { - closeComDeleteTeam, - closeComDeleteTeamMembership, - closeComUpdateTeam, - closeComUpsertTeamUser, -} from "@calcom/lib/sync/SyncServiceManager"; -import { availabilityUserSelect } from "@calcom/prisma"; -import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; - -import { TRPCError } from "@trpc/server"; - -import { authedProcedure, router } from "../../trpc"; - -const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str); -export const viewerTeamsRouter = router({ - // Retrieves team by id - get: authedProcedure - .input( - z.object({ - teamId: z.number(), - }) - ) - .query(async ({ ctx, input }) => { - const team = await getTeamWithMembers(input.teamId, undefined, ctx.user.id); - if (!team) { - throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); - } - const membership = team?.members.find((membership) => membership.id === ctx.user.id); - return { - ...team, - safeBio: markdownToSafeHTML(team.bio), - membership: { - role: membership?.role as MembershipRole, - accepted: membership?.accepted, - }, - }; - }), - // Returns teams I a member of - list: authedProcedure.query(async ({ ctx }) => { - const memberships = await ctx.prisma.membership.findMany({ - where: { - userId: ctx.user.id, - }, - include: { - team: true, - }, - orderBy: { role: "desc" }, - }); - - return memberships.map(({ team, ...membership }) => ({ - role: membership.role, - accepted: membership.accepted, - ...team, - })); - }), - create: authedProcedure - .input( - z.object({ - name: z.string(), - slug: z.string().transform((val) => slugify(val.trim())), - logo: z - .string() - .optional() - .nullable() - .transform((v) => v || null), - }) - ) - .mutation(async ({ ctx, input }) => { - const { slug, name, logo } = input; - - const slugCollisions = await ctx.prisma.team.findFirst({ - where: { - slug: slug, - }, - }); - - if (slugCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "team_url_taken" }); - - // Ensure that the user is not duplicating a requested team - const duplicatedRequest = await ctx.prisma.team.findFirst({ - where: { - members: { - some: { - userId: ctx.user.id, - }, - }, - metadata: { - path: ["requestedSlug"], - equals: slug, - }, - }, - }); - - if (duplicatedRequest) { - return duplicatedRequest; - } - - const createTeam = await ctx.prisma.team.create({ - data: { - name, - logo, - members: { - create: { - userId: ctx.user.id, - role: MembershipRole.OWNER, - accepted: true, - }, - }, - metadata: { - requestedSlug: slug, - }, - ...(!IS_TEAM_BILLING_ENABLED && { slug }), - }, - }); - - // Sync Services: Close.com - closeComUpsertTeamUser(createTeam, ctx.user, MembershipRole.OWNER); - - return createTeam; - }), - // Allows team owner to update team metadata - update: authedProcedure - .input( - z.object({ - id: z.number(), - bio: z.string().optional(), - name: z.string().optional(), - logo: z.string().optional(), - slug: z.string().optional(), - hideBranding: z.boolean().optional(), - hideBookATeamMember: z.boolean().optional(), - brandColor: z.string().optional(), - darkBrandColor: z.string().optional(), - theme: z.string().optional().nullable(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (!(await isTeamAdmin(ctx.user?.id, input.id))) throw new TRPCError({ code: "UNAUTHORIZED" }); - - if (input.slug) { - const userConflict = await ctx.prisma.team.findMany({ - where: { - slug: input.slug, - }, - }); - if (userConflict.some((t) => t.id !== input.id)) return; - } - - const prevTeam = await ctx.prisma.team.findFirst({ - where: { - id: input.id, - }, - }); - - if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); - - const data: Prisma.TeamUpdateArgs["data"] = { - name: input.name, - logo: input.logo, - bio: input.bio, - hideBranding: input.hideBranding, - hideBookATeamMember: input.hideBookATeamMember, - brandColor: input.brandColor, - darkBrandColor: input.darkBrandColor, - theme: input.theme, - }; - - if ( - input.slug && - IS_TEAM_BILLING_ENABLED && - /** If the team doesn't have a slug we can assume that it hasn't been published yet. */ - !prevTeam.slug - ) { - // Save it on the metadata so we can use it later - data.metadata = { - requestedSlug: input.slug, - }; - } else { - data.slug = input.slug; - - // If we save slug, we don't need the requestedSlug anymore - const metadataParse = teamMetadataSchema.safeParse(prevTeam.metadata); - if (metadataParse.success) { - const { requestedSlug: _, ...cleanMetadata } = metadataParse.data || {}; - data.metadata = { - ...cleanMetadata, - }; - } - } - - const updatedTeam = await ctx.prisma.team.update({ - where: { id: input.id }, - data, - }); - - // Sync Services: Close.com - if (prevTeam) closeComUpdateTeam(prevTeam, updatedTeam); - }), - delete: authedProcedure - .input( - z.object({ - teamId: z.number(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); - - if (IS_TEAM_BILLING_ENABLED) await cancelTeamSubscriptionFromStripe(input.teamId); - - // delete all memberships - await ctx.prisma.membership.deleteMany({ - where: { - teamId: input.teamId, - }, - }); - - const deletedTeam = await ctx.prisma.team.delete({ - where: { - id: input.teamId, - }, - }); - - // Sync Services: Close.cm - closeComDeleteTeam(deletedTeam); - }), - removeMember: authedProcedure - .input( - z.object({ - teamId: z.number(), - memberId: z.number(), - }) - ) - .mutation(async ({ ctx, input }) => { - const isAdmin = await isTeamAdmin(ctx.user?.id, input.teamId); - if (!isAdmin && ctx.user?.id !== input.memberId) throw new TRPCError({ code: "UNAUTHORIZED" }); - // Only a team owner can remove another team owner. - if ( - (await isTeamOwner(input.memberId, input.teamId)) && - !(await isTeamOwner(ctx.user?.id, input.teamId)) - ) - throw new TRPCError({ code: "UNAUTHORIZED" }); - if (ctx.user?.id === input.memberId && isAdmin) - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can not remove yourself from a team you own.", - }); - - const membership = await ctx.prisma.membership.delete({ - where: { - userId_teamId: { userId: input.memberId, teamId: input.teamId }, - }, - include: { - user: true, - }, - }); - - // Deleted managed event types from this team from this member - await ctx.prisma.eventType.deleteMany({ - where: { parent: { teamId: input.teamId }, userId: membership.userId }, - }); - - // Sync Services - closeComDeleteTeamMembership(membership.user); - if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); - }), - inviteMember: authedProcedure - .input( - z.object({ - teamId: z.number(), - usernameOrEmail: z.string().transform((usernameOrEmail) => usernameOrEmail.toLowerCase()), - role: z.nativeEnum(MembershipRole), - language: z.string(), - sendEmailInvitation: z.boolean(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); - if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId))) - throw new TRPCError({ code: "UNAUTHORIZED" }); - - const translation = await getTranslation(input.language ?? "en", "common"); - - const team = await ctx.prisma.team.findFirst({ - where: { - id: input.teamId, - }, - }); - - if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); - - const invitee = await ctx.prisma.user.findFirst({ - where: { - OR: [{ username: input.usernameOrEmail }, { email: input.usernameOrEmail }], - }, - }); - - if (!invitee) { - // liberal email match - - if (!isEmail(input.usernameOrEmail)) - throw new TRPCError({ - code: "NOT_FOUND", - message: `Invite failed because there is no corresponding user for ${input.usernameOrEmail}`, - }); - - // valid email given, create User and add to team - await ctx.prisma.user.create({ - data: { - email: input.usernameOrEmail, - invitedTo: input.teamId, - teams: { - create: { - teamId: input.teamId, - role: input.role as MembershipRole, - }, - }, - }, - }); - - const token: string = randomBytes(32).toString("hex"); - - await ctx.prisma.verificationToken.create({ - data: { - identifier: input.usernameOrEmail, - token, - expires: new Date(new Date().setHours(168)), // +1 week - }, - }); - if (ctx?.user?.name && team?.name) { - await sendTeamInviteEmail({ - language: translation, - from: ctx.user.name, - to: input.usernameOrEmail, - teamName: team.name, - joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/teams`, - isCalcomMember: false, - }); - } - } else { - // create provisional membership - try { - await ctx.prisma.membership.create({ - data: { - teamId: input.teamId, - userId: invitee.id, - role: input.role as MembershipRole, - }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === "P2002") { - throw new TRPCError({ - code: "FORBIDDEN", - message: "This user is a member of this team / has a pending invitation.", - }); - } - } else throw e; - } - - let sendTo = input.usernameOrEmail; - if (!isEmail(input.usernameOrEmail)) { - sendTo = invitee.email; - } - // inform user of membership by email - if (input.sendEmailInvitation && ctx?.user?.name && team?.name) { - await sendTeamInviteEmail({ - language: translation, - from: ctx.user.name, - to: sendTo, - teamName: team.name, - joinLink: WEBAPP_URL + "/settings/teams", - isCalcomMember: true, - }); - } - } - if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); - return input; - }), - acceptOrLeave: authedProcedure - .input( - z.object({ - teamId: z.number(), - accept: z.boolean(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (input.accept) { - const membership = await ctx.prisma.membership.update({ - where: { - userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, - }, - data: { - accepted: true, - }, - include: { - team: true, - }, - }); - - closeComUpsertTeamUser(membership.team, ctx.user, membership.role); - } else { - try { - //get team owner so we can alter their subscription seat count - const teamOwner = await ctx.prisma.membership.findFirst({ - where: { teamId: input.teamId, role: MembershipRole.OWNER }, - include: { team: true }, - }); - - const membership = await ctx.prisma.membership.delete({ - where: { - userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, - }, - }); - - // Sync Services: Close.com - if (teamOwner) closeComUpsertTeamUser(teamOwner.team, ctx.user, membership.role); - } catch (e) { - console.log(e); - } - } - }), - changeMemberRole: authedProcedure - .input( - z.object({ - teamId: z.number(), - memberId: z.number(), - role: z.nativeEnum(MembershipRole), - }) - ) - .mutation(async ({ ctx, input }) => { - if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); - // Only owners can award owner role. - if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId))) - throw new TRPCError({ code: "UNAUTHORIZED" }); - const memberships = await ctx.prisma.membership.findMany({ - where: { - teamId: input.teamId, - }, - }); - - const targetMembership = memberships.find((m) => m.userId === input.memberId); - const myMembership = memberships.find((m) => m.userId === ctx.user.id); - const teamHasMoreThanOneOwner = memberships.some((m) => m.role === MembershipRole.OWNER); - - if (myMembership?.role === MembershipRole.ADMIN && targetMembership?.role === MembershipRole.OWNER) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can not change the role of an owner if you are an admin.", - }); - } - - if (!teamHasMoreThanOneOwner) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can not change the role of the only owner of a team.", - }); - } - - if ( - myMembership?.role === MembershipRole.ADMIN && - input.memberId === ctx.user.id && - input.role !== MembershipRole.MEMBER - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can not change yourself to a higher role.", - }); - } - - const membership = await ctx.prisma.membership.update({ - where: { - userId_teamId: { userId: input.memberId, teamId: input.teamId }, - }, - data: { - role: input.role, - }, - include: { - team: true, - user: true, - }, - }); - - // Sync Services: Close.com - closeComUpsertTeamUser(membership.team, membership.user, membership.role); - }), - getMemberAvailability: authedProcedure - .input( - z.object({ - teamId: z.number(), - memberId: z.number(), - timezone: z.string(), - dateFrom: z.string(), - dateTo: z.string(), - }) - ) - .query(async ({ ctx, input }) => { - const team = await isTeamMember(ctx.user?.id, input.teamId); - if (!team) throw new TRPCError({ code: "UNAUTHORIZED" }); - - // verify member is in team - const members = await ctx.prisma.membership.findMany({ - where: { teamId: input.teamId }, - include: { - user: { - select: { - credentials: true, // needed for getUserAvailability - ...availabilityUserSelect, - }, - }, - }, - }); - const member = members?.find((m) => m.userId === input.memberId); - if (!member) throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); - if (!member.user.username) - throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" }); - - // get availability for this member - return await getUserAvailability( - { - username: member.user.username, - dateFrom: input.dateFrom, - dateTo: input.dateTo, - }, - { user: member.user } - ); - }), - getMembershipbyUser: authedProcedure - .input( - z.object({ - teamId: z.number(), - memberId: z.number(), - }) - ) - .query(async ({ ctx, input }) => { - if (ctx.user.id !== input.memberId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You cannot view memberships that are not your own.", - }); - } - - return await ctx.prisma.membership.findUnique({ - where: { - userId_teamId: { - userId: input.memberId, - teamId: input.teamId, - }, - }, - }); - }), - updateMembership: authedProcedure - .input( - z.object({ - teamId: z.number(), - memberId: z.number(), - disableImpersonation: z.boolean(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (ctx.user.id !== input.memberId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You cannot edit memberships that are not your own.", - }); - } - - return await ctx.prisma.membership.update({ - where: { - userId_teamId: { - userId: input.memberId, - teamId: input.teamId, - }, - }, - data: { - disableImpersonation: input.disableImpersonation, - }, - }); - }), - publish: authedProcedure - .input( - z.object({ - teamId: z.number(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); - const { teamId: id } = input; - - const prevTeam = await ctx.prisma.team.findFirst({ where: { id }, include: { members: true } }); - - if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); - - const metadata = teamMetadataSchema.safeParse(prevTeam.metadata); - - if (!metadata.success) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" }); - - // if payment needed, respond with checkout url - if (IS_TEAM_BILLING_ENABLED) { - const checkoutSession = await purchaseTeamSubscription({ - teamId: prevTeam.id, - seats: prevTeam.members.length, - userId: ctx.user.id, - }); - if (!checkoutSession.url) - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed retrieving a checkout session URL.", - }); - return { url: checkoutSession.url, message: "Payment required to publish team" }; - } - - if (!metadata.data?.requestedSlug) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" }); - } - - const { requestedSlug, ...newMetadata } = metadata.data; - let updatedTeam: Awaited>; - - try { - updatedTeam = await ctx.prisma.team.update({ - where: { id }, - data: { - slug: requestedSlug, - metadata: { ...newMetadata }, - }, - }); - } catch (error) { - const { message } = getRequestedSlugError(error, requestedSlug); - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); - } - - // Sync Services: Close.com - closeComUpdateTeam(prevTeam, updatedTeam); - - return { - url: `${WEBAPP_URL}/settings/teams/${updatedTeam.id}/profile`, - message: "Team published successfully", - }; - }), - /** This is a temporal endpoint so we can progressively upgrade teams to the new billing system. */ - getUpgradeable: authedProcedure.query(async ({ ctx }) => { - if (!IS_TEAM_BILLING_ENABLED) return []; - let { teams } = await ctx.prisma.user.findUniqueOrThrow({ - where: { id: ctx.user.id }, - include: { teams: { where: { role: MembershipRole.OWNER }, include: { team: true } } }, - }); - /** We only need to return teams that don't have a `subscriptionId` on their metadata */ - teams = teams.filter((m) => { - const metadata = teamMetadataSchema.safeParse(m.team.metadata); - if (metadata.success && metadata.data?.subscriptionId) return false; - return true; - }); - return teams; - }), - listMembers: authedProcedure - .input( - z.object({ - teamIds: z.number().array().optional(), - }) - ) - .query(async ({ ctx, input }) => { - const teams = await ctx.prisma.team.findMany({ - where: { - id: { - in: input.teamIds, - }, - members: { - some: { - user: { - id: ctx.user.id, - }, - accepted: true, - }, - }, - }, - select: { - members: { - select: { - user: { - select: { - id: true, - name: true, - username: true, - }, - }, - }, - }, - }, - }); - type UserMap = Record; - // flattern users to be unique by id - const users = teams - .flatMap((t) => t.members) - .reduce((acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: m.user }), {} as UserMap); - return Object.values(users); - }), - hasTeamPlan: authedProcedure.query(async ({ ctx }) => { - const userId = ctx.user.id; - const hasTeamPlan = await ctx.prisma.membership.findFirst({ - where: { - userId, - team: { - slug: { - not: null, - }, - }, - }, - }); - return { hasTeamPlan: !!hasTeamPlan }; - }), - listInvites: authedProcedure.query(async ({ ctx }) => { - const userId = ctx.user.id; - return await ctx.prisma.membership.findMany({ - where: { - user: { - id: userId, - }, - accepted: false, - }, - }); - }), -}); diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx new file mode 100644 index 0000000000..5d3aca2ba3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -0,0 +1,336 @@ +import { authedProcedure, router } from "../../../trpc"; +import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema"; +import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema"; +import { ZCreateInputSchema } from "./create.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZGetMemberAvailabilityInputSchema } from "./getMemberAvailability.schema"; +import { ZGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema"; +import { ZInviteMemberInputSchema } from "./inviteMember.schema"; +import { ZListMembersInputSchema } from "./listMembers.schema"; +import { ZPublishInputSchema } from "./publish.schema"; +import { ZRemoveMemberInputSchema } from "./removeMember.schema"; +import { ZUpdateInputSchema } from "./update.schema"; +import { ZUpdateMembershipInputSchema } from "./updateMembership.schema"; + +type TeamsRouterHandlerCache = { + get?: typeof import("./get.handler").getHandler; + list?: typeof import("./list.handler").listHandler; + create?: typeof import("./create.handler").createHandler; + update?: typeof import("./update.handler").updateHandler; + delete?: typeof import("./delete.handler").deleteHandler; + removeMember?: typeof import("./removeMember.handler").removeMemberHandler; + inviteMember?: typeof import("./inviteMember.handler").inviteMemberHandler; + acceptOrLeave?: typeof import("./acceptOrLeave.handler").acceptOrLeaveHandler; + changeMemberRole?: typeof import("./changeMemberRole.handler").changeMemberRoleHandler; + getMemberAvailability?: typeof import("./getMemberAvailability.handler").getMemberAvailabilityHandler; + getMembershipbyUser?: typeof import("./getMembershipbyUser.handler").getMembershipbyUserHandler; + updateMembership?: typeof import("./updateMembership.handler").updateMembershipHandler; + publish?: typeof import("./publish.handler").publishHandler; + getUpgradeable?: typeof import("./getUpgradeable.handler").getUpgradeableHandler; + listMembers?: typeof import("./listMembers.handler").listMembersHandler; + hasTeamPlan?: typeof import("./hasTeamPlan.handler").hasTeamPlanHandler; + listInvites?: typeof import("./listInvites.handler").listInvitesHandler; +}; + +const UNSTABLE_HANDLER_CACHE: TeamsRouterHandlerCache = {}; + +export const viewerTeamsRouter = router({ + // Retrieves team by id + get: authedProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + // Returns teams I a member of + list: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.list) { + UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.list) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.list({ + ctx, + }); + }), + + create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.create) { + UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.create) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.create({ + ctx, + input, + }); + }), + + // Allows team owner to update team metadata + update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.update) { + UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.update) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.update({ + ctx, + input, + }); + }), + + delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + removeMember: authedProcedure.input(ZRemoveMemberInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.removeMember) { + UNSTABLE_HANDLER_CACHE.removeMember = await import("./removeMember.handler").then( + (mod) => mod.removeMemberHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.removeMember) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.removeMember({ + ctx, + input, + }); + }), + + inviteMember: authedProcedure.input(ZInviteMemberInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.inviteMember) { + UNSTABLE_HANDLER_CACHE.inviteMember = await import("./inviteMember.handler").then( + (mod) => mod.inviteMemberHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.inviteMember) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.inviteMember({ + ctx, + input, + }); + }), + + acceptOrLeave: authedProcedure.input(ZAcceptOrLeaveInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.acceptOrLeave) { + UNSTABLE_HANDLER_CACHE.acceptOrLeave = await import("./acceptOrLeave.handler").then( + (mod) => mod.acceptOrLeaveHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.acceptOrLeave) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.acceptOrLeave({ + ctx, + input, + }); + }), + + changeMemberRole: authedProcedure.input(ZChangeMemberRoleInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.changeMemberRole) { + UNSTABLE_HANDLER_CACHE.changeMemberRole = await import("./changeMemberRole.handler").then( + (mod) => mod.changeMemberRoleHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.changeMemberRole) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.changeMemberRole({ + ctx, + input, + }); + }), + + getMemberAvailability: authedProcedure + .input(ZGetMemberAvailabilityInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getMemberAvailability) { + UNSTABLE_HANDLER_CACHE.getMemberAvailability = await import("./getMemberAvailability.handler").then( + (mod) => mod.getMemberAvailabilityHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getMemberAvailability) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getMemberAvailability({ + ctx, + input, + }); + }), + + getMembershipbyUser: authedProcedure + .input(ZGetMembershipbyUserInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getMembershipbyUser) { + UNSTABLE_HANDLER_CACHE.getMembershipbyUser = await import("./getMembershipbyUser.handler").then( + (mod) => mod.getMembershipbyUserHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getMembershipbyUser) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getMembershipbyUser({ + ctx, + input, + }); + }), + + updateMembership: authedProcedure.input(ZUpdateMembershipInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.updateMembership) { + UNSTABLE_HANDLER_CACHE.updateMembership = await import("./updateMembership.handler").then( + (mod) => mod.updateMembershipHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.updateMembership) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.updateMembership({ + ctx, + input, + }); + }), + + publish: authedProcedure.input(ZPublishInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.publish) { + UNSTABLE_HANDLER_CACHE.publish = await import("./publish.handler").then((mod) => mod.publishHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.publish) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.publish({ + ctx, + input, + }); + }), + + /** This is a temporal endpoint so we can progressively upgrade teams to the new billing system. */ + getUpgradeable: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getUpgradeable) { + UNSTABLE_HANDLER_CACHE.getUpgradeable = await import("./getUpgradeable.handler").then( + (mod) => mod.getUpgradeableHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getUpgradeable) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getUpgradeable({ + ctx, + }); + }), + + listMembers: authedProcedure.input(ZListMembersInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.listMembers) { + UNSTABLE_HANDLER_CACHE.listMembers = await import("./listMembers.handler").then( + (mod) => mod.listMembersHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.listMembers) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.listMembers({ + ctx, + input, + }); + }), + + hasTeamPlan: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.hasTeamPlan) { + UNSTABLE_HANDLER_CACHE.hasTeamPlan = await import("./hasTeamPlan.handler").then( + (mod) => mod.hasTeamPlanHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.hasTeamPlan) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.hasTeamPlan({ + ctx, + }); + }), + + listInvites: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.listInvites) { + UNSTABLE_HANDLER_CACHE.listInvites = await import("./listInvites.handler").then( + (mod) => mod.listInvitesHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.listInvites) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.listInvites({ + ctx, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts new file mode 100644 index 0000000000..a2b54bcffa --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts @@ -0,0 +1,51 @@ +import { MembershipRole } from "@prisma/client"; + +import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema"; + +type AcceptOrLeaveOptions = { + ctx: { + user: NonNullable; + }; + input: TAcceptOrLeaveInputSchema; +}; + +export const acceptOrLeaveHandler = async ({ ctx, input }: AcceptOrLeaveOptions) => { + if (input.accept) { + const membership = await prisma.membership.update({ + where: { + userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, + }, + data: { + accepted: true, + }, + include: { + team: true, + }, + }); + + closeComUpsertTeamUser(membership.team, ctx.user, membership.role); + } else { + try { + //get team owner so we can alter their subscription seat count + const teamOwner = await prisma.membership.findFirst({ + where: { teamId: input.teamId, role: MembershipRole.OWNER }, + include: { team: true }, + }); + + const membership = await prisma.membership.delete({ + where: { + userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, + }, + }); + + // Sync Services: Close.com + if (teamOwner) closeComUpsertTeamUser(teamOwner.team, ctx.user, membership.role); + } catch (e) { + console.log(e); + } + } +}; diff --git a/packages/trpc/server/routers/viewer/teams/acceptOrLeave.schema.ts b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.schema.ts new file mode 100644 index 0000000000..546466f9ad --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZAcceptOrLeaveInputSchema = z.object({ + teamId: z.number(), + accept: z.boolean(), +}); + +export type TAcceptOrLeaveInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts b/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts new file mode 100644 index 0000000000..bb91f2da4f --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts @@ -0,0 +1,74 @@ +import { MembershipRole } from "@prisma/client"; + +import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams"; +import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TChangeMemberRoleInputSchema } from "./changeMemberRole.schema"; + +type ChangeMemberRoleOptions = { + ctx: { + user: NonNullable; + }; + input: TChangeMemberRoleInputSchema; +}; + +export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOptions) => { + if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); + // Only owners can award owner role. + if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId))) + throw new TRPCError({ code: "UNAUTHORIZED" }); + const memberships = await prisma.membership.findMany({ + where: { + teamId: input.teamId, + }, + }); + + const targetMembership = memberships.find((m) => m.userId === input.memberId); + const myMembership = memberships.find((m) => m.userId === ctx.user.id); + const teamHasMoreThanOneOwner = memberships.some((m) => m.role === MembershipRole.OWNER); + + if (myMembership?.role === MembershipRole.ADMIN && targetMembership?.role === MembershipRole.OWNER) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can not change the role of an owner if you are an admin.", + }); + } + + if (!teamHasMoreThanOneOwner) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can not change the role of the only owner of a team.", + }); + } + + if ( + myMembership?.role === MembershipRole.ADMIN && + input.memberId === ctx.user.id && + input.role !== MembershipRole.MEMBER + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can not change yourself to a higher role.", + }); + } + + const membership = await prisma.membership.update({ + where: { + userId_teamId: { userId: input.memberId, teamId: input.teamId }, + }, + data: { + role: input.role, + }, + include: { + team: true, + user: true, + }, + }); + + // Sync Services: Close.com + closeComUpsertTeamUser(membership.team, membership.user, membership.role); +}; diff --git a/packages/trpc/server/routers/viewer/teams/changeMemberRole.schema.ts b/packages/trpc/server/routers/viewer/teams/changeMemberRole.schema.ts new file mode 100644 index 0000000000..17ea122d67 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/changeMemberRole.schema.ts @@ -0,0 +1,10 @@ +import { MembershipRole } from "@prisma/client"; +import { z } from "zod"; + +export const ZChangeMemberRoleInputSchema = z.object({ + teamId: z.number(), + memberId: z.number(), + role: z.nativeEnum(MembershipRole), +}); + +export type TChangeMemberRoleInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts new file mode 100644 index 0000000000..7f4ae162e2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -0,0 +1,71 @@ +import { MembershipRole } from "@prisma/client"; + +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TCreateInputSchema } from "./create.schema"; + +type CreateOptions = { + ctx: { + user: NonNullable; + }; + input: TCreateInputSchema; +}; + +export const createHandler = async ({ ctx, input }: CreateOptions) => { + const { slug, name, logo } = input; + + const slugCollisions = await prisma.team.findFirst({ + where: { + slug: slug, + }, + }); + + if (slugCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "team_url_taken" }); + + // Ensure that the user is not duplicating a requested team + const duplicatedRequest = await prisma.team.findFirst({ + where: { + members: { + some: { + userId: ctx.user.id, + }, + }, + metadata: { + path: ["requestedSlug"], + equals: slug, + }, + }, + }); + + if (duplicatedRequest) { + return duplicatedRequest; + } + + const createTeam = await prisma.team.create({ + data: { + name, + logo, + members: { + create: { + userId: ctx.user.id, + role: MembershipRole.OWNER, + accepted: true, + }, + }, + metadata: { + requestedSlug: slug, + }, + ...(!IS_TEAM_BILLING_ENABLED && { slug }), + }, + }); + + // Sync Services: Close.com + closeComUpsertTeamUser(createTeam, ctx.user, MembershipRole.OWNER); + + return createTeam; +}; diff --git a/packages/trpc/server/routers/viewer/teams/create.schema.ts b/packages/trpc/server/routers/viewer/teams/create.schema.ts new file mode 100644 index 0000000000..846228be2b --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/create.schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +import slugify from "@calcom/lib/slugify"; + +export const ZCreateInputSchema = z.object({ + name: z.string(), + slug: z.string().transform((val) => slugify(val.trim())), + logo: z + .string() + .optional() + .nullable() + .transform((v) => v || null), +}); + +export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/delete.handler.ts b/packages/trpc/server/routers/viewer/teams/delete.handler.ts new file mode 100644 index 0000000000..5bde167011 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/delete.handler.ts @@ -0,0 +1,39 @@ +import { cancelTeamSubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { isTeamOwner } from "@calcom/lib/server/queries/teams"; +import { closeComDeleteTeam } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { + if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); + + if (IS_TEAM_BILLING_ENABLED) await cancelTeamSubscriptionFromStripe(input.teamId); + + // delete all memberships + await prisma.membership.deleteMany({ + where: { + teamId: input.teamId, + }, + }); + + const deletedTeam = await prisma.team.delete({ + where: { + id: input.teamId, + }, + }); + + // Sync Services: Close.cm + closeComDeleteTeam(deletedTeam); +}; diff --git a/packages/trpc/server/routers/viewer/teams/delete.schema.ts b/packages/trpc/server/routers/viewer/teams/delete.schema.ts new file mode 100644 index 0000000000..01556322c6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + teamId: z.number(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/get.handler.ts b/packages/trpc/server/routers/viewer/teams/get.handler.ts new file mode 100644 index 0000000000..05f53253a0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/get.handler.ts @@ -0,0 +1,35 @@ +import type { MembershipRole } from "@prisma/client"; + +import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; +import { getTeamWithMembers } from "@calcom/lib/server/queries/teams"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TGetInputSchema } from "./get.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TGetInputSchema; +}; + +export const getHandler = async ({ ctx, input }: GetOptions) => { + const team = await getTeamWithMembers(input.teamId, undefined, ctx.user.id); + + if (!team) { + throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); + } + + const membership = team?.members.find((membership) => membership.id === ctx.user.id); + + return { + ...team, + safeBio: markdownToSafeHTML(team.bio), + membership: { + role: membership?.role as MembershipRole, + accepted: membership?.accepted, + }, + }; +}; diff --git a/packages/trpc/server/routers/viewer/teams/get.schema.ts b/packages/trpc/server/routers/viewer/teams/get.schema.ts new file mode 100644 index 0000000000..b1520391d0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/get.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetInputSchema = z.object({ + teamId: z.number(), +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts new file mode 100644 index 0000000000..97b706c599 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts @@ -0,0 +1,48 @@ +import { getUserAvailability } from "@calcom/core/getUserAvailability"; +import { isTeamMember } from "@calcom/lib/server/queries/teams"; +import { availabilityUserSelect } from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TGetMemberAvailabilityInputSchema } from "./getMemberAvailability.schema"; + +type GetMemberAvailabilityOptions = { + ctx: { + user: NonNullable; + }; + input: TGetMemberAvailabilityInputSchema; +}; + +export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvailabilityOptions) => { + const team = await isTeamMember(ctx.user?.id, input.teamId); + if (!team) throw new TRPCError({ code: "UNAUTHORIZED" }); + + // verify member is in team + const members = await prisma.membership.findMany({ + where: { teamId: input.teamId }, + include: { + user: { + select: { + credentials: true, // needed for getUserAvailability + ...availabilityUserSelect, + }, + }, + }, + }); + const member = members?.find((m) => m.userId === input.memberId); + if (!member) throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); + if (!member.user.username) + throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" }); + + // get availability for this member + return await getUserAvailability( + { + username: member.user.username, + dateFrom: input.dateFrom, + dateTo: input.dateTo, + }, + { user: member.user } + ); +}; diff --git a/packages/trpc/server/routers/viewer/teams/getMemberAvailability.schema.ts b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.schema.ts new file mode 100644 index 0000000000..07b62c1296 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const ZGetMemberAvailabilityInputSchema = z.object({ + teamId: z.number(), + memberId: z.number(), + timezone: z.string(), + dateFrom: z.string(), + dateTo: z.string(), +}); + +export type TGetMemberAvailabilityInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.handler.ts b/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.handler.ts new file mode 100644 index 0000000000..55794cb47f --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.handler.ts @@ -0,0 +1,31 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema"; + +type GetMembershipbyUserOptions = { + ctx: { + user: NonNullable; + }; + input: TGetMembershipbyUserInputSchema; +}; + +export const getMembershipbyUserHandler = async ({ ctx, input }: GetMembershipbyUserOptions) => { + if (ctx.user.id !== input.memberId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You cannot view memberships that are not your own.", + }); + } + + return await prisma.membership.findUnique({ + where: { + userId_teamId: { + userId: input.memberId, + teamId: input.teamId, + }, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.schema.ts b/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.schema.ts new file mode 100644 index 0000000000..5488cf22d7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZGetMembershipbyUserInputSchema = z.object({ + teamId: z.number(), + memberId: z.number(), +}); + +export type TGetMembershipbyUserInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts b/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts new file mode 100644 index 0000000000..22f766a11a --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts @@ -0,0 +1,27 @@ +import { MembershipRole } from "@prisma/client"; + +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { prisma } from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type GetUpgradeableOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const getUpgradeableHandler = async ({ ctx }: GetUpgradeableOptions) => { + if (!IS_TEAM_BILLING_ENABLED) return []; + let { teams } = await prisma.user.findUniqueOrThrow({ + where: { id: ctx.user.id }, + include: { teams: { where: { role: MembershipRole.OWNER }, include: { team: true } } }, + }); + /** We only need to return teams that don't have a `subscriptionId` on their metadata */ + teams = teams.filter((m) => { + const metadata = teamMetadataSchema.safeParse(m.team.metadata); + if (metadata.success && metadata.data?.subscriptionId) return false; + return true; + }); + return teams; +}; diff --git a/packages/trpc/server/routers/viewer/teams/getUpgradeable.schema.ts b/packages/trpc/server/routers/viewer/teams/getUpgradeable.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getUpgradeable.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts new file mode 100644 index 0000000000..b4de3142de --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts @@ -0,0 +1,24 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type HasTeamPlanOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => { + const userId = ctx.user.id; + + const hasTeamPlan = await prisma.membership.findFirst({ + where: { + userId, + team: { + slug: { + not: null, + }, + }, + }, + }); + return { hasTeamPlan: !!hasTeamPlan }; +}; diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.schema.ts b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts new file mode 100644 index 0000000000..1c10fdf2db --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts @@ -0,0 +1,126 @@ +import { MembershipRole, Prisma } from "@prisma/client"; +import { randomBytes } from "crypto"; + +import { sendTeamInviteEmail } from "@calcom/emails"; +import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; +import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TInviteMemberInputSchema } from "./inviteMember.schema"; +import { isEmail } from "./util"; + +type InviteMemberOptions = { + ctx: { + user: NonNullable; + }; + input: TInviteMemberInputSchema; +}; + +export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => { + if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); + if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId))) + throw new TRPCError({ code: "UNAUTHORIZED" }); + + const translation = await getTranslation(input.language ?? "en", "common"); + + const team = await prisma.team.findFirst({ + where: { + id: input.teamId, + }, + }); + + if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); + + const invitee = await prisma.user.findFirst({ + where: { + OR: [{ username: input.usernameOrEmail }, { email: input.usernameOrEmail }], + }, + }); + + if (!invitee) { + // liberal email match + + if (!isEmail(input.usernameOrEmail)) + throw new TRPCError({ + code: "NOT_FOUND", + message: `Invite failed because there is no corresponding user for ${input.usernameOrEmail}`, + }); + + // valid email given, create User and add to team + await prisma.user.create({ + data: { + email: input.usernameOrEmail, + invitedTo: input.teamId, + teams: { + create: { + teamId: input.teamId, + role: input.role as MembershipRole, + }, + }, + }, + }); + + const token: string = randomBytes(32).toString("hex"); + + await prisma.verificationToken.create({ + data: { + identifier: input.usernameOrEmail, + token, + expires: new Date(new Date().setHours(168)), // +1 week + }, + }); + if (ctx?.user?.name && team?.name) { + await sendTeamInviteEmail({ + language: translation, + from: ctx.user.name, + to: input.usernameOrEmail, + teamName: team.name, + joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/teams`, + isCalcomMember: false, + }); + } + } else { + // create provisional membership + try { + await prisma.membership.create({ + data: { + teamId: input.teamId, + userId: invitee.id, + role: input.role as MembershipRole, + }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === "P2002") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This user is a member of this team / has a pending invitation.", + }); + } + } else throw e; + } + + let sendTo = input.usernameOrEmail; + if (!isEmail(input.usernameOrEmail)) { + sendTo = invitee.email; + } + // inform user of membership by email + if (input.sendEmailInvitation && ctx?.user?.name && team?.name) { + await sendTeamInviteEmail({ + language: translation, + from: ctx.user.name, + to: sendTo, + teamName: team.name, + joinLink: WEBAPP_URL + "/settings/teams", + isCalcomMember: true, + }); + } + } + if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); + return input; +}; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember.schema.ts b/packages/trpc/server/routers/viewer/teams/inviteMember.schema.ts new file mode 100644 index 0000000000..bf728981d1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/inviteMember.schema.ts @@ -0,0 +1,12 @@ +import { MembershipRole } from "@prisma/client"; +import { z } from "zod"; + +export const ZInviteMemberInputSchema = z.object({ + teamId: z.number(), + usernameOrEmail: z.string().transform((usernameOrEmail) => usernameOrEmail.toLowerCase()), + role: z.nativeEnum(MembershipRole), + language: z.string(), + sendEmailInvitation: z.boolean(), +}); + +export type TInviteMemberInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/list.handler.ts b/packages/trpc/server/routers/viewer/teams/list.handler.ts new file mode 100644 index 0000000000..a810c32d89 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/list.handler.ts @@ -0,0 +1,27 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listHandler = async ({ ctx }: ListOptions) => { + const memberships = await prisma.membership.findMany({ + where: { + userId: ctx.user.id, + }, + include: { + team: true, + }, + orderBy: { role: "desc" }, + }); + + return memberships.map(({ team, ...membership }) => ({ + role: membership.role, + accepted: membership.accepted, + ...team, + })); +}; diff --git a/packages/trpc/server/routers/viewer/teams/list.schema.ts b/packages/trpc/server/routers/viewer/teams/list.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/list.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/teams/listInvites.handler.ts b/packages/trpc/server/routers/viewer/teams/listInvites.handler.ts new file mode 100644 index 0000000000..d4ffed6217 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/listInvites.handler.ts @@ -0,0 +1,20 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type ListInvitesOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listInvitesHandler = async ({ ctx }: ListInvitesOptions) => { + const userId = ctx.user.id; + return await prisma.membership.findMany({ + where: { + user: { + id: userId, + }, + accepted: false, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/teams/listInvites.schema.ts b/packages/trpc/server/routers/viewer/teams/listInvites.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/listInvites.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts new file mode 100644 index 0000000000..fde52d5a1f --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts @@ -0,0 +1,52 @@ +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TListMembersInputSchema } from "./listMembers.schema"; +import type { PrismaClient } from ".prisma/client"; + +type ListMembersOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TListMembersInputSchema; +}; + +export const listMembersHandler = async ({ ctx, input }: ListMembersOptions) => { + const { prisma } = ctx; + const teams = await prisma.team.findMany({ + where: { + id: { + in: input.teamIds, + }, + members: { + some: { + user: { + id: ctx.user.id, + }, + accepted: true, + }, + }, + }, + select: { + members: { + select: { + user: { + select: { + id: true, + name: true, + username: true, + }, + }, + }, + }, + }, + }); + + type UserMap = Record; + // flattern users to be unique by id + const users = teams + .flatMap((t) => t.members) + .reduce((acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: m.user }), {} as UserMap); + + return Object.values(users); +}; diff --git a/packages/trpc/server/routers/viewer/teams/listMembers.schema.ts b/packages/trpc/server/routers/viewer/teams/listMembers.schema.ts new file mode 100644 index 0000000000..ff83f6c100 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/listMembers.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZListMembersInputSchema = z.object({ + teamIds: z.number().array().optional(), +}); + +export type TListMembersInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/publish.handler.ts b/packages/trpc/server/routers/viewer/teams/publish.handler.ts new file mode 100644 index 0000000000..f2d5d95769 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/publish.handler.ts @@ -0,0 +1,75 @@ +import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; +import { purchaseTeamSubscription } from "@calcom/features/ee/teams/lib/payments"; +import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; +import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TPublishInputSchema } from "./publish.schema"; + +type PublishOptions = { + ctx: { + user: NonNullable; + }; + input: TPublishInputSchema; +}; + +export const publishHandler = async ({ ctx, input }: PublishOptions) => { + if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); + const { teamId: id } = input; + + const prevTeam = await prisma.team.findFirst({ where: { id }, include: { members: true } }); + + if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); + + const metadata = teamMetadataSchema.safeParse(prevTeam.metadata); + + if (!metadata.success) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" }); + + // if payment needed, respond with checkout url + if (IS_TEAM_BILLING_ENABLED) { + const checkoutSession = await purchaseTeamSubscription({ + teamId: prevTeam.id, + seats: prevTeam.members.length, + userId: ctx.user.id, + }); + if (!checkoutSession.url) + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed retrieving a checkout session URL.", + }); + return { url: checkoutSession.url, message: "Payment required to publish team" }; + } + + if (!metadata.data?.requestedSlug) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" }); + } + + const { requestedSlug, ...newMetadata } = metadata.data; + let updatedTeam: Awaited>; + + try { + updatedTeam = await prisma.team.update({ + where: { id }, + data: { + slug: requestedSlug, + metadata: { ...newMetadata }, + }, + }); + } catch (error) { + const { message } = getRequestedSlugError(error, requestedSlug); + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); + } + + // Sync Services: Close.com + closeComUpdateTeam(prevTeam, updatedTeam); + + return { + url: `${WEBAPP_URL}/settings/teams/${updatedTeam.id}/profile`, + message: "Team published successfully", + }; +}; diff --git a/packages/trpc/server/routers/viewer/teams/publish.schema.ts b/packages/trpc/server/routers/viewer/teams/publish.schema.ts new file mode 100644 index 0000000000..4038cb7fc1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/publish.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZPublishInputSchema = z.object({ + teamId: z.number(), +}); + +export type TPublishInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts new file mode 100644 index 0000000000..476fc3e4ce --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts @@ -0,0 +1,51 @@ +import type { PrismaClient } from "@prisma/client"; + +import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams"; +import { closeComDeleteTeamMembership } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TRemoveMemberInputSchema } from "./removeMember.schema"; + +type RemoveMemberOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TRemoveMemberInputSchema; +}; + +export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) => { + const isAdmin = await isTeamAdmin(ctx.user.id, input.teamId); + if (!isAdmin && ctx.user.id !== input.memberId) throw new TRPCError({ code: "UNAUTHORIZED" }); + // Only a team owner can remove another team owner. + if ((await isTeamOwner(input.memberId, input.teamId)) && !(await isTeamOwner(ctx.user.id, input.teamId))) + throw new TRPCError({ code: "UNAUTHORIZED" }); + if (ctx.user.id === input.memberId && isAdmin) + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can not remove yourself from a team you own.", + }); + + const membership = await prisma.membership.delete({ + where: { + userId_teamId: { userId: input.memberId, teamId: input.teamId }, + }, + include: { + user: true, + }, + }); + + // Deleted managed event types from this team from this member + await ctx.prisma.eventType.deleteMany({ + where: { parent: { teamId: input.teamId }, userId: membership.userId }, + }); + + // Sync Services + closeComDeleteTeamMembership(membership.user); + if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); +}; diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts b/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts new file mode 100644 index 0000000000..38ac1843c2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZRemoveMemberInputSchema = z.object({ + teamId: z.number(), + memberId: z.number(), +}); + +export type TRemoveMemberInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/update.handler.ts b/packages/trpc/server/routers/viewer/teams/update.handler.ts new file mode 100644 index 0000000000..2de6432649 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/update.handler.ts @@ -0,0 +1,82 @@ +import type { Prisma } from "@prisma/client"; + +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; +import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TUpdateInputSchema } from "./update.schema"; + +type UpdateOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateInputSchema; +}; + +export const updateHandler = async ({ ctx, input }: UpdateOptions) => { + if (!(await isTeamAdmin(ctx.user?.id, input.id))) throw new TRPCError({ code: "UNAUTHORIZED" }); + + if (input.slug) { + const userConflict = await prisma.team.findMany({ + where: { + slug: input.slug, + }, + }); + if (userConflict.some((t) => t.id !== input.id)) return; + } + + const prevTeam = await prisma.team.findFirst({ + where: { + id: input.id, + }, + }); + + if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); + + const data: Prisma.TeamUpdateArgs["data"] = { + name: input.name, + logo: input.logo, + bio: input.bio, + hideBranding: input.hideBranding, + hideBookATeamMember: input.hideBookATeamMember, + brandColor: input.brandColor, + darkBrandColor: input.darkBrandColor, + theme: input.theme, + }; + + if ( + input.slug && + IS_TEAM_BILLING_ENABLED && + /** If the team doesn't have a slug we can assume that it hasn't been published yet. */ + !prevTeam.slug + ) { + // Save it on the metadata so we can use it later + data.metadata = { + requestedSlug: input.slug, + }; + } else { + data.slug = input.slug; + + // If we save slug, we don't need the requestedSlug anymore + const metadataParse = teamMetadataSchema.safeParse(prevTeam.metadata); + if (metadataParse.success) { + const { requestedSlug: _, ...cleanMetadata } = metadataParse.data || {}; + data.metadata = { + ...cleanMetadata, + }; + } + } + + const updatedTeam = await prisma.team.update({ + where: { id: input.id }, + data, + }); + + // Sync Services: Close.com + if (prevTeam) closeComUpdateTeam(prevTeam, updatedTeam); +}; diff --git a/packages/trpc/server/routers/viewer/teams/update.schema.ts b/packages/trpc/server/routers/viewer/teams/update.schema.ts new file mode 100644 index 0000000000..3a35131461 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/update.schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const ZUpdateInputSchema = z.object({ + id: z.number(), + bio: z.string().optional(), + name: z.string().optional(), + logo: z.string().optional(), + slug: z.string().optional(), + hideBranding: z.boolean().optional(), + hideBookATeamMember: z.boolean().optional(), + brandColor: z.string().optional(), + darkBrandColor: z.string().optional(), + theme: z.string().optional().nullable(), +}); + +export type TUpdateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/updateMembership.handler.ts b/packages/trpc/server/routers/viewer/teams/updateMembership.handler.ts new file mode 100644 index 0000000000..9a1b1cac9d --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/updateMembership.handler.ts @@ -0,0 +1,34 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TUpdateMembershipInputSchema } from "./updateMembership.schema"; + +type UpdateMembershipOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateMembershipInputSchema; +}; + +export const updateMembershipHandler = async ({ ctx, input }: UpdateMembershipOptions) => { + if (ctx.user.id !== input.memberId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You cannot edit memberships that are not your own.", + }); + } + + return await prisma.membership.update({ + where: { + userId_teamId: { + userId: input.memberId, + teamId: input.teamId, + }, + }, + data: { + disableImpersonation: input.disableImpersonation, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/teams/updateMembership.schema.ts b/packages/trpc/server/routers/viewer/teams/updateMembership.schema.ts new file mode 100644 index 0000000000..702aba5fcd --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/updateMembership.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZUpdateMembershipInputSchema = z.object({ + teamId: z.number(), + memberId: z.number(), + disableImpersonation: z.boolean(), +}); + +export type TUpdateMembershipInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/util.ts b/packages/trpc/server/routers/viewer/teams/util.ts new file mode 100644 index 0000000000..4d99c35d1f --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/util.ts @@ -0,0 +1 @@ +export const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str); diff --git a/packages/trpc/server/routers/viewer/webhook.tsx b/packages/trpc/server/routers/viewer/webhook.tsx deleted file mode 100644 index b06e1344a5..0000000000 --- a/packages/trpc/server/routers/viewer/webhook.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import { v4 } from "uuid"; -import { z } from "zod"; - -import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants"; -import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; -import { getErrorFromUnknown } from "@calcom/lib/errors"; -import { getTranslation } from "@calcom/lib/server/i18n"; - -import { TRPCError } from "@trpc/server"; - -import { router, authedProcedure } from "../../trpc"; - -// Common data for all endpoints under webhook -const webhookIdAndEventTypeIdSchema = z.object({ - // Webhook ID - id: z.string().optional(), - // Event type ID - eventTypeId: z.number().optional(), -}); - -const webhookProcedure = authedProcedure - .input(webhookIdAndEventTypeIdSchema.optional()) - .use(async ({ ctx, input, next }) => { - // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input - if (!input) return next(); - const { eventTypeId, id } = input; - - // A webhook is either linked to Event Type or to a user. - if (eventTypeId) { - const team = await ctx.prisma.team.findFirst({ - where: { - eventTypes: { - some: { - id: eventTypeId, - }, - }, - }, - include: { - members: true, - }, - }); - - // Team should be available and the user should be a member of the team - if (!team?.members.some((membership) => membership.userId === ctx.user.id)) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - } else if (id) { - const authorizedHook = await ctx.prisma.webhook.findFirst({ - where: { - id: id, - userId: ctx.user.id, - }, - }); - if (!authorizedHook) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - } - return next(); - }); - -export const webhookRouter = router({ - list: webhookProcedure - .input( - z - .object({ - appId: z.string().optional(), - }) - .optional() - ) - .query(async ({ ctx, input }) => { - const where: Prisma.WebhookWhereInput = { - /* Don't mixup zapier webhooks with normal ones */ - AND: [{ appId: !input?.appId ? null : input.appId }], - }; - if (Array.isArray(where.AND)) { - if (input?.eventTypeId) { - where.AND?.push({ eventTypeId: input.eventTypeId }); - } else { - where.AND?.push({ userId: ctx.user.id }); - } - } - - return await ctx.prisma.webhook.findMany({ - where, - }); - }), - get: webhookProcedure - .input( - z.object({ - webhookId: z.string().optional(), - }) - ) - .query(async ({ ctx, input }) => { - return await ctx.prisma.webhook.findUniqueOrThrow({ - where: { - id: input.webhookId, - }, - select: { - id: true, - subscriberUrl: true, - payloadTemplate: true, - active: true, - eventTriggers: true, - secret: true, - }, - }); - }), - create: webhookProcedure - .input( - z.object({ - subscriberUrl: z.string().url(), - eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(), - active: z.boolean(), - payloadTemplate: z.string().nullable(), - eventTypeId: z.number().optional(), - appId: z.string().optional().nullable(), - secret: z.string().optional().nullable(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (input.eventTypeId) { - return await ctx.prisma.webhook.create({ - data: { - id: v4(), - ...input, - }, - }); - } - - return await ctx.prisma.webhook.create({ - data: { - id: v4(), - userId: ctx.user.id, - ...input, - }, - }); - }), - edit: webhookProcedure - .input( - z.object({ - id: z.string(), - subscriberUrl: z.string().url().optional(), - eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(), - active: z.boolean().optional(), - payloadTemplate: z.string().nullable(), - eventTypeId: z.number().optional(), - appId: z.string().optional().nullable(), - secret: z.string().optional().nullable(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id, ...data } = input; - const webhook = input.eventTypeId - ? await ctx.prisma.webhook.findFirst({ - where: { - eventTypeId: input.eventTypeId, - id, - }, - }) - : await ctx.prisma.webhook.findFirst({ - where: { - userId: ctx.user.id, - id, - }, - }); - if (!webhook) { - // user does not own this webhook - // team event doesn't own this webhook - return null; - } - return await ctx.prisma.webhook.update({ - where: { - id, - }, - data, - }); - }), - delete: webhookProcedure - .input( - z.object({ - id: z.string(), - eventTypeId: z.number().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - input.eventTypeId - ? await ctx.prisma.eventType.update({ - where: { - id: input.eventTypeId, - }, - data: { - webhooks: { - delete: { - id, - }, - }, - }, - }) - : await ctx.prisma.user.update({ - where: { - id: ctx.user.id, - }, - data: { - webhooks: { - delete: { - id, - }, - }, - }, - }); - return { - id, - }; - }), - testTrigger: webhookProcedure - .input( - z.object({ - url: z.string().url(), - type: z.string(), - payloadTemplate: z.string().optional().nullable(), - }) - ) - .mutation(async ({ input }) => { - const { url, type, payloadTemplate = null } = input; - const translation = await getTranslation("en", "common"); - const language = { - locale: "en", - translate: translation, - }; - - const data = { - type: "Test", - title: "Test trigger event", - description: "", - startTime: new Date().toISOString(), - endTime: new Date().toISOString(), - attendees: [ - { - email: "jdoe@example.com", - name: "John Doe", - timeZone: "Europe/London", - language, - }, - ], - organizer: { - name: "Cal", - email: "no-reply@cal.com", - timeZone: "Europe/London", - language, - }, - }; - - try { - const webhook = { subscriberUrl: url, payloadTemplate, appId: null, secret: null }; - return await sendPayload(null, type, new Date().toISOString(), webhook, data); - } catch (_err) { - const error = getErrorFromUnknown(_err); - return { - ok: false, - status: 500, - message: error.message, - }; - } - }), -}); diff --git a/packages/trpc/server/routers/viewer/webhook/_router.tsx b/packages/trpc/server/routers/viewer/webhook/_router.tsx new file mode 100644 index 0000000000..e4f1bc844a --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/_router.tsx @@ -0,0 +1,119 @@ +import { router } from "../../../trpc"; +import { ZCreateInputSchema } from "./create.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZEditInputSchema } from "./edit.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZListInputSchema } from "./list.schema"; +import { ZTestTriggerInputSchema } from "./testTrigger.schema"; +import { webhookProcedure } from "./util"; + +type WebhookRouterHandlerCache = { + list?: typeof import("./list.handler").listHandler; + get?: typeof import("./get.handler").getHandler; + create?: typeof import("./create.handler").createHandler; + edit?: typeof import("./edit.handler").editHandler; + delete?: typeof import("./delete.handler").deleteHandler; + testTrigger?: typeof import("./testTrigger.handler").testTriggerHandler; +}; + +const UNSTABLE_HANDLER_CACHE: WebhookRouterHandlerCache = {}; + +export const webhookRouter = router({ + list: webhookProcedure.input(ZListInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.list) { + UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.list) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.list({ + ctx, + input, + }); + }), + + get: webhookProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + create: webhookProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.create) { + UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.create) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.create({ + ctx, + input, + }); + }), + + edit: webhookProcedure.input(ZEditInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.edit) { + UNSTABLE_HANDLER_CACHE.edit = await import("./edit.handler").then((mod) => mod.editHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.edit) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.edit({ + ctx, + input, + }); + }), + + delete: webhookProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + testTrigger: webhookProcedure.input(ZTestTriggerInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.testTrigger) { + UNSTABLE_HANDLER_CACHE.testTrigger = await import("./testTrigger.handler").then( + (mod) => mod.testTriggerHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.testTrigger) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.testTrigger({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/webhook/create.handler.ts b/packages/trpc/server/routers/viewer/webhook/create.handler.ts new file mode 100644 index 0000000000..498e032218 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/create.handler.ts @@ -0,0 +1,32 @@ +import { v4 } from "uuid"; + +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TCreateInputSchema } from "./create.schema"; + +type CreateOptions = { + ctx: { + user: NonNullable; + }; + input: TCreateInputSchema; +}; + +export const createHandler = async ({ ctx, input }: CreateOptions) => { + if (input.eventTypeId) { + return await prisma.webhook.create({ + data: { + id: v4(), + ...input, + }, + }); + } + + return await prisma.webhook.create({ + data: { + id: v4(), + userId: ctx.user.id, + ...input, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/webhook/create.schema.ts b/packages/trpc/server/routers/viewer/webhook/create.schema.ts new file mode 100644 index 0000000000..2bef2f8ab2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/create.schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants"; + +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const ZCreateInputSchema = webhookIdAndEventTypeIdSchema.extend({ + subscriberUrl: z.string().url(), + eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(), + active: z.boolean(), + payloadTemplate: z.string().nullable(), + eventTypeId: z.number().optional(), + appId: z.string().optional().nullable(), + secret: z.string().optional().nullable(), +}); + +export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/delete.handler.ts b/packages/trpc/server/routers/viewer/webhook/delete.handler.ts new file mode 100644 index 0000000000..601f619a30 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/delete.handler.ts @@ -0,0 +1,44 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { + const { id } = input; + input.eventTypeId + ? await prisma.eventType.update({ + where: { + id: input.eventTypeId, + }, + data: { + webhooks: { + delete: { + id, + }, + }, + }, + }) + : await prisma.user.update({ + where: { + id: ctx.user.id, + }, + data: { + webhooks: { + delete: { + id, + }, + }, + }, + }); + + return { + id, + }; +}; diff --git a/packages/trpc/server/routers/viewer/webhook/delete.schema.ts b/packages/trpc/server/routers/viewer/webhook/delete.schema.ts new file mode 100644 index 0000000000..f2f04a402f --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/delete.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const ZDeleteInputSchema = webhookIdAndEventTypeIdSchema.extend({ + id: z.string(), + eventTypeId: z.number().optional(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/edit.handler.ts b/packages/trpc/server/routers/viewer/webhook/edit.handler.ts new file mode 100644 index 0000000000..9e1f2f447f --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/edit.handler.ts @@ -0,0 +1,39 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TEditInputSchema } from "./edit.schema"; + +type EditOptions = { + ctx: { + user: NonNullable; + }; + input: TEditInputSchema; +}; + +export const editHandler = async ({ ctx, input }: EditOptions) => { + const { id, ...data } = input; + const webhook = input.eventTypeId + ? await prisma.webhook.findFirst({ + where: { + eventTypeId: input.eventTypeId, + id, + }, + }) + : await prisma.webhook.findFirst({ + where: { + userId: ctx.user.id, + id, + }, + }); + if (!webhook) { + // user does not own this webhook + // team event doesn't own this webhook + return null; + } + return await prisma.webhook.update({ + where: { + id, + }, + data, + }); +}; diff --git a/packages/trpc/server/routers/viewer/webhook/edit.schema.ts b/packages/trpc/server/routers/viewer/webhook/edit.schema.ts new file mode 100644 index 0000000000..183df2f74b --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/edit.schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants"; + +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const ZEditInputSchema = webhookIdAndEventTypeIdSchema.extend({ + id: z.string(), + subscriberUrl: z.string().url().optional(), + eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(), + active: z.boolean().optional(), + payloadTemplate: z.string().nullable(), + eventTypeId: z.number().optional(), + appId: z.string().optional().nullable(), + secret: z.string().optional().nullable(), +}); + +export type TEditInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/get.handler.ts b/packages/trpc/server/routers/viewer/webhook/get.handler.ts new file mode 100644 index 0000000000..3050e94eca --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/get.handler.ts @@ -0,0 +1,27 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TGetInputSchema } from "./get.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TGetInputSchema; +}; + +export const getHandler = async ({ ctx: _ctx, input }: GetOptions) => { + return await prisma.webhook.findUniqueOrThrow({ + where: { + id: input.webhookId, + }, + select: { + id: true, + subscriberUrl: true, + payloadTemplate: true, + active: true, + eventTriggers: true, + secret: true, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/webhook/get.schema.ts b/packages/trpc/server/routers/viewer/webhook/get.schema.ts new file mode 100644 index 0000000000..6e97404ca3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/get.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const ZGetInputSchema = webhookIdAndEventTypeIdSchema.extend({ + webhookId: z.string().optional(), +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/list.handler.ts b/packages/trpc/server/routers/viewer/webhook/list.handler.ts new file mode 100644 index 0000000000..3eddf5ffec --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/list.handler.ts @@ -0,0 +1,31 @@ +import type { Prisma } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TListInputSchema } from "./list.schema"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; + input: TListInputSchema; +}; + +export const listHandler = async ({ ctx, input }: ListOptions) => { + const where: Prisma.WebhookWhereInput = { + /* Don't mixup zapier webhooks with normal ones */ + AND: [{ appId: !input?.appId ? null : input.appId }], + }; + if (Array.isArray(where.AND)) { + if (input?.eventTypeId) { + where.AND?.push({ eventTypeId: input.eventTypeId }); + } else { + where.AND?.push({ userId: ctx.user.id }); + } + } + + return await prisma.webhook.findMany({ + where, + }); +}; diff --git a/packages/trpc/server/routers/viewer/webhook/list.schema.ts b/packages/trpc/server/routers/viewer/webhook/list.schema.ts new file mode 100644 index 0000000000..7362b4f566 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/list.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const ZListInputSchema = webhookIdAndEventTypeIdSchema + .extend({ + appId: z.string().optional(), + }) + .optional(); + +export type TListInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts new file mode 100644 index 0000000000..7ee9f4bbb9 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts @@ -0,0 +1,53 @@ +import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; +import { getErrorFromUnknown } from "@calcom/lib/errors"; +import { getTranslation } from "@calcom/lib/server/i18n"; + +import type { TTestTriggerInputSchema } from "./testTrigger.schema"; + +type TestTriggerOptions = { + ctx: Record; + input: TTestTriggerInputSchema; +}; + +export const testTriggerHandler = async ({ ctx: _ctx, input }: TestTriggerOptions) => { + const { url, type, payloadTemplate = null } = input; + const translation = await getTranslation("en", "common"); + const language = { + locale: "en", + translate: translation, + }; + + const data = { + type: "Test", + title: "Test trigger event", + description: "", + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + attendees: [ + { + email: "jdoe@example.com", + name: "John Doe", + timeZone: "Europe/London", + language, + }, + ], + organizer: { + name: "Cal", + email: "no-reply@cal.com", + timeZone: "Europe/London", + language, + }, + }; + + try { + const webhook = { subscriberUrl: url, payloadTemplate, appId: null, secret: null }; + return await sendPayload(null, type, new Date().toISOString(), webhook, data); + } catch (_err) { + const error = getErrorFromUnknown(_err); + return { + ok: false, + status: 500, + message: error.message, + }; + } +}; diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts new file mode 100644 index 0000000000..53f92f7e88 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const ZTestTriggerInputSchema = webhookIdAndEventTypeIdSchema.extend({ + url: z.string().url(), + type: z.string(), + payloadTemplate: z.string().optional().nullable(), +}); + +export type TTestTriggerInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/types.ts b/packages/trpc/server/routers/viewer/webhook/types.ts new file mode 100644 index 0000000000..8a57b596b0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +// Common data for all endpoints under webhook +export const webhookIdAndEventTypeIdSchema = z.object({ + // Webhook ID + id: z.string().optional(), + // Event type ID + eventTypeId: z.number().optional(), +}); diff --git a/packages/trpc/server/routers/viewer/webhook/util.ts b/packages/trpc/server/routers/viewer/webhook/util.ts new file mode 100644 index 0000000000..5e1861dafa --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/util.ts @@ -0,0 +1,50 @@ +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import { authedProcedure } from "../../../trpc"; +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const webhookProcedure = authedProcedure + .input(webhookIdAndEventTypeIdSchema.optional()) + .use(async ({ ctx, input, next }) => { + // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input + if (!input) return next(); + const { eventTypeId, id } = input; + + // A webhook is either linked to Event Type or to a user. + if (eventTypeId) { + const team = await prisma.team.findFirst({ + where: { + eventTypes: { + some: { + id: eventTypeId, + }, + }, + }, + include: { + members: true, + }, + }); + + // Team should be available and the user should be a member of the team + if (!team?.members.some((membership) => membership.userId === ctx.user.id)) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + } else if (id) { + const authorizedHook = await prisma.webhook.findFirst({ + where: { + id: id, + userId: ctx.user.id, + }, + }); + if (!authorizedHook) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + } + return next(); + }); diff --git a/packages/trpc/server/routers/viewer/workflows.tsx b/packages/trpc/server/routers/viewer/workflows.tsx deleted file mode 100644 index 05489f2be8..0000000000 --- a/packages/trpc/server/routers/viewer/workflows.tsx +++ /dev/null @@ -1,1580 +0,0 @@ -import type { Workflow, Prisma } from "@prisma/client"; -import { - WorkflowTemplates, - WorkflowActions, - WorkflowTriggerEvents, - BookingStatus, - WorkflowMethods, - TimeUnit, - MembershipRole, -} from "@prisma/client"; -import { z } from "zod"; - -import emailReminderTemplate from "@calcom/ee/workflows/lib/reminders/templates/emailReminderTemplate"; -import { - SMS_REMINDER_NUMBER_FIELD, - getSmsReminderNumberField, - getSmsReminderNumberSource, -} from "@calcom/features/bookings/lib/getBookingFields"; -import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage"; -import { isSMSAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; -import { - WORKFLOW_TEMPLATES, - WORKFLOW_TRIGGER_EVENTS, - WORKFLOW_ACTIONS, - TIME_UNIT, -} from "@calcom/features/ee/workflows/lib/constants"; -import { getWorkflowActionOptions } from "@calcom/features/ee/workflows/lib/getOptions"; -import { - deleteScheduledEmailReminder, - scheduleEmailReminder, -} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager"; -import { - deleteScheduledSMSReminder, - scheduleSMSReminder, -} from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager"; -import { - verifyPhoneNumber, - sendVerificationCode, -} from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber"; -import { upsertBookingField, removeBookingField } from "@calcom/features/eventtypes/lib/bookingFieldsManager"; -import { IS_SELF_HOSTED, SENDER_ID, CAL_URL } from "@calcom/lib/constants"; -import { SENDER_NAME } from "@calcom/lib/constants"; -import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; -// import { getErrorFromUnknown } from "@calcom/lib/errors"; -import { getTranslation } from "@calcom/lib/server/i18n"; -import type PrismaType from "@calcom/prisma"; -import type { WorkflowStep } from "@calcom/prisma/client"; - -import { TRPCError } from "@trpc/server"; - -import { router, authedProcedure } from "../../trpc"; -import { viewerTeamsRouter } from "./teams"; - -function getSender( - step: Pick & { senderName: string | null | undefined } -) { - return isSMSAction(step.action) ? step.sender || SENDER_ID : step.senderName || SENDER_NAME; -} - -async function isAuthorized( - workflow: Pick | null, - prisma: typeof PrismaType, - currentUserId: number, - readOnly?: boolean -) { - if (!workflow) { - return false; - } - - if (!readOnly) { - const userWorkflow = await prisma.workflow.findFirst({ - where: { - id: workflow.id, - OR: [ - { userId: currentUserId }, - { - team: { - members: { - some: { - userId: currentUserId, - accepted: true, - }, - }, - }, - }, - ], - }, - }); - if (userWorkflow) return true; - } - - const userWorkflow = await prisma.workflow.findFirst({ - where: { - id: workflow.id, - OR: [ - { userId: currentUserId }, - { - team: { - members: { - some: { - userId: currentUserId, - accepted: true, - NOT: { - role: MembershipRole.MEMBER, - }, - }, - }, - }, - }, - ], - }, - }); - - if (userWorkflow) return true; - - return false; -} - -export const workflowsRouter = router({ - list: authedProcedure - .input( - z - .object({ - teamId: z.number().optional(), - userId: z.number().optional(), - }) - .optional() - ) - .query(async ({ ctx, input }) => { - if (input && input.teamId) { - const workflows: WorkflowType[] = await ctx.prisma.workflow.findMany({ - where: { - team: { - id: input.teamId, - members: { - some: { - userId: ctx.user.id, - accepted: true, - }, - }, - }, - }, - include: { - team: { - select: { - id: true, - slug: true, - name: true, - members: true, - }, - }, - activeOn: { - select: { - eventType: { - select: { - id: true, - title: true, - }, - }, - }, - }, - steps: true, - }, - orderBy: { - id: "asc", - }, - }); - const workflowsWithReadOnly = workflows.map((workflow) => { - const readOnly = !!workflow.team?.members?.find( - (member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER - ); - return { ...workflow, readOnly }; - }); - - return { workflows: workflowsWithReadOnly }; - } - - if (input && input.userId) { - const workflows: WorkflowType[] = await ctx.prisma.workflow.findMany({ - where: { - userId: ctx.user.id, - }, - include: { - activeOn: { - select: { - eventType: { - select: { - id: true, - title: true, - }, - }, - }, - }, - steps: true, - team: { - select: { - id: true, - slug: true, - name: true, - members: true, - }, - }, - }, - orderBy: { - id: "asc", - }, - }); - - return { workflows }; - } - - const workflows = await ctx.prisma.workflow.findMany({ - where: { - OR: [ - { userId: ctx.user.id }, - { - team: { - members: { - some: { - userId: ctx.user.id, - accepted: true, - }, - }, - }, - }, - ], - }, - include: { - activeOn: { - select: { - eventType: { - select: { - id: true, - title: true, - }, - }, - }, - }, - steps: true, - team: { - select: { - id: true, - slug: true, - name: true, - members: true, - }, - }, - }, - orderBy: { - id: "asc", - }, - }); - - const workflowsWithReadOnly: WorkflowType[] = workflows.map((workflow) => { - const readOnly = !!workflow.team?.members?.find( - (member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER - ); - - return { readOnly, ...workflow }; - }); - - return { workflows: workflowsWithReadOnly }; - }), - get: authedProcedure - .input( - z.object({ - id: z.number(), - }) - ) - .query(async ({ ctx, input }) => { - const workflow = await ctx.prisma.workflow.findFirst({ - where: { - id: input.id, - }, - select: { - id: true, - name: true, - userId: true, - teamId: true, - team: { - select: { - id: true, - slug: true, - members: true, - }, - }, - time: true, - timeUnit: true, - activeOn: { - select: { - eventType: true, - }, - }, - trigger: true, - steps: { - orderBy: { - stepNumber: "asc", - }, - }, - }, - }); - - const isUserAuthorized = await isAuthorized(workflow, ctx.prisma, ctx.user.id); - - if (!isUserAuthorized) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - - return workflow; - }), - create: authedProcedure - .input( - z.object({ - teamId: z.number().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { teamId } = input; - - const userId = ctx.user.id; - - if (teamId) { - const team = await ctx.prisma.team.findFirst({ - where: { - id: teamId, - members: { - some: { - userId: ctx.user.id, - accepted: true, - NOT: { - role: MembershipRole.MEMBER, - }, - }, - }, - }, - }); - - if (!team) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - } - - try { - const workflow: Workflow = await ctx.prisma.workflow.create({ - data: { - name: "", - trigger: WorkflowTriggerEvents.BEFORE_EVENT, - time: 24, - timeUnit: TimeUnit.HOUR, - userId, - teamId, - }, - }); - - await ctx.prisma.workflowStep.create({ - data: { - stepNumber: 1, - action: WorkflowActions.EMAIL_ATTENDEE, - template: WorkflowTemplates.REMINDER, - reminderBody: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailBody, - emailSubject: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailSubject, - workflowId: workflow.id, - sender: SENDER_NAME, - numberVerificationPending: false, - }, - }); - return { workflow }; - } catch (e) { - throw e; - } - }), - delete: authedProcedure - .input( - z.object({ - id: z.number(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - - const workflowToDelete = await ctx.prisma.workflow.findFirst({ - where: { - id, - }, - include: { - activeOn: true, - }, - }); - - const isUserAuthorized = await isAuthorized(workflowToDelete, ctx.prisma, ctx.user.id, true); - - if (!isUserAuthorized || !workflowToDelete) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const scheduledReminders = await ctx.prisma.workflowReminder.findMany({ - where: { - workflowStep: { - workflowId: id, - }, - scheduled: true, - NOT: { - referenceId: null, - }, - }, - }); - - //cancel workflow reminders of deleted workflow - scheduledReminders.forEach((reminder) => { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.id, reminder.referenceId); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.id, reminder.referenceId); - } - }); - - for (const activeOn of workflowToDelete.activeOn) { - await removeSmsReminderFieldForBooking({ workflowId: id, eventTypeId: activeOn.eventTypeId }); - } - - await ctx.prisma.workflow.deleteMany({ - where: { - id, - }, - }); - - return { - id, - }; - }), - update: authedProcedure - .input( - z.object({ - id: z.number(), - name: z.string(), - activeOn: z.number().array(), - steps: z - .object({ - id: z.number(), - stepNumber: z.number(), - action: z.enum(WORKFLOW_ACTIONS), - workflowId: z.number(), - sendTo: z.string().optional().nullable(), - reminderBody: z.string().optional().nullable(), - emailSubject: z.string().optional().nullable(), - template: z.enum(WORKFLOW_TEMPLATES), - numberRequired: z.boolean().nullable(), - sender: z.string().optional().nullable(), - senderName: z.string().optional().nullable(), - }) - .array(), - trigger: z.enum(WORKFLOW_TRIGGER_EVENTS), - time: z.number().nullable(), - timeUnit: z.enum(TIME_UNIT).nullable(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user } = ctx; - const { id, name, activeOn, steps, trigger, time, timeUnit } = input; - - const userWorkflow = await ctx.prisma.workflow.findUnique({ - where: { - id, - }, - select: { - id: true, - userId: true, - teamId: true, - user: { - select: { - teams: true, - }, - }, - steps: true, - activeOn: true, - }, - }); - - const isUserAuthorized = await isAuthorized(userWorkflow, ctx.prisma, ctx.user.id, true); - - if (!isUserAuthorized || !userWorkflow) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - if (steps.find((step) => step.workflowId != id)) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const oldActiveOnEventTypes = await ctx.prisma.workflowsOnEventTypes.findMany({ - where: { - workflowId: id, - }, - select: { - eventTypeId: true, - }, - }); - - const newActiveEventTypes = activeOn.filter((eventType) => { - if ( - !oldActiveOnEventTypes || - !oldActiveOnEventTypes - .map((oldEventType) => { - return oldEventType.eventTypeId; - }) - .includes(eventType) - ) { - return eventType; - } - }); - - //check if new event types belong to user or team - for (const newEventTypeId of newActiveEventTypes) { - const newEventType = await ctx.prisma.eventType.findFirst({ - where: { - id: newEventTypeId, - }, - include: { - users: true, - team: { - include: { - members: true, - }, - }, - }, - }); - - if (newEventType) { - if (userWorkflow.teamId && userWorkflow.teamId !== newEventType.teamId) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - if ( - !userWorkflow.teamId && - userWorkflow.userId && - newEventType.userId !== userWorkflow.userId && - !newEventType?.users.find((eventTypeUser) => eventTypeUser.id === userWorkflow.userId) - ) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - } - } - - //remove all scheduled Email and SMS reminders for eventTypes that are not active any more - const removedEventTypes = oldActiveOnEventTypes - .map((eventType) => { - return eventType.eventTypeId; - }) - .filter((eventType) => { - if (!activeOn.includes(eventType)) { - return eventType; - } - }); - - const remindersToDeletePromise: Prisma.PrismaPromise< - { - id: number; - referenceId: string | null; - method: string; - scheduled: boolean; - }[] - >[] = []; - - removedEventTypes.forEach((eventTypeId) => { - const reminderToDelete = ctx.prisma.workflowReminder.findMany({ - where: { - booking: { - eventTypeId: eventTypeId, - userId: ctx.user.id, - }, - workflowStepId: { - in: userWorkflow.steps.map((step) => { - return step.id; - }), - }, - }, - select: { - id: true, - referenceId: true, - method: true, - scheduled: true, - }, - }); - - remindersToDeletePromise.push(reminderToDelete); - }); - - const remindersToDelete = await Promise.all(remindersToDeletePromise); - - //cancel workflow reminders for all bookings from event types that got disabled - remindersToDelete.flat().forEach((reminder) => { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.id, reminder.referenceId); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.id, reminder.referenceId); - } - }); - - //update active on & reminders for new eventTypes - await ctx.prisma.workflowsOnEventTypes.deleteMany({ - where: { - workflowId: id, - }, - }); - - let newEventTypes: number[] = []; - if (activeOn.length) { - if (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) { - newEventTypes = newActiveEventTypes; - } - if (newEventTypes.length > 0) { - //create reminders for all bookings with newEventTypes - const bookingsForReminders = await ctx.prisma.booking.findMany({ - where: { - eventTypeId: { in: newEventTypes }, - status: BookingStatus.ACCEPTED, - startTime: { - gte: new Date(), - }, - }, - include: { - attendees: true, - eventType: true, - user: true, - }, - }); - - steps.forEach(async (step) => { - if (step.action !== WorkflowActions.SMS_ATTENDEE) { - //as we do not have attendees phone number (user is notified about that when setting this action) - bookingsForReminders.forEach(async (booking) => { - const bookingInfo = { - uid: booking.uid, - attendees: booking.attendees.map((attendee) => { - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { locale: attendee.locale || "" }, - }; - }), - organizer: booking.user - ? { - language: { locale: booking.user.locale || "" }, - name: booking.user.name || "", - email: booking.user.email, - timeZone: booking.user.timeZone, - } - : { name: "", email: "", timeZone: "", language: { locale: "" } }, - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - title: booking.title, - language: { locale: booking?.user?.locale || "" }, - eventType: { - slug: booking.eventType?.slug, - }, - }; - if ( - step.action === WorkflowActions.EMAIL_HOST || - step.action === WorkflowActions.EMAIL_ATTENDEE /*|| - step.action === WorkflowActions.EMAIL_ADDRESS*/ - ) { - let sendTo = ""; - - switch (step.action) { - case WorkflowActions.EMAIL_HOST: - sendTo = bookingInfo.organizer?.email; - break; - case WorkflowActions.EMAIL_ATTENDEE: - sendTo = bookingInfo.attendees[0].email; - break; - /*case WorkflowActions.EMAIL_ADDRESS: - sendTo = step.sendTo || "";*/ - } - - await scheduleEmailReminder( - bookingInfo, - trigger, - step.action, - { - time, - timeUnit, - }, - sendTo, - step.emailSubject || "", - step.reminderBody || "", - step.id, - step.template, - step.senderName || SENDER_NAME - ); - } else if (step.action === WorkflowActions.SMS_NUMBER) { - await scheduleSMSReminder( - bookingInfo, - step.sendTo || "", - trigger, - step.action, - { - time, - timeUnit, - }, - step.reminderBody || "", - step.id, - step.template, - step.sender || SENDER_ID, - user.id, - userWorkflow.teamId - ); - } - }); - } - }); - } - //create all workflow - eventtypes relationships - activeOn.forEach(async (eventTypeId) => { - await ctx.prisma.workflowsOnEventTypes.createMany({ - data: { - workflowId: id, - eventTypeId, - }, - }); - }); - } - - userWorkflow.steps.map(async (oldStep) => { - const newStep = steps.filter((s) => s.id === oldStep.id)[0]; - const remindersFromStep = await ctx.prisma.workflowReminder.findMany({ - where: { - workflowStepId: oldStep.id, - }, - include: { - booking: true, - }, - }); - - //step was deleted - if (!newStep) { - // cancel all workflow reminders from deleted steps - if (remindersFromStep.length > 0) { - remindersFromStep.forEach((reminder) => { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.id, reminder.referenceId); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.id, reminder.referenceId); - } - }); - } - await ctx.prisma.workflowStep.delete({ - where: { - id: oldStep.id, - }, - }); - - //step was edited - } else if (JSON.stringify(oldStep) !== JSON.stringify(newStep)) { - if ( - !userWorkflow.teamId && - !userWorkflow.user?.teams.length && - !isSMSAction(oldStep.action) && - isSMSAction(newStep.action) - ) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - await ctx.prisma.workflowStep.update({ - where: { - id: oldStep.id, - }, - data: { - action: newStep.action, - sendTo: - newStep.action === WorkflowActions.SMS_NUMBER /*|| - newStep.action === WorkflowActions.EMAIL_ADDRESS*/ - ? newStep.sendTo - : null, - stepNumber: newStep.stepNumber, - workflowId: newStep.workflowId, - reminderBody: newStep.reminderBody, - emailSubject: newStep.emailSubject, - template: newStep.template, - numberRequired: newStep.numberRequired, - sender: getSender({ - action: newStep.action, - sender: newStep.sender || null, - senderName: newStep.senderName, - }), - numberVerificationPending: false, - }, - }); - //cancel all reminders of step and create new ones (not for newEventTypes) - const remindersToUpdate = remindersFromStep.filter((reminder) => { - if (reminder.booking?.eventTypeId && !newEventTypes.includes(reminder.booking?.eventTypeId)) { - return reminder; - } - }); - - //cancel all workflow reminders from steps that were edited - remindersToUpdate.forEach(async (reminder) => { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.id, reminder.referenceId); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.id, reminder.referenceId); - } - }); - const eventTypesToUpdateReminders = activeOn.filter((eventTypeId) => { - if (!newEventTypes.includes(eventTypeId)) { - return eventTypeId; - } - }); - if ( - eventTypesToUpdateReminders && - (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) - ) { - const bookingsOfEventTypes = await ctx.prisma.booking.findMany({ - where: { - eventTypeId: { - in: eventTypesToUpdateReminders, - }, - status: BookingStatus.ACCEPTED, - startTime: { - gte: new Date(), - }, - }, - include: { - attendees: true, - eventType: true, - user: true, - }, - }); - bookingsOfEventTypes.forEach(async (booking) => { - const bookingInfo = { - uid: booking.uid, - attendees: booking.attendees.map((attendee) => { - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { locale: attendee.locale || "" }, - }; - }), - organizer: booking.user - ? { - language: { locale: booking.user.locale || "" }, - name: booking.user.name || "", - email: booking.user.email, - timeZone: booking.user.timeZone, - } - : { name: "", email: "", timeZone: "", language: { locale: "" } }, - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - title: booking.title, - language: { locale: booking?.user?.locale || "" }, - eventType: { - slug: booking.eventType?.slug, - }, - }; - if ( - newStep.action === WorkflowActions.EMAIL_HOST || - newStep.action === WorkflowActions.EMAIL_ATTENDEE /*|| - newStep.action === WorkflowActions.EMAIL_ADDRESS*/ - ) { - let sendTo = ""; - - switch (newStep.action) { - case WorkflowActions.EMAIL_HOST: - sendTo = bookingInfo.organizer?.email; - break; - case WorkflowActions.EMAIL_ATTENDEE: - sendTo = bookingInfo.attendees[0].email; - break; - /*case WorkflowActions.EMAIL_ADDRESS: - sendTo = newStep.sendTo || "";*/ - } - - await scheduleEmailReminder( - bookingInfo, - trigger, - newStep.action, - { - time, - timeUnit, - }, - sendTo, - newStep.emailSubject || "", - newStep.reminderBody || "", - newStep.id, - newStep.template, - newStep.senderName || SENDER_NAME - ); - } else if (newStep.action === WorkflowActions.SMS_NUMBER) { - await scheduleSMSReminder( - bookingInfo, - newStep.sendTo || "", - trigger, - newStep.action, - { - time, - timeUnit, - }, - newStep.reminderBody || "", - newStep.id || 0, - newStep.template, - newStep.sender || SENDER_ID, - user.id, - userWorkflow.teamId - ); - } - }); - } - } - }); - //added steps - const addedSteps = steps.map((s) => { - if (s.id <= 0) { - if (!userWorkflow.user?.teams.length && isSMSAction(s.action)) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - const { id: _stepId, ...stepToAdd } = s; - return stepToAdd; - } - }); - - if (addedSteps) { - const eventTypesToCreateReminders = activeOn.map((activeEventType) => { - if (activeEventType && !newEventTypes.includes(activeEventType)) { - return activeEventType; - } - }); - addedSteps.forEach(async (step) => { - if (step) { - const { senderName, ...newStep } = step; - newStep.sender = getSender({ - action: newStep.action, - sender: newStep.sender || null, - senderName: senderName, - }); - const createdStep = await ctx.prisma.workflowStep.create({ - data: { ...newStep, numberVerificationPending: false }, - }); - if ( - (trigger === WorkflowTriggerEvents.BEFORE_EVENT || - trigger === WorkflowTriggerEvents.AFTER_EVENT) && - eventTypesToCreateReminders && - step.action !== WorkflowActions.SMS_ATTENDEE - ) { - const bookingsForReminders = await ctx.prisma.booking.findMany({ - where: { - eventTypeId: { in: eventTypesToCreateReminders as number[] }, - status: BookingStatus.ACCEPTED, - startTime: { - gte: new Date(), - }, - }, - include: { - attendees: true, - eventType: true, - user: true, - }, - }); - bookingsForReminders.forEach(async (booking) => { - const bookingInfo = { - uid: booking.uid, - attendees: booking.attendees.map((attendee) => { - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { locale: attendee.locale || "" }, - }; - }), - organizer: booking.user - ? { - name: booking.user.name || "", - email: booking.user.email, - timeZone: booking.user.timeZone, - language: { locale: booking.user.locale || "" }, - } - : { name: "", email: "", timeZone: "", language: { locale: "" } }, - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - title: booking.title, - language: { locale: booking?.user?.locale || "" }, - eventType: { - slug: booking.eventType?.slug, - }, - }; - - if ( - step.action === WorkflowActions.EMAIL_ATTENDEE || - step.action === WorkflowActions.EMAIL_HOST /*|| - step.action === WorkflowActions.EMAIL_ADDRESS*/ - ) { - let sendTo = ""; - - switch (step.action) { - case WorkflowActions.EMAIL_HOST: - sendTo = bookingInfo.organizer?.email; - break; - case WorkflowActions.EMAIL_ATTENDEE: - sendTo = bookingInfo.attendees[0].email; - break; - /*case WorkflowActions.EMAIL_ADDRESS: - sendTo = step.sendTo || "";*/ - } - - await scheduleEmailReminder( - bookingInfo, - trigger, - step.action, - { - time, - timeUnit, - }, - sendTo, - step.emailSubject || "", - step.reminderBody || "", - createdStep.id, - step.template, - step.senderName || SENDER_NAME - ); - } else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) { - await scheduleSMSReminder( - bookingInfo, - step.sendTo, - trigger, - step.action, - { - time, - timeUnit, - }, - step.reminderBody || "", - createdStep.id, - step.template, - step.sender || SENDER_ID, - user.id, - userWorkflow.teamId - ); - } - }); - } - } - }); - } - - //update trigger, name, time, timeUnit - await ctx.prisma.workflow.update({ - where: { - id, - }, - data: { - name, - trigger, - time, - timeUnit, - }, - }); - - const workflow = await ctx.prisma.workflow.findFirst({ - where: { - id, - }, - include: { - activeOn: { - select: { - eventType: true, - }, - }, - team: { - select: { - id: true, - slug: true, - members: true, - }, - }, - steps: { - orderBy: { - stepNumber: "asc", - }, - }, - }, - }); - - // Remove or add booking field for sms reminder number - const smsReminderNumberNeeded = - activeOn.length && steps.some((step) => step.action === WorkflowActions.SMS_ATTENDEE); - - for (const removedEventType of removedEventTypes) { - await removeSmsReminderFieldForBooking({ - workflowId: id, - eventTypeId: removedEventType, - }); - } - - for (const eventTypeId of activeOn) { - if (smsReminderNumberNeeded) { - await upsertSmsReminderFieldForBooking({ - workflowId: id, - isSmsReminderNumberRequired: steps.some( - (s) => s.action === WorkflowActions.SMS_ATTENDEE && s.numberRequired - ), - eventTypeId, - }); - } else { - await removeSmsReminderFieldForBooking({ workflowId: id, eventTypeId }); - } - } - - return { - workflow, - }; - }), - /* testAction: authedRateLimitedProcedure({ intervalInMs: 10000, limit: 3 }) - .input( - z.object({ - step: z.object({ - id: z.number(), - stepNumber: z.number(), - action: z.enum(WORKFLOW_ACTIONS), - workflowId: z.number(), - sendTo: z.string().optional().nullable(), - reminderBody: z.string().optional().nullable(), - emailSubject: z.string().optional().nullable(), - template: z.enum(WORKFLOW_TEMPLATES), - numberRequired: z.boolean().nullable(), - sender: z.string().optional().nullable(), - }), - emailSubject: z.string(), - reminderBody: z.string(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user } = ctx; - const { step, emailSubject, reminderBody } = input; - const { action, template, sendTo, sender } = step; - - const senderID = sender || SENDER_ID; - - if (action === WorkflowActions.SMS_NUMBER) { - if (!sendTo) throw new TRPCError({ code: "BAD_REQUEST", message: "Missing sendTo" }); - const verifiedNumbers = await ctx.prisma.verifiedNumber.findFirst({ - where: { - userId: ctx.user.id, - phoneNumber: sendTo, - }, - }); - if (!verifiedNumbers) - throw new TRPCError({ code: "UNAUTHORIZED", message: "Phone number is not verified" }); - } - - try { - const userWorkflow = await ctx.prisma.workflow.findUnique({ - where: { - id: step.workflowId, - }, - select: { - userId: true, - steps: true, - }, - }); - - if (!userWorkflow || userWorkflow.userId !== user.id) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - if (isSMSAction(step.action) /*|| step.action === WorkflowActions.EMAIL_ADDRESS*/ /*) { -const hasTeamPlan = (await ctx.prisma.membership.count({ where: { userId: user.id } })) > 0; -if (!hasTeamPlan) { -throw new TRPCError({ code: "UNAUTHORIZED", message: "Team plan needed" }); -} -} - -const booking = await ctx.prisma.booking.findFirst({ -orderBy: { -createdAt: "desc", -}, -where: { -userId: ctx.user.id, -}, -include: { -attendees: true, -user: true, -}, -}); - -let evt: BookingInfo; -if (booking) { -evt = { -uid: booking?.uid, -attendees: -booking?.attendees.map((attendee) => { -return { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone }; -}) || [], -organizer: { -language: { -locale: booking?.user?.locale || "", -}, -name: booking?.user?.name || "", -email: booking?.user?.email || "", -timeZone: booking?.user?.timeZone || "", -}, -startTime: booking?.startTime.toISOString() || "", -endTime: booking?.endTime.toISOString() || "", -title: booking?.title || "", -location: booking?.location || null, -additionalNotes: booking?.description || null, -customInputs: booking?.customInputs, -}; -} else { -//if no booking exists create an example booking -evt = { -attendees: [{ name: "John Doe", email: "john.doe@example.com", timeZone: "Europe/London" }], -organizer: { -language: { -locale: ctx.user.locale, -}, -name: ctx.user.name || "", -email: ctx.user.email, -timeZone: ctx.user.timeZone, -}, -startTime: dayjs().add(10, "hour").toISOString(), -endTime: dayjs().add(11, "hour").toISOString(), -title: "Example Booking", -location: "Office", -additionalNotes: "These are additional notes", -}; -} - -if ( -action === WorkflowActions.EMAIL_ATTENDEE || -action === WorkflowActions.EMAIL_HOST /*|| -action === WorkflowActions.EMAIL_ADDRESS*/ - /*) { - scheduleEmailReminder( - evt, - WorkflowTriggerEvents.NEW_EVENT, - action, - { time: null, timeUnit: null }, - ctx.user.email, - emailSubject, - reminderBody, - 0, - template - ); - return { message: "Notification sent" }; - } else if (action === WorkflowActions.SMS_NUMBER && sendTo) { - scheduleSMSReminder( - evt, - sendTo, - WorkflowTriggerEvents.NEW_EVENT, - action, - { time: null, timeUnit: null }, - reminderBody, - 0, - template, - senderID, - ctx.user.id - ); - return { message: "Notification sent" }; - } - return { - ok: false, - status: 500, - message: "Notification could not be sent", - }; - } catch (_err) { - const error = getErrorFromUnknown(_err); - return { - ok: false, - status: 500, - message: error.message, - }; - } - }), - */ - activateEventType: authedProcedure - .input( - z.object({ - eventTypeId: z.number(), - workflowId: z.number(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { eventTypeId, workflowId } = input; - - // Check that vent type belong to the user or team - const userEventType = await ctx.prisma.eventType.findFirst({ - where: { - id: eventTypeId, - OR: [ - { userId: ctx.user.id }, - { - team: { - members: { - some: { - userId: ctx.user.id, - accepted: true, - NOT: { - role: MembershipRole.MEMBER, - }, - }, - }, - }, - }, - ], - }, - }); - - if (!userEventType) - throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authorized to edit this event type" }); - - // Check that the workflow belongs to the user or team - const eventTypeWorkflow = await ctx.prisma.workflow.findFirst({ - where: { - id: workflowId, - OR: [ - { - userId: ctx.user.id, - }, - { - teamId: userEventType.teamId, - }, - ], - }, - include: { - steps: true, - }, - }); - - if (!eventTypeWorkflow) - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Not authorized to enable/disable this workflow", - }); - - //check if event type is already active - const isActive = await ctx.prisma.workflowsOnEventTypes.findFirst({ - where: { - workflowId, - eventTypeId, - }, - }); - - if (isActive) { - await ctx.prisma.workflowsOnEventTypes.deleteMany({ - where: { - workflowId, - eventTypeId, - }, - }); - - await removeSmsReminderFieldForBooking({ - workflowId, - eventTypeId, - }); - } else { - await ctx.prisma.workflowsOnEventTypes.create({ - data: { - workflowId, - eventTypeId, - }, - }); - - if ( - eventTypeWorkflow.steps.some((step) => { - return step.action === WorkflowActions.SMS_ATTENDEE; - }) - ) { - const isSmsReminderNumberRequired = eventTypeWorkflow.steps.some((step) => { - return step.action === WorkflowActions.SMS_ATTENDEE && step.numberRequired; - }); - await upsertSmsReminderFieldForBooking({ - workflowId, - isSmsReminderNumberRequired, - eventTypeId, - }); - } - } - }), - sendVerificationCode: authedProcedure - .input( - z.object({ - phoneNumber: z.string(), - }) - ) - .mutation(async ({ input }) => { - const { phoneNumber } = input; - return sendVerificationCode(phoneNumber); - }), - verifyPhoneNumber: authedProcedure - .input( - z.object({ - phoneNumber: z.string(), - code: z.string(), - teamId: z.number().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { phoneNumber, code, teamId } = input; - const { user } = ctx; - const verifyStatus = await verifyPhoneNumber(phoneNumber, code, user.id, teamId); - return verifyStatus; - }), - getVerifiedNumbers: authedProcedure - .input( - z.object({ - teamId: z.number().optional(), - }) - ) - .query(async ({ ctx, input }) => { - const { user } = ctx; - const verifiedNumbers = await ctx.prisma.verifiedNumber.findMany({ - where: { - OR: [{ userId: user.id }, { teamId: input.teamId }], - }, - }); - - return verifiedNumbers; - }), - getWorkflowActionOptions: authedProcedure.query(async ({ ctx }) => { - const { user } = ctx; - - const isCurrentUsernamePremium = user && user.metadata && hasKeyInMetadata(user, "isPremium"); - - let isTeamsPlan = false; - if (!isCurrentUsernamePremium) { - const { hasTeamPlan } = await viewerTeamsRouter.createCaller(ctx).hasTeamPlan(); - isTeamsPlan = !!hasTeamPlan; - } - const t = await getTranslation(ctx.user.locale, "common"); - return getWorkflowActionOptions(t, IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan); - }), - getByViewer: authedProcedure.query(async ({ ctx }) => { - const { prisma } = ctx; - - const user = await prisma.user.findUnique({ - where: { - id: ctx.user.id, - }, - select: { - id: true, - username: true, - avatar: true, - name: true, - startTime: true, - endTime: true, - bufferTime: true, - workflows: { - select: { - id: true, - name: true, - }, - }, - teams: { - where: { - accepted: true, - }, - select: { - role: true, - team: { - select: { - id: true, - name: true, - slug: true, - members: { - select: { - userId: true, - }, - }, - workflows: { - select: { - id: true, - name: true, - }, - }, - }, - }, - }, - }, - }, - }); - - if (!user) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); - } - - const userWorkflows = user.workflows; - - type WorkflowGroup = { - teamId?: number | null; - profile: { - slug: (typeof user)["username"]; - name: (typeof user)["name"]; - image?: string; - }; - metadata?: { - readOnly: boolean; - }; - workflows: typeof userWorkflows; - }; - - let workflowGroups: WorkflowGroup[] = []; - - workflowGroups.push({ - teamId: null, - profile: { - slug: user.username, - name: user.name, - image: user.avatar || undefined, - }, - workflows: userWorkflows, - metadata: { - readOnly: false, - }, - }); - - workflowGroups = ([] as WorkflowGroup[]).concat( - workflowGroups, - user.teams.map((membership) => ({ - teamId: membership.team.id, - profile: { - name: membership.team.name, - slug: "team/" + membership.team.slug, - image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`, - }, - metadata: { - readOnly: membership.role === MembershipRole.MEMBER, - }, - workflows: membership.team.workflows, - })) - ); - - return { - workflowGroups: workflowGroups.filter((groupBy) => !!groupBy.workflows?.length), - profiles: workflowGroups.map((group) => ({ - teamId: group.teamId, - ...group.profile, - ...group.metadata, - })), - }; - }), -}); - -async function upsertSmsReminderFieldForBooking({ - workflowId, - eventTypeId, - isSmsReminderNumberRequired, -}: { - workflowId: number; - isSmsReminderNumberRequired: boolean; - eventTypeId: number; -}) { - await upsertBookingField( - getSmsReminderNumberField(), - getSmsReminderNumberSource({ - workflowId, - isSmsReminderNumberRequired, - }), - eventTypeId - ); -} - -async function removeSmsReminderFieldForBooking({ - workflowId, - eventTypeId, -}: { - workflowId: number; - eventTypeId: number; -}) { - await removeBookingField( - { - name: SMS_REMINDER_NUMBER_FIELD, - }, - { - id: "" + workflowId, - type: "workflow", - }, - eventTypeId - ); -} diff --git a/packages/trpc/server/routers/viewer/workflows/_router.tsx b/packages/trpc/server/routers/viewer/workflows/_router.tsx new file mode 100644 index 0000000000..a273781f8d --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/_router.tsx @@ -0,0 +1,216 @@ +import { authedProcedure, router } from "../../../trpc"; +import { ZActivateEventTypeInputSchema } from "./activateEventType.schema"; +import { ZCreateInputSchema } from "./create.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZGetVerifiedNumbersInputSchema } from "./getVerifiedNumbers.schema"; +import { ZListInputSchema } from "./list.schema"; +import { ZSendVerificationCodeInputSchema } from "./sendVerificationCode.schema"; +import { ZUpdateInputSchema } from "./update.schema"; +import { ZVerifyPhoneNumberInputSchema } from "./verifyPhoneNumber.schema"; + +type WorkflowsRouterHandlerCache = { + list?: typeof import("./list.handler").listHandler; + get?: typeof import("./get.handler").getHandler; + create?: typeof import("./create.handler").createHandler; + delete?: typeof import("./delete.handler").deleteHandler; + update?: typeof import("./update.handler").updateHandler; + activateEventType?: typeof import("./activateEventType.handler").activateEventTypeHandler; + sendVerificationCode?: typeof import("./sendVerificationCode.handler").sendVerificationCodeHandler; + verifyPhoneNumber?: typeof import("./verifyPhoneNumber.handler").verifyPhoneNumberHandler; + getVerifiedNumbers?: typeof import("./getVerifiedNumbers.handler").getVerifiedNumbersHandler; + getWorkflowActionOptions?: typeof import("./getWorkflowActionOptions.handler").getWorkflowActionOptionsHandler; + getByViewer?: typeof import("./getByViewer.handler").getByViewerHandler; +}; + +const UNSTABLE_HANDLER_CACHE: WorkflowsRouterHandlerCache = {}; + +export const workflowsRouter = router({ + list: authedProcedure.input(ZListInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.list) { + UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.list) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.list({ + ctx, + input, + }); + }), + + get: authedProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.create) { + UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.create) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.create({ + ctx, + input, + }); + }), + + delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.update) { + UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.update) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.update({ + ctx, + input, + }); + }), + + activateEventType: authedProcedure.input(ZActivateEventTypeInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.activateEventType) { + UNSTABLE_HANDLER_CACHE.activateEventType = await import("./activateEventType.handler").then( + (mod) => mod.activateEventTypeHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.activateEventType) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.activateEventType({ + ctx, + input, + }); + }), + + sendVerificationCode: authedProcedure + .input(ZSendVerificationCodeInputSchema) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.sendVerificationCode) { + UNSTABLE_HANDLER_CACHE.sendVerificationCode = await import("./sendVerificationCode.handler").then( + (mod) => mod.sendVerificationCodeHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.sendVerificationCode) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.sendVerificationCode({ + ctx, + input, + }); + }), + + verifyPhoneNumber: authedProcedure.input(ZVerifyPhoneNumberInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.verifyPhoneNumber) { + UNSTABLE_HANDLER_CACHE.verifyPhoneNumber = await import("./verifyPhoneNumber.handler").then( + (mod) => mod.verifyPhoneNumberHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.verifyPhoneNumber) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.verifyPhoneNumber({ + ctx, + input, + }); + }), + + getVerifiedNumbers: authedProcedure.input(ZGetVerifiedNumbersInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getVerifiedNumbers) { + UNSTABLE_HANDLER_CACHE.getVerifiedNumbers = await import("./getVerifiedNumbers.handler").then( + (mod) => mod.getVerifiedNumbersHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getVerifiedNumbers) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getVerifiedNumbers({ + ctx, + input, + }); + }), + + getWorkflowActionOptions: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getWorkflowActionOptions) { + UNSTABLE_HANDLER_CACHE.getWorkflowActionOptions = await import( + "./getWorkflowActionOptions.handler" + ).then((mod) => mod.getWorkflowActionOptionsHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getWorkflowActionOptions) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getWorkflowActionOptions({ + ctx, + }); + }), + + getByViewer: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getByViewer) { + UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then( + (mod) => mod.getByViewerHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getByViewer) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getByViewer({ + ctx, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts new file mode 100644 index 0000000000..a952cd42a2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts @@ -0,0 +1,114 @@ +import { MembershipRole, WorkflowActions } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TActivateEventTypeInputSchema } from "./activateEventType.schema"; +import { removeSmsReminderFieldForBooking, upsertSmsReminderFieldForBooking } from "./util"; + +type ActivateEventTypeOptions = { + ctx: { + user: NonNullable; + }; + input: TActivateEventTypeInputSchema; +}; + +export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventTypeOptions) => { + const { eventTypeId, workflowId } = input; + + // Check that vent type belong to the user or team + const userEventType = await prisma.eventType.findFirst({ + where: { + id: eventTypeId, + OR: [ + { userId: ctx.user.id }, + { + team: { + members: { + some: { + userId: ctx.user.id, + accepted: true, + NOT: { + role: MembershipRole.MEMBER, + }, + }, + }, + }, + }, + ], + }, + }); + + if (!userEventType) + throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authorized to edit this event type" }); + + // Check that the workflow belongs to the user or team + const eventTypeWorkflow = await prisma.workflow.findFirst({ + where: { + id: workflowId, + OR: [ + { + userId: ctx.user.id, + }, + { + teamId: userEventType.teamId, + }, + ], + }, + include: { + steps: true, + }, + }); + + if (!eventTypeWorkflow) + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Not authorized to enable/disable this workflow", + }); + + //check if event type is already active + const isActive = await prisma.workflowsOnEventTypes.findFirst({ + where: { + workflowId, + eventTypeId, + }, + }); + + if (isActive) { + await prisma.workflowsOnEventTypes.deleteMany({ + where: { + workflowId, + eventTypeId, + }, + }); + + await removeSmsReminderFieldForBooking({ + workflowId, + eventTypeId, + }); + } else { + await prisma.workflowsOnEventTypes.create({ + data: { + workflowId, + eventTypeId, + }, + }); + + if ( + eventTypeWorkflow.steps.some((step) => { + return step.action === WorkflowActions.SMS_ATTENDEE; + }) + ) { + const isSmsReminderNumberRequired = eventTypeWorkflow.steps.some((step) => { + return step.action === WorkflowActions.SMS_ATTENDEE && step.numberRequired; + }); + await upsertSmsReminderFieldForBooking({ + workflowId, + isSmsReminderNumberRequired, + eventTypeId, + }); + } + } +}; diff --git a/packages/trpc/server/routers/viewer/workflows/activateEventType.schema.ts b/packages/trpc/server/routers/viewer/workflows/activateEventType.schema.ts new file mode 100644 index 0000000000..78446e3b8d --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/activateEventType.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZActivateEventTypeInputSchema = z.object({ + eventTypeId: z.number(), + workflowId: z.number(), +}); + +export type TActivateEventTypeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/create.handler.ts b/packages/trpc/server/routers/viewer/workflows/create.handler.ts new file mode 100644 index 0000000000..9a346b23f7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/create.handler.ts @@ -0,0 +1,84 @@ +import type { Workflow } from "@prisma/client"; +import { + MembershipRole, + TimeUnit, + WorkflowActions, + WorkflowTemplates, + WorkflowTriggerEvents, +} from "@prisma/client"; + +import emailReminderTemplate from "@calcom/ee/workflows/lib/reminders/templates/emailReminderTemplate"; +import { SENDER_NAME } from "@calcom/lib/constants"; +import { prisma } from "@calcom/prisma"; +import type { PrismaClient } from "@calcom/prisma/client"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TCreateInputSchema } from "./create.schema"; + +type CreateOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TCreateInputSchema; +}; + +export const createHandler = async ({ ctx, input }: CreateOptions) => { + const { teamId } = input; + + const userId = ctx.user.id; + + if (teamId) { + const team = await prisma.team.findFirst({ + where: { + id: teamId, + members: { + some: { + userId: ctx.user.id, + accepted: true, + NOT: { + role: MembershipRole.MEMBER, + }, + }, + }, + }, + }); + + if (!team) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + } + + try { + const workflow: Workflow = await prisma.workflow.create({ + data: { + name: "", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 24, + timeUnit: TimeUnit.HOUR, + userId, + teamId, + }, + }); + + await ctx.prisma.workflowStep.create({ + data: { + stepNumber: 1, + action: WorkflowActions.EMAIL_ATTENDEE, + template: WorkflowTemplates.REMINDER, + reminderBody: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailBody, + emailSubject: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailSubject, + workflowId: workflow.id, + sender: SENDER_NAME, + numberVerificationPending: false, + }, + }); + return { workflow }; + } catch (e) { + throw e; + } +}; diff --git a/packages/trpc/server/routers/viewer/workflows/create.schema.ts b/packages/trpc/server/routers/viewer/workflows/create.schema.ts new file mode 100644 index 0000000000..2dbc2bcbfa --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/create.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZCreateInputSchema = z.object({ + teamId: z.number().optional(), +}); + +export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/delete.handler.ts b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts new file mode 100644 index 0000000000..d1dfe978eb --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts @@ -0,0 +1,72 @@ +import { WorkflowMethods } from "@prisma/client"; + +import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager"; +import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TDeleteInputSchema } from "./delete.schema"; +import { isAuthorized, removeSmsReminderFieldForBooking } from "./util"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { + const { id } = input; + + const workflowToDelete = await prisma.workflow.findFirst({ + where: { + id, + }, + include: { + activeOn: true, + }, + }); + + const isUserAuthorized = await isAuthorized(workflowToDelete, prisma, ctx.user.id, true); + + if (!isUserAuthorized || !workflowToDelete) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const scheduledReminders = await prisma.workflowReminder.findMany({ + where: { + workflowStep: { + workflowId: id, + }, + scheduled: true, + NOT: { + referenceId: null, + }, + }, + }); + + //cancel workflow reminders of deleted workflow + scheduledReminders.forEach((reminder) => { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.id, reminder.referenceId); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.id, reminder.referenceId); + } + }); + + for (const activeOn of workflowToDelete.activeOn) { + await removeSmsReminderFieldForBooking({ workflowId: id, eventTypeId: activeOn.eventTypeId }); + } + + await prisma.workflow.deleteMany({ + where: { + id, + }, + }); + + return { + id, + }; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/delete.schema.ts b/packages/trpc/server/routers/viewer/workflows/delete.schema.ts new file mode 100644 index 0000000000..411d5e953b --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + id: z.number(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/get.handler.ts b/packages/trpc/server/routers/viewer/workflows/get.handler.ts new file mode 100644 index 0000000000..e9bdefaa7e --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/get.handler.ts @@ -0,0 +1,58 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TGetInputSchema } from "./get.schema"; +import { isAuthorized } from "./util"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TGetInputSchema; +}; + +export const getHandler = async ({ ctx, input }: GetOptions) => { + const workflow = await prisma.workflow.findFirst({ + where: { + id: input.id, + }, + select: { + id: true, + name: true, + userId: true, + teamId: true, + team: { + select: { + id: true, + slug: true, + members: true, + }, + }, + time: true, + timeUnit: true, + activeOn: { + select: { + eventType: true, + }, + }, + trigger: true, + steps: { + orderBy: { + stepNumber: "asc", + }, + }, + }, + }); + + const isUserAuthorized = await isAuthorized(workflow, prisma, ctx.user.id); + + if (!isUserAuthorized) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + + return workflow; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/get.schema.ts b/packages/trpc/server/routers/viewer/workflows/get.schema.ts new file mode 100644 index 0000000000..d549577697 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/get.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetInputSchema = z.object({ + id: z.number(), +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/workflows/getByViewer.handler.ts new file mode 100644 index 0000000000..0f397e7094 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getByViewer.handler.ts @@ -0,0 +1,121 @@ +import { MembershipRole } from "@prisma/client"; + +import { CAL_URL } from "@calcom/lib/constants"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +type GetByViewerOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => { + const user = await prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + select: { + id: true, + username: true, + avatar: true, + name: true, + startTime: true, + endTime: true, + bufferTime: true, + workflows: { + select: { + id: true, + name: true, + }, + }, + teams: { + where: { + accepted: true, + }, + select: { + role: true, + team: { + select: { + id: true, + name: true, + slug: true, + members: { + select: { + userId: true, + }, + }, + workflows: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!user) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + + const userWorkflows = user.workflows; + + type WorkflowGroup = { + teamId?: number | null; + profile: { + slug: (typeof user)["username"]; + name: (typeof user)["name"]; + image?: string; + }; + metadata?: { + readOnly: boolean; + }; + workflows: typeof userWorkflows; + }; + + let workflowGroups: WorkflowGroup[] = []; + + workflowGroups.push({ + teamId: null, + profile: { + slug: user.username, + name: user.name, + image: user.avatar || undefined, + }, + workflows: userWorkflows, + metadata: { + readOnly: false, + }, + }); + + workflowGroups = ([] as WorkflowGroup[]).concat( + workflowGroups, + user.teams.map((membership) => ({ + teamId: membership.team.id, + profile: { + name: membership.team.name, + slug: "team/" + membership.team.slug, + image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`, + }, + metadata: { + readOnly: membership.role === MembershipRole.MEMBER, + }, + workflows: membership.team.workflows, + })) + ); + + return { + workflowGroups: workflowGroups.filter((groupBy) => !!groupBy.workflows?.length), + profiles: workflowGroups.map((group) => ({ + teamId: group.teamId, + ...group.profile, + ...group.metadata, + })), + }; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/getByViewer.schema.ts b/packages/trpc/server/routers/viewer/workflows/getByViewer.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getByViewer.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.handler.ts b/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.handler.ts new file mode 100644 index 0000000000..406fcde53b --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.handler.ts @@ -0,0 +1,22 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TGetVerifiedNumbersInputSchema } from "./getVerifiedNumbers.schema"; + +type GetVerifiedNumbersOptions = { + ctx: { + user: NonNullable; + }; + input: TGetVerifiedNumbersInputSchema; +}; + +export const getVerifiedNumbersHandler = async ({ ctx, input }: GetVerifiedNumbersOptions) => { + const { user } = ctx; + const verifiedNumbers = await prisma.verifiedNumber.findMany({ + where: { + OR: [{ userId: user.id }, { teamId: input.teamId }], + }, + }); + + return verifiedNumbers; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.schema.ts b/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.schema.ts new file mode 100644 index 0000000000..032231919e --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetVerifiedNumbersInputSchema = z.object({ + teamId: z.number().optional(), +}); + +export type TGetVerifiedNumbersInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts new file mode 100644 index 0000000000..ad12d6ddd6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts @@ -0,0 +1,29 @@ +import { getWorkflowActionOptions } from "@calcom/features/ee/workflows/lib/getOptions"; +import { IS_SELF_HOSTED } from "@calcom/lib/constants"; +import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { hasTeamPlanHandler } from "../teams/hasTeamPlan.handler"; + +type GetWorkflowActionOptionsOptions = { + ctx: { + user: NonNullable & { + locale: string; + }; + }; +}; + +export const getWorkflowActionOptionsHandler = async ({ ctx }: GetWorkflowActionOptionsOptions) => { + const { user } = ctx; + + const isCurrentUsernamePremium = user && user.metadata && hasKeyInMetadata(user, "isPremium"); + + let isTeamsPlan = false; + if (!isCurrentUsernamePremium) { + const { hasTeamPlan } = await hasTeamPlanHandler({ ctx }); + isTeamsPlan = !!hasTeamPlan; + } + const t = await getTranslation(ctx.user.locale, "common"); + return getWorkflowActionOptions(t, IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan); +}; diff --git a/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.schema.ts b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/workflows/list.handler.ts b/packages/trpc/server/routers/viewer/workflows/list.handler.ts new file mode 100644 index 0000000000..8baac7f4a3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/list.handler.ts @@ -0,0 +1,152 @@ +import { MembershipRole } from "@prisma/client"; + +import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage"; +// import dayjs from "@calcom/dayjs"; +// import { getErrorFromUnknown } from "@calcom/lib/errors"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TListInputSchema } from "./list.schema"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; + input: TListInputSchema; +}; + +export const listHandler = async ({ ctx, input }: ListOptions) => { + if (input && input.teamId) { + const workflows: WorkflowType[] = await prisma.workflow.findMany({ + where: { + team: { + id: input.teamId, + members: { + some: { + userId: ctx.user.id, + accepted: true, + }, + }, + }, + }, + include: { + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + }, + }, + }, + }, + steps: true, + }, + orderBy: { + id: "asc", + }, + }); + const workflowsWithReadOnly = workflows.map((workflow) => { + const readOnly = !!workflow.team?.members?.find( + (member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER + ); + return { ...workflow, readOnly }; + }); + + return { workflows: workflowsWithReadOnly }; + } + + if (input && input.userId) { + const workflows: WorkflowType[] = await prisma.workflow.findMany({ + where: { + userId: ctx.user.id, + }, + include: { + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + }, + }, + }, + }, + steps: true, + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + }, + orderBy: { + id: "asc", + }, + }); + + return { workflows }; + } + + const workflows = await prisma.workflow.findMany({ + where: { + OR: [ + { userId: ctx.user.id }, + { + team: { + members: { + some: { + userId: ctx.user.id, + accepted: true, + }, + }, + }, + }, + ], + }, + include: { + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + }, + }, + }, + }, + steps: true, + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + }, + orderBy: { + id: "asc", + }, + }); + + const workflowsWithReadOnly: WorkflowType[] = workflows.map((workflow) => { + const readOnly = !!workflow.team?.members?.find( + (member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER + ); + + return { readOnly, ...workflow }; + }); + + return { workflows: workflowsWithReadOnly }; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/list.schema.ts b/packages/trpc/server/routers/viewer/workflows/list.schema.ts new file mode 100644 index 0000000000..e78497d03d --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/list.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZListInputSchema = z + .object({ + teamId: z.number().optional(), + userId: z.number().optional(), + }) + .optional(); + +export type TListInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.handler.ts b/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.handler.ts new file mode 100644 index 0000000000..b7a96d848b --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.handler.ts @@ -0,0 +1,16 @@ +import { sendVerificationCode } from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TSendVerificationCodeInputSchema } from "./sendVerificationCode.schema"; + +type SendVerificationCodeOptions = { + ctx: { + user: NonNullable; + }; + input: TSendVerificationCodeInputSchema; +}; + +export const sendVerificationCodeHandler = async ({ ctx: _ctx, input }: SendVerificationCodeOptions) => { + const { phoneNumber } = input; + return sendVerificationCode(phoneNumber); +}; diff --git a/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.schema.ts b/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.schema.ts new file mode 100644 index 0000000000..8def3a8b33 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZSendVerificationCodeInputSchema = z.object({ + phoneNumber: z.string(), +}); + +export type TSendVerificationCodeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts new file mode 100644 index 0000000000..cbfc2b9c18 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -0,0 +1,685 @@ +import type { Prisma } from "@prisma/client"; +import { BookingStatus, WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@prisma/client"; + +import { isSMSAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; +import { + deleteScheduledEmailReminder, + scheduleEmailReminder, +} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager"; +import { + deleteScheduledSMSReminder, + scheduleSMSReminder, +} from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager"; +import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants"; +import type { PrismaClient } from "@calcom/prisma/client"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TUpdateInputSchema } from "./update.schema"; +import { + getSender, + isAuthorized, + removeSmsReminderFieldForBooking, + upsertSmsReminderFieldForBooking, +} from "./util"; + +type UpdateOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TUpdateInputSchema; +}; + +export const updateHandler = async ({ ctx, input }: UpdateOptions) => { + const { user } = ctx; + const { id, name, activeOn, steps, trigger, time, timeUnit } = input; + + const userWorkflow = await ctx.prisma.workflow.findUnique({ + where: { + id, + }, + select: { + id: true, + userId: true, + teamId: true, + user: { + select: { + teams: true, + }, + }, + steps: true, + activeOn: true, + }, + }); + + const isUserAuthorized = await isAuthorized(userWorkflow, ctx.prisma, ctx.user.id, true); + + if (!isUserAuthorized || !userWorkflow) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + if (steps.find((step) => step.workflowId != id)) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const oldActiveOnEventTypes = await ctx.prisma.workflowsOnEventTypes.findMany({ + where: { + workflowId: id, + }, + select: { + eventTypeId: true, + }, + }); + + const newActiveEventTypes = activeOn.filter((eventType) => { + if ( + !oldActiveOnEventTypes || + !oldActiveOnEventTypes + .map((oldEventType) => { + return oldEventType.eventTypeId; + }) + .includes(eventType) + ) { + return eventType; + } + }); + + //check if new event types belong to user or team + for (const newEventTypeId of newActiveEventTypes) { + const newEventType = await ctx.prisma.eventType.findFirst({ + where: { + id: newEventTypeId, + }, + include: { + users: true, + team: { + include: { + members: true, + }, + }, + }, + }); + + if (newEventType) { + if (userWorkflow.teamId && userWorkflow.teamId !== newEventType.teamId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + if ( + !userWorkflow.teamId && + userWorkflow.userId && + newEventType.userId !== userWorkflow.userId && + !newEventType?.users.find((eventTypeUser) => eventTypeUser.id === userWorkflow.userId) + ) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + } + + //remove all scheduled Email and SMS reminders for eventTypes that are not active any more + const removedEventTypes = oldActiveOnEventTypes + .map((eventType) => { + return eventType.eventTypeId; + }) + .filter((eventType) => { + if (!activeOn.includes(eventType)) { + return eventType; + } + }); + + const remindersToDeletePromise: Prisma.PrismaPromise< + { + id: number; + referenceId: string | null; + method: string; + scheduled: boolean; + }[] + >[] = []; + + removedEventTypes.forEach((eventTypeId) => { + const reminderToDelete = ctx.prisma.workflowReminder.findMany({ + where: { + booking: { + eventTypeId: eventTypeId, + userId: ctx.user.id, + }, + workflowStepId: { + in: userWorkflow.steps.map((step) => { + return step.id; + }), + }, + }, + select: { + id: true, + referenceId: true, + method: true, + scheduled: true, + }, + }); + + remindersToDeletePromise.push(reminderToDelete); + }); + + const remindersToDelete = await Promise.all(remindersToDeletePromise); + + //cancel workflow reminders for all bookings from event types that got disabled + remindersToDelete.flat().forEach((reminder) => { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.id, reminder.referenceId); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.id, reminder.referenceId); + } + }); + + //update active on & reminders for new eventTypes + await ctx.prisma.workflowsOnEventTypes.deleteMany({ + where: { + workflowId: id, + }, + }); + + let newEventTypes: number[] = []; + if (activeOn.length) { + if (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) { + newEventTypes = newActiveEventTypes; + } + if (newEventTypes.length > 0) { + //create reminders for all bookings with newEventTypes + const bookingsForReminders = await ctx.prisma.booking.findMany({ + where: { + eventTypeId: { in: newEventTypes }, + status: BookingStatus.ACCEPTED, + startTime: { + gte: new Date(), + }, + }, + include: { + attendees: true, + eventType: true, + user: true, + }, + }); + + steps.forEach(async (step) => { + if (step.action !== WorkflowActions.SMS_ATTENDEE) { + //as we do not have attendees phone number (user is notified about that when setting this action) + bookingsForReminders.forEach(async (booking) => { + const bookingInfo = { + uid: booking.uid, + attendees: booking.attendees.map((attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { locale: attendee.locale || "" }, + }; + }), + organizer: booking.user + ? { + language: { locale: booking.user.locale || "" }, + name: booking.user.name || "", + email: booking.user.email, + timeZone: booking.user.timeZone, + } + : { name: "", email: "", timeZone: "", language: { locale: "" } }, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + title: booking.title, + language: { locale: booking?.user?.locale || "" }, + eventType: { + slug: booking.eventType?.slug, + }, + }; + if ( + step.action === WorkflowActions.EMAIL_HOST || + step.action === WorkflowActions.EMAIL_ATTENDEE /*|| + step.action === WorkflowActions.EMAIL_ADDRESS*/ + ) { + let sendTo = ""; + + switch (step.action) { + case WorkflowActions.EMAIL_HOST: + sendTo = bookingInfo.organizer?.email; + break; + case WorkflowActions.EMAIL_ATTENDEE: + sendTo = bookingInfo.attendees[0].email; + break; + /*case WorkflowActions.EMAIL_ADDRESS: + sendTo = step.sendTo || "";*/ + } + + await scheduleEmailReminder( + bookingInfo, + trigger, + step.action, + { + time, + timeUnit, + }, + sendTo, + step.emailSubject || "", + step.reminderBody || "", + step.id, + step.template, + step.senderName || SENDER_NAME + ); + } else if (step.action === WorkflowActions.SMS_NUMBER) { + await scheduleSMSReminder( + bookingInfo, + step.sendTo || "", + trigger, + step.action, + { + time, + timeUnit, + }, + step.reminderBody || "", + step.id, + step.template, + step.sender || SENDER_ID, + user.id, + userWorkflow.teamId + ); + } + }); + } + }); + } + //create all workflow - eventtypes relationships + activeOn.forEach(async (eventTypeId) => { + await ctx.prisma.workflowsOnEventTypes.createMany({ + data: { + workflowId: id, + eventTypeId, + }, + }); + }); + } + + userWorkflow.steps.map(async (oldStep) => { + const newStep = steps.filter((s) => s.id === oldStep.id)[0]; + const remindersFromStep = await ctx.prisma.workflowReminder.findMany({ + where: { + workflowStepId: oldStep.id, + }, + include: { + booking: true, + }, + }); + + //step was deleted + if (!newStep) { + // cancel all workflow reminders from deleted steps + if (remindersFromStep.length > 0) { + remindersFromStep.forEach((reminder) => { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.id, reminder.referenceId); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.id, reminder.referenceId); + } + }); + } + await ctx.prisma.workflowStep.delete({ + where: { + id: oldStep.id, + }, + }); + + //step was edited + } else if (JSON.stringify(oldStep) !== JSON.stringify(newStep)) { + if ( + !userWorkflow.teamId && + !userWorkflow.user?.teams.length && + !isSMSAction(oldStep.action) && + isSMSAction(newStep.action) + ) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + await ctx.prisma.workflowStep.update({ + where: { + id: oldStep.id, + }, + data: { + action: newStep.action, + sendTo: + newStep.action === WorkflowActions.SMS_NUMBER /*|| + newStep.action === WorkflowActions.EMAIL_ADDRESS*/ + ? newStep.sendTo + : null, + stepNumber: newStep.stepNumber, + workflowId: newStep.workflowId, + reminderBody: newStep.reminderBody, + emailSubject: newStep.emailSubject, + template: newStep.template, + numberRequired: newStep.numberRequired, + sender: getSender({ + action: newStep.action, + sender: newStep.sender || null, + senderName: newStep.senderName, + }), + numberVerificationPending: false, + }, + }); + //cancel all reminders of step and create new ones (not for newEventTypes) + const remindersToUpdate = remindersFromStep.filter((reminder) => { + if (reminder.booking?.eventTypeId && !newEventTypes.includes(reminder.booking?.eventTypeId)) { + return reminder; + } + }); + + //cancel all workflow reminders from steps that were edited + remindersToUpdate.forEach(async (reminder) => { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.id, reminder.referenceId); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.id, reminder.referenceId); + } + }); + const eventTypesToUpdateReminders = activeOn.filter((eventTypeId) => { + if (!newEventTypes.includes(eventTypeId)) { + return eventTypeId; + } + }); + if ( + eventTypesToUpdateReminders && + (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) + ) { + const bookingsOfEventTypes = await ctx.prisma.booking.findMany({ + where: { + eventTypeId: { + in: eventTypesToUpdateReminders, + }, + status: BookingStatus.ACCEPTED, + startTime: { + gte: new Date(), + }, + }, + include: { + attendees: true, + eventType: true, + user: true, + }, + }); + bookingsOfEventTypes.forEach(async (booking) => { + const bookingInfo = { + uid: booking.uid, + attendees: booking.attendees.map((attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { locale: attendee.locale || "" }, + }; + }), + organizer: booking.user + ? { + language: { locale: booking.user.locale || "" }, + name: booking.user.name || "", + email: booking.user.email, + timeZone: booking.user.timeZone, + } + : { name: "", email: "", timeZone: "", language: { locale: "" } }, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + title: booking.title, + language: { locale: booking?.user?.locale || "" }, + eventType: { + slug: booking.eventType?.slug, + }, + }; + if ( + newStep.action === WorkflowActions.EMAIL_HOST || + newStep.action === WorkflowActions.EMAIL_ATTENDEE /*|| + newStep.action === WorkflowActions.EMAIL_ADDRESS*/ + ) { + let sendTo = ""; + + switch (newStep.action) { + case WorkflowActions.EMAIL_HOST: + sendTo = bookingInfo.organizer?.email; + break; + case WorkflowActions.EMAIL_ATTENDEE: + sendTo = bookingInfo.attendees[0].email; + break; + /*case WorkflowActions.EMAIL_ADDRESS: + sendTo = newStep.sendTo || "";*/ + } + + await scheduleEmailReminder( + bookingInfo, + trigger, + newStep.action, + { + time, + timeUnit, + }, + sendTo, + newStep.emailSubject || "", + newStep.reminderBody || "", + newStep.id, + newStep.template, + newStep.senderName || SENDER_NAME + ); + } else if (newStep.action === WorkflowActions.SMS_NUMBER) { + await scheduleSMSReminder( + bookingInfo, + newStep.sendTo || "", + trigger, + newStep.action, + { + time, + timeUnit, + }, + newStep.reminderBody || "", + newStep.id || 0, + newStep.template, + newStep.sender || SENDER_ID, + user.id, + userWorkflow.teamId + ); + } + }); + } + } + }); + //added steps + const addedSteps = steps.map((s) => { + if (s.id <= 0) { + if (!userWorkflow.user?.teams.length && isSMSAction(s.action)) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + const { id: _stepId, ...stepToAdd } = s; + return stepToAdd; + } + }); + + if (addedSteps) { + const eventTypesToCreateReminders = activeOn.map((activeEventType) => { + if (activeEventType && !newEventTypes.includes(activeEventType)) { + return activeEventType; + } + }); + addedSteps.forEach(async (step) => { + if (step) { + const { senderName, ...newStep } = step; + newStep.sender = getSender({ + action: newStep.action, + sender: newStep.sender || null, + senderName: senderName, + }); + const createdStep = await ctx.prisma.workflowStep.create({ + data: { ...newStep, numberVerificationPending: false }, + }); + if ( + (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) && + eventTypesToCreateReminders && + step.action !== WorkflowActions.SMS_ATTENDEE + ) { + const bookingsForReminders = await ctx.prisma.booking.findMany({ + where: { + eventTypeId: { in: eventTypesToCreateReminders as number[] }, + status: BookingStatus.ACCEPTED, + startTime: { + gte: new Date(), + }, + }, + include: { + attendees: true, + eventType: true, + user: true, + }, + }); + bookingsForReminders.forEach(async (booking) => { + const bookingInfo = { + uid: booking.uid, + attendees: booking.attendees.map((attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { locale: attendee.locale || "" }, + }; + }), + organizer: booking.user + ? { + name: booking.user.name || "", + email: booking.user.email, + timeZone: booking.user.timeZone, + language: { locale: booking.user.locale || "" }, + } + : { name: "", email: "", timeZone: "", language: { locale: "" } }, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + title: booking.title, + language: { locale: booking?.user?.locale || "" }, + eventType: { + slug: booking.eventType?.slug, + }, + }; + + if ( + step.action === WorkflowActions.EMAIL_ATTENDEE || + step.action === WorkflowActions.EMAIL_HOST /*|| + step.action === WorkflowActions.EMAIL_ADDRESS*/ + ) { + let sendTo = ""; + + switch (step.action) { + case WorkflowActions.EMAIL_HOST: + sendTo = bookingInfo.organizer?.email; + break; + case WorkflowActions.EMAIL_ATTENDEE: + sendTo = bookingInfo.attendees[0].email; + break; + /*case WorkflowActions.EMAIL_ADDRESS: + sendTo = step.sendTo || "";*/ + } + + await scheduleEmailReminder( + bookingInfo, + trigger, + step.action, + { + time, + timeUnit, + }, + sendTo, + step.emailSubject || "", + step.reminderBody || "", + createdStep.id, + step.template, + step.senderName || SENDER_NAME + ); + } else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) { + await scheduleSMSReminder( + bookingInfo, + step.sendTo, + trigger, + step.action, + { + time, + timeUnit, + }, + step.reminderBody || "", + createdStep.id, + step.template, + step.sender || SENDER_ID, + user.id, + userWorkflow.teamId + ); + } + }); + } + } + }); + } + + //update trigger, name, time, timeUnit + await ctx.prisma.workflow.update({ + where: { + id, + }, + data: { + name, + trigger, + time, + timeUnit, + }, + }); + + const workflow = await ctx.prisma.workflow.findFirst({ + where: { + id, + }, + include: { + activeOn: { + select: { + eventType: true, + }, + }, + team: { + select: { + id: true, + slug: true, + members: true, + }, + }, + steps: { + orderBy: { + stepNumber: "asc", + }, + }, + }, + }); + + // Remove or add booking field for sms reminder number + const smsReminderNumberNeeded = + activeOn.length && steps.some((step) => step.action === WorkflowActions.SMS_ATTENDEE); + + for (const removedEventType of removedEventTypes) { + await removeSmsReminderFieldForBooking({ + workflowId: id, + eventTypeId: removedEventType, + }); + } + + for (const eventTypeId of activeOn) { + if (smsReminderNumberNeeded) { + await upsertSmsReminderFieldForBooking({ + workflowId: id, + isSmsReminderNumberRequired: steps.some( + (s) => s.action === WorkflowActions.SMS_ATTENDEE && s.numberRequired + ), + eventTypeId, + }); + } else { + await removeSmsReminderFieldForBooking({ workflowId: id, eventTypeId }); + } + } + + return { + workflow, + }; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/update.schema.ts b/packages/trpc/server/routers/viewer/workflows/update.schema.ts new file mode 100644 index 0000000000..0234ffa7f0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/update.schema.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +import { + TIME_UNIT, + WORKFLOW_ACTIONS, + WORKFLOW_TEMPLATES, + WORKFLOW_TRIGGER_EVENTS, +} from "@calcom/features/ee/workflows/lib/constants"; + +export const ZUpdateInputSchema = z.object({ + id: z.number(), + name: z.string(), + activeOn: z.number().array(), + steps: z + .object({ + id: z.number(), + stepNumber: z.number(), + action: z.enum(WORKFLOW_ACTIONS), + workflowId: z.number(), + sendTo: z.string().optional().nullable(), + reminderBody: z.string().optional().nullable(), + emailSubject: z.string().optional().nullable(), + template: z.enum(WORKFLOW_TEMPLATES), + numberRequired: z.boolean().nullable(), + sender: z.string().optional().nullable(), + senderName: z.string().optional().nullable(), + }) + .array(), + trigger: z.enum(WORKFLOW_TRIGGER_EVENTS), + time: z.number().nullable(), + timeUnit: z.enum(TIME_UNIT).nullable(), +}); + +export type TUpdateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/util.ts b/packages/trpc/server/routers/viewer/workflows/util.ts new file mode 100644 index 0000000000..01ee25b7eb --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/util.ts @@ -0,0 +1,116 @@ +import type { Workflow } from "@prisma/client"; +import { MembershipRole } from "@prisma/client"; + +import { isSMSAction } from "@calcom/ee/workflows/lib/actionHelperFunctions"; +import { + getSmsReminderNumberField, + getSmsReminderNumberSource, + SMS_REMINDER_NUMBER_FIELD, +} from "@calcom/features/bookings/lib/getBookingFields"; +import { removeBookingField, upsertBookingField } from "@calcom/features/eventtypes/lib/bookingFieldsManager"; +import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants"; +import type PrismaType from "@calcom/prisma"; +import type { WorkflowStep } from "@calcom/prisma/client"; + +export function getSender( + step: Pick & { senderName: string | null | undefined } +) { + return isSMSAction(step.action) ? step.sender || SENDER_ID : step.senderName || SENDER_NAME; +} + +export async function isAuthorized( + workflow: Pick | null, + prisma: typeof PrismaType, + currentUserId: number, + readOnly?: boolean +) { + if (!workflow) { + return false; + } + + if (!readOnly) { + const userWorkflow = await prisma.workflow.findFirst({ + where: { + id: workflow.id, + OR: [ + { userId: currentUserId }, + { + team: { + members: { + some: { + userId: currentUserId, + accepted: true, + }, + }, + }, + }, + ], + }, + }); + if (userWorkflow) return true; + } + + const userWorkflow = await prisma.workflow.findFirst({ + where: { + id: workflow.id, + OR: [ + { userId: currentUserId }, + { + team: { + members: { + some: { + userId: currentUserId, + accepted: true, + NOT: { + role: MembershipRole.MEMBER, + }, + }, + }, + }, + }, + ], + }, + }); + + if (userWorkflow) return true; + + return false; +} + +export async function upsertSmsReminderFieldForBooking({ + workflowId, + eventTypeId, + isSmsReminderNumberRequired, +}: { + workflowId: number; + isSmsReminderNumberRequired: boolean; + eventTypeId: number; +}) { + await upsertBookingField( + getSmsReminderNumberField(), + getSmsReminderNumberSource({ + workflowId, + isSmsReminderNumberRequired, + }), + eventTypeId + ); +} + +export async function removeSmsReminderFieldForBooking({ + workflowId, + eventTypeId, +}: { + workflowId: number; + eventTypeId: number; +}) { + await removeBookingField( + { + name: SMS_REMINDER_NUMBER_FIELD, + }, + { + id: "" + workflowId, + type: "workflow", + }, + eventTypeId + ); +} diff --git a/packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.handler.ts b/packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.handler.ts new file mode 100644 index 0000000000..708c2e3733 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.handler.ts @@ -0,0 +1,18 @@ +import { verifyPhoneNumber } from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TVerifyPhoneNumberInputSchema } from "./verifyPhoneNumber.schema"; + +type VerifyPhoneNumberOptions = { + ctx: { + user: NonNullable; + }; + input: TVerifyPhoneNumberInputSchema; +}; + +export const verifyPhoneNumberHandler = async ({ ctx, input }: VerifyPhoneNumberOptions) => { + const { phoneNumber, code, teamId } = input; + const { user } = ctx; + const verifyStatus = await verifyPhoneNumber(phoneNumber, code, user.id, teamId); + return verifyStatus; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.schema.ts b/packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.schema.ts new file mode 100644 index 0000000000..fb3a02537b --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZVerifyPhoneNumberInputSchema = z.object({ + phoneNumber: z.string(), + code: z.string(), + teamId: z.number().optional(), +}); + +export type TVerifyPhoneNumberInputSchema = z.infer; diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index 27f51624cc..6d4d36b10d 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -92,15 +92,20 @@ async function getUserFromSession({ session }: { session: Maybe }) { }; } +export type TrpcSessionUser = Awaited>; + const t = initTRPC.context().create({ transformer: superjson, }); const perfMiddleware = t.middleware(async ({ path, type, next }) => { performance.mark("Start"); + const start = performance.now(); const result = await next(); + const end = performance.now(); performance.mark("End"); performance.measure(`[${result.ok ? "OK" : "ERROR"}][$1] ${type} '${path}'`, "Start", "End"); + console.log(`[${result.ok ? "OK" : "ERROR"}][${end - start}ms] ${type} '${path}'`); return result; });