From d6fb0df64fc2227b06a4ef59b2fb9fc703d69849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efra=C3=ADn=20Roch=C3=ADn?= Date: Tue, 9 May 2023 12:27:05 -0700 Subject: [PATCH] perf: tRPC procedures and middleware refactor (#8419) * trpc procedures an middleware refactor * allow use sessionMiddleware without a req object * sync with the new tRPC structure * tRPC refactor on routing form app * import Prisma from @prisma/client * Lazy load apps from appstore * remove unrelated changes * Add types for PaymentService * type fixes * Merge branch 'main' into roae85/cal-1514-set-the-user-session-only-on-the * fix typo * remove console.log * remove explicit types from apstore object * linter fixes --------- Co-authored-by: Keith Williams Co-authored-by: Hariom Balhara --- apps/web/pages/_document.tsx | 1 + apps/web/pages/api/link.ts | 19 +- apps/web/server/lib/ssr.ts | 13 +- packages/app-store/_utils/getCalendar.ts | 2 +- packages/app-store/index.ts | 59 +- packages/app-store/rainbow/trpc/router.ts | 3 +- .../app-store/routing-forms/trpc-router.ts | 666 +---------------- .../app-store/routing-forms/trpc/_router.ts | 667 ++++++++++++++++++ .../routing-forms/trpc/deleteForm.handler.ts | 43 ++ .../routing-forms/trpc/deleteForm.schema.ts | 7 + .../trpc/formMutation.handler.ts | 330 +++++++++ .../routing-forms/trpc/formMutation.schema.ts | 20 + .../routing-forms/trpc/formQuery.handler.ts | 36 + .../routing-forms/trpc/formQuery.schema.ts | 7 + .../routing-forms/trpc/forms.handler.ts | 37 + .../routing-forms/trpc/report.handler.ts | 80 +++ .../routing-forms/trpc/report.schema.ts | 11 + .../routing-forms/trpc/response.handler.ts | 101 +++ .../routing-forms/trpc/response.schema.ts | 14 + .../app-store/routing-forms/trpc/utils.ts | 66 ++ .../stripepayment/lib/PaymentService.ts | 2 +- packages/core/videoClient.ts | 2 +- .../bookings/components/AvailableTimes.tsx | 2 +- .../bookings/lib/handleCancelBooking.ts | 9 +- .../features/ee/users/server/trpc-router.ts | 9 +- packages/features/flags/server/router.ts | 4 +- .../features/insights/server/trpc-router.ts | 9 +- packages/lib/payment/deletePayment.ts | 8 +- packages/lib/payment/handlePayment.ts | 9 +- packages/trpc/server/createContext.ts | 60 +- .../server/middlewares/localeMiddleware.ts | 19 + .../trpc/server/middlewares/perfMiddleware.ts | 11 + .../server/middlewares/sessionMiddleware.ts | 136 ++++ .../trpc/server/procedures/authedProcedure.ts | 31 + .../trpc/server/procedures/publicProcedure.ts | 6 + .../server/routers/loggedInViewer/_router.tsx | 3 +- ...ownloadLinkOfCalVideoRecordings.handler.ts | 4 +- .../server/routers/publicViewer/_router.tsx | 50 +- .../publicViewer/cityTimezones.handler.ts | 10 +- .../routers/publicViewer/i18n.handler.ts | 12 +- .../publicViewer/samlTenantProduct.handler.ts | 10 +- .../stripeCheckoutSession.handler.ts | 5 - .../server/routers/viewer/apiKeys/_router.tsx | 3 +- .../server/routers/viewer/apps/_router.tsx | 3 +- .../server/routers/viewer/auth/_router.tsx | 3 +- .../routers/viewer/availability/_router.tsx | 3 +- .../viewer/availability/schedule/_router.tsx | 3 +- .../routers/viewer/bookings/_router.tsx | 3 +- .../viewer/bookings/confirm.handler.ts | 8 +- .../server/routers/viewer/bookings/util.ts | 2 +- .../viewer/deploymentSetup/_router.tsx | 3 +- .../routers/viewer/eventTypes/_router.ts | 3 +- .../server/routers/viewer/eventTypes/util.ts | 2 +- .../trpc/server/routers/viewer/payments.tsx | 3 +- .../server/routers/viewer/payments/_router.ts | 3 +- .../viewer/payments/chargeCard.handler.ts | 9 +- .../server/routers/viewer/slots/_router.tsx | 3 +- .../server/routers/viewer/sso/_router.tsx | 3 +- .../server/routers/viewer/teams/_router.tsx | 3 +- .../server/routers/viewer/webhook/util.ts | 2 +- .../routers/viewer/workflows/_router.tsx | 3 +- packages/trpc/server/trpc.ts | 177 +---- .../PaymentService.d.ts} | 2 +- 63 files changed, 1818 insertions(+), 1019 deletions(-) create mode 100644 packages/app-store/routing-forms/trpc/_router.ts create mode 100644 packages/app-store/routing-forms/trpc/deleteForm.handler.ts create mode 100644 packages/app-store/routing-forms/trpc/deleteForm.schema.ts create mode 100644 packages/app-store/routing-forms/trpc/formMutation.handler.ts create mode 100644 packages/app-store/routing-forms/trpc/formMutation.schema.ts create mode 100644 packages/app-store/routing-forms/trpc/formQuery.handler.ts create mode 100644 packages/app-store/routing-forms/trpc/formQuery.schema.ts create mode 100644 packages/app-store/routing-forms/trpc/forms.handler.ts create mode 100644 packages/app-store/routing-forms/trpc/report.handler.ts create mode 100644 packages/app-store/routing-forms/trpc/report.schema.ts create mode 100644 packages/app-store/routing-forms/trpc/response.handler.ts create mode 100644 packages/app-store/routing-forms/trpc/response.schema.ts create mode 100644 packages/app-store/routing-forms/trpc/utils.ts create mode 100644 packages/trpc/server/middlewares/localeMiddleware.ts create mode 100644 packages/trpc/server/middlewares/perfMiddleware.ts create mode 100644 packages/trpc/server/middlewares/sessionMiddleware.ts create mode 100644 packages/trpc/server/procedures/authedProcedure.ts create mode 100644 packages/trpc/server/procedures/publicProcedure.ts rename packages/{lib/PaymentService.ts => types/PaymentService.d.ts} (97%) diff --git a/apps/web/pages/_document.tsx b/apps/web/pages/_document.tsx index f7b51e9352..9efa2cedd2 100644 --- a/apps/web/pages/_document.tsx +++ b/apps/web/pages/_document.tsx @@ -23,6 +23,7 @@ class MyDocument extends Document { // If x-csp not set by gSSP, then it's initialPropsOnly setHeader(ctx, "x-csp", "initialPropsOnly"); } + const asPath = ctx.asPath || ""; // Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only const parsedUrl = new URL(asPath, "https://dummyurl"); diff --git a/apps/web/pages/api/link.ts b/apps/web/pages/api/link.ts index de9e045480..efae23bb33 100644 --- a/apps/web/pages/api/link.ts +++ b/apps/web/pages/api/link.ts @@ -4,7 +4,6 @@ import { z } from "zod"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import { defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; -import { UserPermissionRole } from "@calcom/prisma/enums"; import { TRPCError } from "@calcom/trpc/server"; import { createContext } from "@calcom/trpc/server/createContext"; import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router"; @@ -27,32 +26,18 @@ const decryptedSchema = z.object({ async function handler(req: NextApiRequest, res: NextApiResponse) { const { action, token, reason } = querySchema.parse(req.query); - const { bookingUid, userId } = decryptedSchema.parse( + const { bookingUid } = decryptedSchema.parse( JSON.parse(symmetricDecrypt(decodeURIComponent(token), process.env.CALENDSO_ENCRYPTION_KEY || "")) ); - /** We shape the session as required by tRPC router */ - async function sessionGetter() { - return { - user: { - id: userId, - username: "" /* Not used in this context */, - role: UserPermissionRole.USER, - }, - hasValidLicense: true, - expires: "" /* Not used in this context */, - }; - } - const booking = await prisma.booking.findUniqueOrThrow({ where: { uid: bookingUid }, }); try { /** @see https://trpc.io/docs/server-side-calls */ - const ctx = await createContext({ req, res }, sessionGetter); + const ctx = await createContext({ req, res }); const caller = viewerRouter.createCaller({ ...ctx, req, res }); - await caller.bookings.confirm({ bookingId: booking.id, recurringEventId: booking.recurringEventId || undefined, diff --git a/apps/web/server/lib/ssr.ts b/apps/web/server/lib/ssr.ts index 0d0c4177d4..2c0d53f2e0 100644 --- a/apps/web/server/lib/ssr.ts +++ b/apps/web/server/lib/ssr.ts @@ -1,7 +1,8 @@ import type { GetServerSidePropsContext } from "next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import superjson from "superjson"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { getLocaleFromHeaders } from "@calcom/lib/i18n"; import { createProxySSGHelpers } from "@calcom/trpc/react/ssg"; import { createContext } from "@calcom/trpc/server/createContext"; import { appRouter } from "@calcom/trpc/server/routers/_app"; @@ -13,16 +14,14 @@ import { appRouter } from "@calcom/trpc/server/routers/_app"; * Make sure to `return { props: { trpcState: ssr.dehydrate() } }` at the end. */ export async function ssrInit(context: GetServerSidePropsContext) { - const { req, res } = context; - - const sessionGetter = () => getServerSession({ req, res }); - - const ctx = await createContext(context, sessionGetter); + const ctx = await createContext(context); + const locale = getLocaleFromHeaders(context.req); + const i18n = await serverSideTranslations(getLocaleFromHeaders(context.req), ["common", "vital"]); const ssr = createProxySSGHelpers({ router: appRouter, transformer: superjson, - ctx, + ctx: { ...ctx, locale, i18n }, }); // always preload "viewer.public.i18n" diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index 10c60ed1c3..5fddb8b419 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -12,7 +12,7 @@ export const getCalendar = async (credential: CredentialPayload | null): Promise if (calendarType?.endsWith("_other_calendar")) { calendarType = calendarType.split("_other_calendar")[0]; } - const calendarApp = await appStore[calendarType.split("_").join("") as keyof typeof appStore]; + const calendarApp = await appStore[calendarType.split("_").join("") as keyof typeof appStore](); if (!(calendarApp && "lib" in calendarApp && "CalendarService" in calendarApp.lib)) { log.warn(`calendar of type ${calendarType} is not implemented`); return null; diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index b7d5393644..1453334b4c 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -1,34 +1,33 @@ const appStore = { - // example: import("./example"), - applecalendar: import("./applecalendar"), - caldavcalendar: import("./caldavcalendar"), - closecom: import("./closecom"), - dailyvideo: import("./dailyvideo"), - googlecalendar: import("./googlecalendar"), - googlevideo: import("./googlevideo"), - hubspot: import("./hubspot"), - huddle01video: import("./huddle01video"), - jitsivideo: import("./jitsivideo"), - larkcalendar: import("./larkcalendar"), - office365calendar: import("./office365calendar"), - office365video: import("./office365video"), - plausible: import("./plausible"), - salesforce: import("./salesforce"), - zohocrm: import("./zohocrm"), - "zoho-bigin": import("./zoho-bigin"), - sendgrid: import("./sendgrid"), - stripepayment: import("./stripepayment"), - tandemvideo: import("./tandemvideo"), - vital: import("./vital"), - zoomvideo: import("./zoomvideo"), - wipemycalother: import("./wipemycalother"), - giphy: import("./giphy"), - zapier: import("./zapier"), - exchange2013calendar: import("./exchange2013calendar"), - exchange2016calendar: import("./exchange2016calendar"), - exchangecalendar: import("./exchangecalendar"), - facetime: import("./facetime"), - sylapsvideo: import("./sylapsvideo"), + // example: () => import("./example"), + applecalendar: () => import("./applecalendar"), + caldavcalendar: () => import("./caldavcalendar"), + closecom: () => import("./closecom"), + dailyvideo: () => import("./dailyvideo"), + googlecalendar: () => import("./googlecalendar"), + googlevideo: () => import("./googlevideo"), + hubspot: () => import("./hubspot"), + huddle01video: () => import("./huddle01video"), + jitsivideo: () => import("./jitsivideo"), + larkcalendar: () => import("./larkcalendar"), + office365calendar: () => import("./office365calendar"), + office365video: () => import("./office365video"), + plausible: () => import("./plausible"), + salesforce: () => import("./salesforce"), + zohocrm: () => import("./zohocrm"), + sendgrid: () => import("./sendgrid"), + stripepayment: () => import("./stripepayment"), + tandemvideo: () => import("./tandemvideo"), + vital: () => import("./vital"), + zoomvideo: () => import("./zoomvideo"), + wipemycalother: () => import("./wipemycalother"), + giphy: () => import("./giphy"), + zapier: () => import("./zapier"), + exchange2013calendar: () => import("./exchange2013calendar"), + exchange2016calendar: () => import("./exchange2016calendar"), + exchangecalendar: () => import("./exchangecalendar"), + facetime: () => import("./facetime"), + sylapsvideo: () => import("./sylapsvideo"), }; export default appStore; diff --git a/packages/app-store/rainbow/trpc/router.ts b/packages/app-store/rainbow/trpc/router.ts index 81f4c49b4f..9bc9f40c80 100644 --- a/packages/app-store/rainbow/trpc/router.ts +++ b/packages/app-store/rainbow/trpc/router.ts @@ -1,4 +1,5 @@ -import { router, publicProcedure } from "@calcom/trpc/server/trpc"; +import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure"; +import { router } from "@calcom/trpc/server/trpc"; import { ZBalanceInputSchema, ZBalanceOutputSchema } from "./balance.schema"; import { ZContractInputSchema, ZContractOutputSchema } from "./contract.schema"; diff --git a/packages/app-store/routing-forms/trpc-router.ts b/packages/app-store/routing-forms/trpc-router.ts index 6e1ba9506c..2adeb77930 100644 --- a/packages/app-store/routing-forms/trpc-router.ts +++ b/packages/app-store/routing-forms/trpc-router.ts @@ -1,665 +1 @@ -import type { App_RoutingForms_Form, User } from "@prisma/client"; -import { Prisma } from "@prisma/client"; -import { z } from "zod"; - -import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; -import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; -import logger from "@calcom/lib/logger"; -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; -import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; -import { TRPCError } from "@calcom/trpc/server"; -import { authedProcedure, publicProcedure, router } from "@calcom/trpc/server/trpc"; -import type { Ensure } from "@calcom/types/utils"; - -import ResponseEmail from "./emails/templates/response-email"; -import { jsonLogicToPrisma } from "./jsonLogicToPrisma"; -import { createFallbackRoute } from "./lib/createFallbackRoute"; -import getConnectedForms from "./lib/getConnectedForms"; -import { getSerializableForm } from "./lib/getSerializableForm"; -import { isFallbackRoute } from "./lib/isFallbackRoute"; -import { isFormEditAllowed } from "./lib/isFormEditAllowed"; -import isRouter from "./lib/isRouter"; -import isRouterLinkedField from "./lib/isRouterLinkedField"; -import type { Response, SerializableForm } from "./types/types"; -import { zodFields, zodRouterRoute, zodRoutes } from "./zod"; - -async function onFormSubmission( - form: Ensure & { user: User }, "fields">, - response: Response -) { - const fieldResponsesByName: Record = {}; - - for (const [fieldId, fieldResponse] of Object.entries(response)) { - // Use the label lowercased as the key to identify a field. - const key = - form.fields.find((f) => f.id === fieldId)?.identifier || - (fieldResponse.label as keyof typeof fieldResponsesByName); - fieldResponsesByName[key] = fieldResponse.value; - } - - const subscriberOptions = { - userId: form.user.id, - // It isn't an eventType webhook - eventTypeId: -1, - triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, - }; - - const webhooks = await getWebhooks(subscriberOptions); - const promises = webhooks.map((webhook) => { - sendGenericWebhookPayload( - webhook.secret, - "FORM_SUBMITTED", - new Date().toISOString(), - webhook, - fieldResponsesByName - ).catch((e) => { - console.error(`Error executing routing form webhook`, webhook, e); - }); - }); - - await Promise.all(promises); - if (form.settings?.emailOwnerOnSubmission) { - logger.debug( - `Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}` - ); - await sendResponseEmail(form, response, form.user.email); - } -} - -const sendResponseEmail = async ( - form: Pick, - response: Response, - ownerEmail: string -) => { - try { - const email = new ResponseEmail({ form: form, toAddresses: [ownerEmail], response: response }); - await email.sendEmail(); - } catch (e) { - logger.error("Error sending response email", e); - } -}; - -const appRoutingForms = router({ - public: router({ - response: publicProcedure - .input( - z.object({ - formId: z.string(), - formFillerId: z.string(), - response: z.record( - z.object({ - label: z.string(), - value: z.union([z.string(), z.array(z.string())]), - }) - ), - }) - ) - .mutation(async ({ ctx, input }) => { - const { prisma } = ctx; - try { - const { response, formId } = input; - const form = await prisma.app_RoutingForms_Form.findFirst({ - where: { - id: formId, - }, - include: { - user: true, - }, - }); - if (!form) { - throw new TRPCError({ - code: "NOT_FOUND", - }); - } - - const serializableForm = await getSerializableForm(form); - if (!serializableForm.fields) { - // There is no point in submitting a form that doesn't have fields defined - throw new TRPCError({ - code: "BAD_REQUEST", - }); - } - - const serializableFormWithFields = { - ...serializableForm, - fields: serializableForm.fields, - }; - - const missingFields = serializableFormWithFields.fields - .filter((field) => !(field.required ? response[field.id]?.value : true)) - .map((f) => f.label); - - if (missingFields.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Missing required fields ${missingFields.join(", ")}`, - }); - } - const invalidFields = serializableFormWithFields.fields - .filter((field) => { - const fieldValue = response[field.id]?.value; - // The field isn't required at this point. Validate only if it's set - if (!fieldValue) { - return false; - } - let schema; - if (field.type === "email") { - schema = z.string().email(); - } else if (field.type === "phone") { - schema = z.any(); - } else { - schema = z.any(); - } - return !schema.safeParse(fieldValue).success; - }) - .map((f) => ({ label: f.label, type: f.type })); - - if (invalidFields.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid fields ${invalidFields.map((f) => `${f.label}: ${f.type}`)}`, - }); - } - - const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({ - data: input, - }); - - await onFormSubmission(serializableFormWithFields, dbFormResponse.response as Response); - return dbFormResponse; - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === "P2002") { - throw new TRPCError({ - code: "CONFLICT", - }); - } - } - throw e; - } - }), - }), - forms: authedProcedure.query(async ({ ctx }) => { - const { prisma, user } = ctx; - const forms = await prisma.app_RoutingForms_Form.findMany({ - where: { - userId: user.id, - }, - orderBy: { - createdAt: "desc", - }, - include: { - _count: { - select: { - responses: true, - }, - }, - }, - }); - - const serializableForms = []; - for (let i = 0; i < forms.length; i++) { - serializableForms.push(await getSerializableForm(forms[i])); - } - return serializableForms; - }), - formQuery: authedProcedure - .input( - z.object({ - id: z.string(), - }) - ) - .query(async ({ ctx, input }) => { - const { prisma, user } = ctx; - const form = await prisma.app_RoutingForms_Form.findFirst({ - where: { - userId: user.id, - id: input.id, - }, - include: { - _count: { - select: { - responses: true, - }, - }, - }, - }); - - if (!form) { - return null; - } - - return await getSerializableForm(form); - }), - formMutation: authedProcedure - .input( - z.object({ - id: z.string(), - name: z.string(), - description: z.string().nullable().optional(), - disabled: z.boolean().optional(), - fields: zodFields, - routes: zodRoutes, - addFallback: z.boolean().optional(), - duplicateFrom: z.string().nullable().optional(), - shouldConnect: z.boolean().optional(), - settings: RoutingFormSettings.optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user, prisma } = ctx; - const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input; - if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) { - throw new TRPCError({ - code: "FORBIDDEN", - }); - } - let { routes: inputRoutes } = input; - let { fields: inputFields } = input; - inputFields = inputFields || []; - inputRoutes = inputRoutes || []; - type InputFields = typeof inputFields; - type InputRoutes = typeof inputRoutes; - let routes: InputRoutes; - let fields: InputFields; - type DuplicateFrom = NonNullable; - - const dbForm = await prisma.app_RoutingForms_Form.findUnique({ - where: { - id: id, - }, - select: { - id: true, - user: true, - name: true, - description: true, - userId: true, - disabled: true, - createdAt: true, - updatedAt: true, - routes: true, - fields: true, - settings: true, - }, - }); - - const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null; - - if (duplicateFrom) { - ({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom)); - } else { - [fields, routes] = [inputFields, inputRoutes]; - if (dbSerializedForm) { - fields = markMissingFieldsDeleted(dbSerializedForm, fields); - } - } - - if (dbSerializedForm) { - // If it's an existing form being mutated, update fields in the connected forms(if any). - await updateFieldsInConnectedForms(dbSerializedForm, inputFields); - } - - fields = await getUpdatedRouterLinkedFields(fields, routes); - - if (addFallback) { - // Add a fallback route if there is none - if (!routes.find(isFallbackRoute)) { - routes.push(createFallbackRoute()); - } - } - - return await prisma.app_RoutingForms_Form.upsert({ - where: { - id: id, - }, - create: { - user: { - connect: { - id: user.id, - }, - }, - fields, - name: name, - description, - // Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case. - routes: routes === null ? Prisma.JsonNull : routes, - id: id, - }, - update: { - disabled: disabled, - fields, - name: name, - description, - settings: settings === null ? Prisma.JsonNull : settings, - routes: routes === null ? Prisma.JsonNull : routes, - }, - }); - - /** - * If Form has Router Linked fields, enrich them with the latest info from the Router - * If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router - */ - async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) { - const routerLinkedFields: Record = {}; - for (const [, field] of Object.entries(fields)) { - if (!isRouterLinkedField(field)) { - continue; - } - routerLinkedFields[field.routerId] = true; - - if (!routes.some((route) => route.id === field.routerId)) { - // If the field is from a router that is not available anymore, mark it as deleted - field.deleted = true; - continue; - } - // Get back deleted field as now the Router is there for it. - if (field.deleted) field.deleted = false; - const router = await prisma.app_RoutingForms_Form.findFirst({ - where: { - id: field.routerId, - userId: user.id, - }, - }); - if (router) { - assertIfInvalidRouter(router); - const parsedRouterFields = zodFields.parse(router.fields); - - // There is a field from some router available, make sure that the field has up-to-date info from the router - const routerField = parsedRouterFields?.find((f) => f.id === field.id); - // Update local field(cache) with router field on every mutation - Object.assign(field, routerField); - } - } - - for (const [, route] of Object.entries(routes)) { - if (!isRouter(route)) { - continue; - } - - // If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields - if (routerLinkedFields[route.id]) { - continue; - } - - const router = await prisma.app_RoutingForms_Form.findFirst({ - where: { - id: route.id, - userId: user.id, - }, - }); - if (router) { - assertIfInvalidRouter(router); - const parsedRouterFields = zodFields.parse(router.fields); - const fieldsFromRouter = parsedRouterFields - ?.filter((f) => !f.deleted) - .map((f) => { - return { - ...f, - routerId: route.id, - }; - }); - - if (fieldsFromRouter) { - fields = fields.concat(fieldsFromRouter); - } - } - } - return fields; - } - - function findFieldWithId(id: string, fields: InputFields) { - return fields.find((field) => field.id === id); - } - - /** - * Update fields in connected forms as per the inputFields - */ - async function updateFieldsInConnectedForms( - serializedForm: SerializableForm, - inputFields: InputFields - ) { - for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) { - const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({ - where: { - id: connectedForm.id, - }, - }); - if (!connectedFormDb) { - continue; - } - const connectedFormFields = zodFields.parse(connectedFormDb.fields); - - const fieldsThatAreNotInConnectedForm = ( - inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || [] - ).map((f) => ({ - ...f, - routerId: serializedForm.id, - })); - - const updatedConnectedFormFields = connectedFormFields - // Update fields that are already in connected form - ?.map((field) => { - if (isRouterLinkedField(field) && field.routerId === serializedForm.id) { - return { - ...field, - ...findFieldWithId(field.id, inputFields || []), - }; - } - return field; - }) - // Add fields that are not there - .concat(fieldsThatAreNotInConnectedForm); - - await prisma.app_RoutingForms_Form.update({ - where: { - id: connectedForm.id, - }, - data: { - fields: updatedConnectedFormFields, - }, - }); - } - } - - async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) { - const sourceForm = await prisma.app_RoutingForms_Form.findFirst({ - where: { - userId: user.id, - id: duplicateFrom, - }, - select: { - id: true, - fields: true, - routes: true, - }, - }); - if (!sourceForm) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Form to duplicate: ${duplicateFrom} not found`, - }); - } - //TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well. - const fieldsParsed = zodFields.safeParse(sourceForm.fields); - const routesParsed = zodRoutes.safeParse(sourceForm.routes); - if (!fieldsParsed.success || !routesParsed.success) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Could not parse source form's fields or routes", - }); - } - - let fields, routes; - if (shouldConnect) { - routes = [ - // This connected route would automatically link the fields - zodRouterRoute.parse({ - id: sourceForm.id, - isRouter: true, - }), - ]; - fields = - fieldsParsed.data - // Deleted fields in the form shouldn't be added to the new form - ?.filter((f) => !f.deleted) - .map((f) => { - return { - id: f.id, - routerId: sourceForm.id, - label: "", - type: "", - }; - }) || []; - } else { - // Duplicate just routes and fields - // We don't want name, description and responses to be copied - routes = routesParsed.data || []; - // FIXME: Deleted fields shouldn't come in duplicate - fields = fieldsParsed.data || []; - } - return { routes, fields }; - } - - function markMissingFieldsDeleted( - serializedForm: SerializableForm, - fields: InputFields - ) { - // Find all fields that are in DB(including deleted) but not in the mutation - // e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted - const deletedFields = - serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || []; - - // Add back deleted fields in the end and mark them deleted. - // Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation - fields = fields.concat( - deletedFields.map((f) => { - f.deleted = true; - return f; - }) - ); - return fields; - } - function assertIfInvalidRouter(router: App_RoutingForms_Form) { - const routesOfRouter = zodRoutes.parse(router.routes); - if (routesOfRouter) { - if (routesOfRouter.find(isRouter)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: - "A form being used as a Router must be a Origin form. It must not be using any other Router.", - }); - } - } - } - }), - deleteForm: authedProcedure - .input( - z.object({ - id: z.string(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user, prisma } = ctx; - if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) { - throw new TRPCError({ - code: "FORBIDDEN", - }); - } - - const areFormsUsingIt = ( - await getConnectedForms(prisma, { - id: input.id, - userId: user.id, - }) - ).length; - if (areFormsUsingIt) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This form is being used by other forms. Please remove it's usage from there first.", - }); - } - return await prisma.app_RoutingForms_Form.deleteMany({ - where: { - id: input.id, - userId: user.id, - }, - }); - }), - - report: authedProcedure - .input( - z.object({ - formId: z.string(), - jsonLogicQuery: z.object({ - logic: z.union([z.record(z.any()), z.null()]), - }), - cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type - }) - ) - .query(async ({ ctx: { prisma }, input }) => { - // Can be any prisma `where` clause - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const prismaWhere: Record = input.jsonLogicQuery - ? jsonLogicToPrisma(input.jsonLogicQuery) - : {}; - const skip = input.cursor ?? 0; - const take = 50; - logger.debug( - `Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify( - input.jsonLogicQuery - )}` - ); - const form = await prisma.app_RoutingForms_Form.findUnique({ - where: { - id: input.formId, - }, - }); - - if (!form) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Form not found", - }); - } - // TODO: Second argument is required to return deleted operators. - const serializedForm = await getSerializableForm(form, true); - - const rows = await prisma.app_RoutingForms_FormResponse.findMany({ - where: { - formId: input.formId, - ...prismaWhere, - }, - take, - skip, - }); - const fields = serializedForm?.fields || []; - const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : "")); - const responses: string[][] = []; - rows.forEach((r) => { - const rowResponses: string[] = []; - responses.push(rowResponses); - fields.forEach((field) => { - if (!r.response) { - return; - } - const response = r.response as Response; - const value = response[field.id]?.value || ""; - let stringValue = ""; - if (value instanceof Array) { - stringValue = value.join(", "); - } else { - stringValue = value; - } - rowResponses.push(stringValue); - }); - }); - const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take; - return { - headers, - responses, - nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length, - }; - }), -}); - -export default appRoutingForms; +export { default } from "./trpc/_router"; diff --git a/packages/app-store/routing-forms/trpc/_router.ts b/packages/app-store/routing-forms/trpc/_router.ts new file mode 100644 index 0000000000..1830454f06 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/_router.ts @@ -0,0 +1,667 @@ +import type { App_RoutingForms_Form, User } from "@prisma/client"; +import { Prisma } from "@prisma/client"; +import { z } from "zod"; + +import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; +import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; +import logger from "@calcom/lib/logger"; +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; +import { TRPCError } from "@calcom/trpc/server"; +import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure"; +import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure"; +import { router } from "@calcom/trpc/server/trpc"; +import type { Ensure } from "@calcom/types/utils"; + +import ResponseEmail from "../emails/templates/response-email"; +import { jsonLogicToPrisma } from "../jsonLogicToPrisma"; +import { createFallbackRoute } from "../lib/createFallbackRoute"; +import getConnectedForms from "../lib/getConnectedForms"; +import { getSerializableForm } from "../lib/getSerializableForm"; +import { isFallbackRoute } from "../lib/isFallbackRoute"; +import { isFormEditAllowed } from "../lib/isFormEditAllowed"; +import isRouter from "../lib/isRouter"; +import isRouterLinkedField from "../lib/isRouterLinkedField"; +import type { Response, SerializableForm } from "../types/types"; +import { zodFields, zodRouterRoute, zodRoutes } from "../zod"; + +async function onFormSubmission( + form: Ensure & { user: User }, "fields">, + response: Response +) { + const fieldResponsesByName: Record = {}; + + for (const [fieldId, fieldResponse] of Object.entries(response)) { + // Use the label lowercased as the key to identify a field. + const key = + form.fields.find((f) => f.id === fieldId)?.identifier || + (fieldResponse.label as keyof typeof fieldResponsesByName); + fieldResponsesByName[key] = fieldResponse.value; + } + + const subscriberOptions = { + userId: form.user.id, + // It isn't an eventType webhook + eventTypeId: -1, + triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, + }; + + const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => { + sendGenericWebhookPayload( + webhook.secret, + "FORM_SUBMITTED", + new Date().toISOString(), + webhook, + fieldResponsesByName + ).catch((e) => { + console.error(`Error executing routing form webhook`, webhook, e); + }); + }); + + await Promise.all(promises); + if (form.settings?.emailOwnerOnSubmission) { + logger.debug( + `Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}` + ); + await sendResponseEmail(form, response, form.user.email); + } +} + +const sendResponseEmail = async ( + form: Pick, + response: Response, + ownerEmail: string +) => { + try { + const email = new ResponseEmail({ form: form, toAddresses: [ownerEmail], response: response }); + await email.sendEmail(); + } catch (e) { + logger.error("Error sending response email", e); + } +}; + +const appRoutingForms = router({ + public: router({ + response: publicProcedure + .input( + z.object({ + formId: z.string(), + formFillerId: z.string(), + response: z.record( + z.object({ + label: z.string(), + value: z.union([z.string(), z.array(z.string())]), + }) + ), + }) + ) + .mutation(async ({ ctx, input }) => { + const { prisma } = ctx; + try { + const { response, formId } = input; + const form = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: formId, + }, + include: { + user: true, + }, + }); + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", + }); + } + + const serializableForm = await getSerializableForm(form); + if (!serializableForm.fields) { + // There is no point in submitting a form that doesn't have fields defined + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } + + const serializableFormWithFields = { + ...serializableForm, + fields: serializableForm.fields, + }; + + const missingFields = serializableFormWithFields.fields + .filter((field) => !(field.required ? response[field.id]?.value : true)) + .map((f) => f.label); + + if (missingFields.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Missing required fields ${missingFields.join(", ")}`, + }); + } + const invalidFields = serializableFormWithFields.fields + .filter((field) => { + const fieldValue = response[field.id]?.value; + // The field isn't required at this point. Validate only if it's set + if (!fieldValue) { + return false; + } + let schema; + if (field.type === "email") { + schema = z.string().email(); + } else if (field.type === "phone") { + schema = z.any(); + } else { + schema = z.any(); + } + return !schema.safeParse(fieldValue).success; + }) + .map((f) => ({ label: f.label, type: f.type })); + + if (invalidFields.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid fields ${invalidFields.map((f) => `${f.label}: ${f.type}`)}`, + }); + } + + const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({ + data: input, + }); + + await onFormSubmission(serializableFormWithFields, dbFormResponse.response as Response); + return dbFormResponse; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === "P2002") { + throw new TRPCError({ + code: "CONFLICT", + }); + } + } + throw e; + } + }), + }), + forms: authedProcedure.query(async ({ ctx }) => { + const { prisma, user } = ctx; + const forms = await prisma.app_RoutingForms_Form.findMany({ + where: { + userId: user.id, + }, + orderBy: { + createdAt: "desc", + }, + include: { + _count: { + select: { + responses: true, + }, + }, + }, + }); + + const serializableForms = []; + for (let i = 0; i < forms.length; i++) { + serializableForms.push(await getSerializableForm(forms[i])); + } + return serializableForms; + }), + formQuery: authedProcedure + .input( + z.object({ + id: z.string(), + }) + ) + .query(async ({ ctx, input }) => { + const { prisma, user } = ctx; + const form = await prisma.app_RoutingForms_Form.findFirst({ + where: { + userId: user.id, + id: input.id, + }, + include: { + _count: { + select: { + responses: true, + }, + }, + }, + }); + + if (!form) { + return null; + } + + return await getSerializableForm(form); + }), + formMutation: authedProcedure + .input( + z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + disabled: z.boolean().optional(), + fields: zodFields, + routes: zodRoutes, + addFallback: z.boolean().optional(), + duplicateFrom: z.string().nullable().optional(), + shouldConnect: z.boolean().optional(), + settings: RoutingFormSettings.optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { user, prisma } = ctx; + const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input; + if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) { + throw new TRPCError({ + code: "FORBIDDEN", + }); + } + let { routes: inputRoutes } = input; + let { fields: inputFields } = input; + inputFields = inputFields || []; + inputRoutes = inputRoutes || []; + type InputFields = typeof inputFields; + type InputRoutes = typeof inputRoutes; + let routes: InputRoutes; + let fields: InputFields; + type DuplicateFrom = NonNullable; + + const dbForm = await prisma.app_RoutingForms_Form.findUnique({ + where: { + id: id, + }, + select: { + id: true, + user: true, + name: true, + description: true, + userId: true, + disabled: true, + createdAt: true, + updatedAt: true, + routes: true, + fields: true, + settings: true, + }, + }); + + const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null; + + if (duplicateFrom) { + ({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom)); + } else { + [fields, routes] = [inputFields, inputRoutes]; + if (dbSerializedForm) { + fields = markMissingFieldsDeleted(dbSerializedForm, fields); + } + } + + if (dbSerializedForm) { + // If it's an existing form being mutated, update fields in the connected forms(if any). + await updateFieldsInConnectedForms(dbSerializedForm, inputFields); + } + + fields = await getUpdatedRouterLinkedFields(fields, routes); + + if (addFallback) { + // Add a fallback route if there is none + if (!routes.find(isFallbackRoute)) { + routes.push(createFallbackRoute()); + } + } + + return await prisma.app_RoutingForms_Form.upsert({ + where: { + id: id, + }, + create: { + user: { + connect: { + id: user.id, + }, + }, + fields, + name: name, + description, + // Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case. + routes: routes === null ? Prisma.JsonNull : routes, + id: id, + }, + update: { + disabled: disabled, + fields, + name: name, + description, + settings: settings === null ? Prisma.JsonNull : settings, + routes: routes === null ? Prisma.JsonNull : routes, + }, + }); + + /** + * If Form has Router Linked fields, enrich them with the latest info from the Router + * If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router + */ + async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) { + const routerLinkedFields: Record = {}; + for (const [, field] of Object.entries(fields)) { + if (!isRouterLinkedField(field)) { + continue; + } + routerLinkedFields[field.routerId] = true; + + if (!routes.some((route) => route.id === field.routerId)) { + // If the field is from a router that is not available anymore, mark it as deleted + field.deleted = true; + continue; + } + // Get back deleted field as now the Router is there for it. + if (field.deleted) field.deleted = false; + const router = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: field.routerId, + userId: user.id, + }, + }); + if (router) { + assertIfInvalidRouter(router); + const parsedRouterFields = zodFields.parse(router.fields); + + // There is a field from some router available, make sure that the field has up-to-date info from the router + const routerField = parsedRouterFields?.find((f) => f.id === field.id); + // Update local field(cache) with router field on every mutation + Object.assign(field, routerField); + } + } + + for (const [, route] of Object.entries(routes)) { + if (!isRouter(route)) { + continue; + } + + // If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields + if (routerLinkedFields[route.id]) { + continue; + } + + const router = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: route.id, + userId: user.id, + }, + }); + if (router) { + assertIfInvalidRouter(router); + const parsedRouterFields = zodFields.parse(router.fields); + const fieldsFromRouter = parsedRouterFields + ?.filter((f) => !f.deleted) + .map((f) => { + return { + ...f, + routerId: route.id, + }; + }); + + if (fieldsFromRouter) { + fields = fields.concat(fieldsFromRouter); + } + } + } + return fields; + } + + function findFieldWithId(id: string, fields: InputFields) { + return fields.find((field) => field.id === id); + } + + /** + * Update fields in connected forms as per the inputFields + */ + async function updateFieldsInConnectedForms( + serializedForm: SerializableForm, + inputFields: InputFields + ) { + for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) { + const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: connectedForm.id, + }, + }); + if (!connectedFormDb) { + continue; + } + const connectedFormFields = zodFields.parse(connectedFormDb.fields); + + const fieldsThatAreNotInConnectedForm = ( + inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || [] + ).map((f) => ({ + ...f, + routerId: serializedForm.id, + })); + + const updatedConnectedFormFields = connectedFormFields + // Update fields that are already in connected form + ?.map((field) => { + if (isRouterLinkedField(field) && field.routerId === serializedForm.id) { + return { + ...field, + ...findFieldWithId(field.id, inputFields || []), + }; + } + return field; + }) + // Add fields that are not there + .concat(fieldsThatAreNotInConnectedForm); + + await prisma.app_RoutingForms_Form.update({ + where: { + id: connectedForm.id, + }, + data: { + fields: updatedConnectedFormFields, + }, + }); + } + } + + async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) { + const sourceForm = await prisma.app_RoutingForms_Form.findFirst({ + where: { + userId: user.id, + id: duplicateFrom, + }, + select: { + id: true, + fields: true, + routes: true, + }, + }); + if (!sourceForm) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Form to duplicate: ${duplicateFrom} not found`, + }); + } + //TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well. + const fieldsParsed = zodFields.safeParse(sourceForm.fields); + const routesParsed = zodRoutes.safeParse(sourceForm.routes); + if (!fieldsParsed.success || !routesParsed.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Could not parse source form's fields or routes", + }); + } + + let fields, routes; + if (shouldConnect) { + routes = [ + // This connected route would automatically link the fields + zodRouterRoute.parse({ + id: sourceForm.id, + isRouter: true, + }), + ]; + fields = + fieldsParsed.data + // Deleted fields in the form shouldn't be added to the new form + ?.filter((f) => !f.deleted) + .map((f) => { + return { + id: f.id, + routerId: sourceForm.id, + label: "", + type: "", + }; + }) || []; + } else { + // Duplicate just routes and fields + // We don't want name, description and responses to be copied + routes = routesParsed.data || []; + // FIXME: Deleted fields shouldn't come in duplicate + fields = fieldsParsed.data || []; + } + return { routes, fields }; + } + + function markMissingFieldsDeleted( + serializedForm: SerializableForm, + fields: InputFields + ) { + // Find all fields that are in DB(including deleted) but not in the mutation + // e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted + const deletedFields = + serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || []; + + // Add back deleted fields in the end and mark them deleted. + // Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation + fields = fields.concat( + deletedFields.map((f) => { + f.deleted = true; + return f; + }) + ); + return fields; + } + function assertIfInvalidRouter(router: App_RoutingForms_Form) { + const routesOfRouter = zodRoutes.parse(router.routes); + if (routesOfRouter) { + if (routesOfRouter.find(isRouter)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "A form being used as a Router must be a Origin form. It must not be using any other Router.", + }); + } + } + } + }), + deleteForm: authedProcedure + .input( + z.object({ + id: z.string(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { user, prisma } = ctx; + if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) { + throw new TRPCError({ + code: "FORBIDDEN", + }); + } + + const areFormsUsingIt = ( + await getConnectedForms(prisma, { + id: input.id, + userId: user.id, + }) + ).length; + if (areFormsUsingIt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This form is being used by other forms. Please remove it's usage from there first.", + }); + } + return await prisma.app_RoutingForms_Form.deleteMany({ + where: { + id: input.id, + userId: user.id, + }, + }); + }), + + report: authedProcedure + .input( + z.object({ + formId: z.string(), + jsonLogicQuery: z.object({ + logic: z.union([z.record(z.any()), z.null()]), + }), + cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type + }) + ) + .query(async ({ ctx: { prisma }, input }) => { + // Can be any prisma `where` clause + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const prismaWhere: Record = input.jsonLogicQuery + ? jsonLogicToPrisma(input.jsonLogicQuery) + : {}; + const skip = input.cursor ?? 0; + const take = 50; + logger.debug( + `Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify( + input.jsonLogicQuery + )}` + ); + const form = await prisma.app_RoutingForms_Form.findUnique({ + where: { + id: input.formId, + }, + }); + + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Form not found", + }); + } + // TODO: Second argument is required to return deleted operators. + const serializedForm = await getSerializableForm(form, true); + + const rows = await prisma.app_RoutingForms_FormResponse.findMany({ + where: { + formId: input.formId, + ...prismaWhere, + }, + take, + skip, + }); + const fields = serializedForm?.fields || []; + const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : "")); + const responses: string[][] = []; + rows.forEach((r) => { + const rowResponses: string[] = []; + responses.push(rowResponses); + fields.forEach((field) => { + if (!r.response) { + return; + } + const response = r.response as Response; + const value = response[field.id]?.value || ""; + let stringValue = ""; + if (value instanceof Array) { + stringValue = value.join(", "); + } else { + stringValue = value; + } + rowResponses.push(stringValue); + }); + }); + const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take; + return { + headers, + responses, + nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length, + }; + }), +}); + +export default appRoutingForms; diff --git a/packages/app-store/routing-forms/trpc/deleteForm.handler.ts b/packages/app-store/routing-forms/trpc/deleteForm.handler.ts new file mode 100644 index 0000000000..cc5e936e3c --- /dev/null +++ b/packages/app-store/routing-forms/trpc/deleteForm.handler.ts @@ -0,0 +1,43 @@ +import type { PrismaClient } from "@prisma/client"; + +import { TRPCError } from "@calcom/trpc/server"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import getConnectedForms from "../lib/getConnectedForms"; +import { isFormEditAllowed } from "../lib/isFormEditAllowed"; +import type { TDeleteFormInputSchema } from "./deleteForm.schema"; + +interface DeleteFormHandlerOptions { + ctx: { + prisma: PrismaClient; + user: NonNullable; + }; + input: TDeleteFormInputSchema; +} +export const deleteFormHandler = async ({ ctx, input }: DeleteFormHandlerOptions) => { + const { user, prisma } = ctx; + if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) { + throw new TRPCError({ + code: "FORBIDDEN", + }); + } + + const areFormsUsingIt = ( + await getConnectedForms(prisma, { + id: input.id, + userId: user.id, + }) + ).length; + if (areFormsUsingIt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This form is being used by other forms. Please remove it's usage from there first.", + }); + } + return await prisma.app_RoutingForms_Form.deleteMany({ + where: { + id: input.id, + userId: user.id, + }, + }); +}; diff --git a/packages/app-store/routing-forms/trpc/deleteForm.schema.ts b/packages/app-store/routing-forms/trpc/deleteForm.schema.ts new file mode 100644 index 0000000000..8e3bab6a0e --- /dev/null +++ b/packages/app-store/routing-forms/trpc/deleteForm.schema.ts @@ -0,0 +1,7 @@ +import z from "zod"; + +export const ZDeleteFormInputSchema = z.object({ + id: z.string(), +}); + +export type TDeleteFormInputSchema = z.infer; diff --git a/packages/app-store/routing-forms/trpc/formMutation.handler.ts b/packages/app-store/routing-forms/trpc/formMutation.handler.ts new file mode 100644 index 0000000000..1455613d49 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/formMutation.handler.ts @@ -0,0 +1,330 @@ +import type { PrismaClient } from "@prisma/client"; +import type { App_RoutingForms_Form } from "@prisma/client"; +import { Prisma } from "@prisma/client"; + +import { TRPCError } from "@calcom/trpc/server"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { createFallbackRoute } from "../lib/createFallbackRoute"; +import { getSerializableForm } from "../lib/getSerializableForm"; +import { isFallbackRoute } from "../lib/isFallbackRoute"; +import { isFormEditAllowed } from "../lib/isFormEditAllowed"; +import isRouter from "../lib/isRouter"; +import isRouterLinkedField from "../lib/isRouterLinkedField"; +import type { SerializableForm } from "../types/types"; +import { zodFields, zodRouterRoute, zodRoutes } from "../zod"; +import type { TFormMutationInputSchema } from "./formMutation.schema"; + +interface FormMutationHandlerOptions { + ctx: { + prisma: PrismaClient; + user: NonNullable; + }; + input: TFormMutationInputSchema; +} +export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOptions) => { + const { user, prisma } = ctx; + const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input; + if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) { + throw new TRPCError({ + code: "FORBIDDEN", + }); + } + let { routes: inputRoutes } = input; + let { fields: inputFields } = input; + inputFields = inputFields || []; + inputRoutes = inputRoutes || []; + type InputFields = typeof inputFields; + type InputRoutes = typeof inputRoutes; + let routes: InputRoutes; + let fields: InputFields; + type DuplicateFrom = NonNullable; + + const dbForm = await prisma.app_RoutingForms_Form.findUnique({ + where: { + id: id, + }, + select: { + id: true, + user: true, + name: true, + description: true, + userId: true, + disabled: true, + createdAt: true, + updatedAt: true, + routes: true, + fields: true, + settings: true, + }, + }); + + const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null; + + if (duplicateFrom) { + ({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom)); + } else { + [fields, routes] = [inputFields, inputRoutes]; + if (dbSerializedForm) { + fields = markMissingFieldsDeleted(dbSerializedForm, fields); + } + } + + if (dbSerializedForm) { + // If it's an existing form being mutated, update fields in the connected forms(if any). + await updateFieldsInConnectedForms(dbSerializedForm, inputFields); + } + + fields = await getUpdatedRouterLinkedFields(fields, routes); + + if (addFallback) { + // Add a fallback route if there is none + if (!routes.find(isFallbackRoute)) { + routes.push(createFallbackRoute()); + } + } + + return await prisma.app_RoutingForms_Form.upsert({ + where: { + id: id, + }, + create: { + user: { + connect: { + id: user.id, + }, + }, + fields, + name: name, + description, + // Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case. + routes: routes === null ? Prisma.JsonNull : routes, + id: id, + }, + update: { + disabled: disabled, + fields, + name: name, + description, + settings: settings === null ? Prisma.JsonNull : settings, + routes: routes === null ? Prisma.JsonNull : routes, + }, + }); + + /** + * If Form has Router Linked fields, enrich them with the latest info from the Router + * If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router + */ + async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) { + const routerLinkedFields: Record = {}; + for (const [, field] of Object.entries(fields)) { + if (!isRouterLinkedField(field)) { + continue; + } + routerLinkedFields[field.routerId] = true; + + if (!routes.some((route) => route.id === field.routerId)) { + // If the field is from a router that is not available anymore, mark it as deleted + field.deleted = true; + continue; + } + // Get back deleted field as now the Router is there for it. + if (field.deleted) field.deleted = false; + const router = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: field.routerId, + userId: user.id, + }, + }); + if (router) { + assertIfInvalidRouter(router); + const parsedRouterFields = zodFields.parse(router.fields); + + // There is a field from some router available, make sure that the field has up-to-date info from the router + const routerField = parsedRouterFields?.find((f) => f.id === field.id); + // Update local field(cache) with router field on every mutation + Object.assign(field, routerField); + } + } + + for (const [, route] of Object.entries(routes)) { + if (!isRouter(route)) { + continue; + } + + // If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields + if (routerLinkedFields[route.id]) { + continue; + } + + const router = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: route.id, + userId: user.id, + }, + }); + if (router) { + assertIfInvalidRouter(router); + const parsedRouterFields = zodFields.parse(router.fields); + const fieldsFromRouter = parsedRouterFields + ?.filter((f) => !f.deleted) + .map((f) => { + return { + ...f, + routerId: route.id, + }; + }); + + if (fieldsFromRouter) { + fields = fields.concat(fieldsFromRouter); + } + } + } + return fields; + } + + function findFieldWithId(id: string, fields: InputFields) { + return fields.find((field) => field.id === id); + } + + /** + * Update fields in connected forms as per the inputFields + */ + async function updateFieldsInConnectedForms( + serializedForm: SerializableForm, + inputFields: InputFields + ) { + for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) { + const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: connectedForm.id, + }, + }); + if (!connectedFormDb) { + continue; + } + const connectedFormFields = zodFields.parse(connectedFormDb.fields); + + const fieldsThatAreNotInConnectedForm = ( + inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || [] + ).map((f) => ({ + ...f, + routerId: serializedForm.id, + })); + + const updatedConnectedFormFields = connectedFormFields + // Update fields that are already in connected form + ?.map((field) => { + if (isRouterLinkedField(field) && field.routerId === serializedForm.id) { + return { + ...field, + ...findFieldWithId(field.id, inputFields || []), + }; + } + return field; + }) + // Add fields that are not there + .concat(fieldsThatAreNotInConnectedForm); + + await prisma.app_RoutingForms_Form.update({ + where: { + id: connectedForm.id, + }, + data: { + fields: updatedConnectedFormFields, + }, + }); + } + } + + async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) { + const sourceForm = await prisma.app_RoutingForms_Form.findFirst({ + where: { + userId: user.id, + id: duplicateFrom, + }, + select: { + id: true, + fields: true, + routes: true, + }, + }); + if (!sourceForm) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Form to duplicate: ${duplicateFrom} not found`, + }); + } + //TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well. + const fieldsParsed = zodFields.safeParse(sourceForm.fields); + const routesParsed = zodRoutes.safeParse(sourceForm.routes); + if (!fieldsParsed.success || !routesParsed.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Could not parse source form's fields or routes", + }); + } + + let fields, routes; + if (shouldConnect) { + routes = [ + // This connected route would automatically link the fields + zodRouterRoute.parse({ + id: sourceForm.id, + isRouter: true, + }), + ]; + fields = + fieldsParsed.data + // Deleted fields in the form shouldn't be added to the new form + ?.filter((f) => !f.deleted) + .map((f) => { + return { + id: f.id, + routerId: sourceForm.id, + label: "", + type: "", + }; + }) || []; + } else { + // Duplicate just routes and fields + // We don't want name, description and responses to be copied + routes = routesParsed.data || []; + // FIXME: Deleted fields shouldn't come in duplicate + fields = fieldsParsed.data || []; + } + return { routes, fields }; + } + + function markMissingFieldsDeleted( + serializedForm: SerializableForm, + fields: InputFields + ) { + // Find all fields that are in DB(including deleted) but not in the mutation + // e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted + const deletedFields = + serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || []; + + // Add back deleted fields in the end and mark them deleted. + // Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation + fields = fields.concat( + deletedFields.map((f) => { + f.deleted = true; + return f; + }) + ); + return fields; + } + function assertIfInvalidRouter(router: App_RoutingForms_Form) { + const routesOfRouter = zodRoutes.parse(router.routes); + if (routesOfRouter) { + if (routesOfRouter.find(isRouter)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "A form being used as a Router must be a Origin form. It must not be using any other Router.", + }); + } + } + } +}; diff --git a/packages/app-store/routing-forms/trpc/formMutation.schema.ts b/packages/app-store/routing-forms/trpc/formMutation.schema.ts new file mode 100644 index 0000000000..802e88a5fa --- /dev/null +++ b/packages/app-store/routing-forms/trpc/formMutation.schema.ts @@ -0,0 +1,20 @@ +import z from "zod"; + +import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; + +import { zodFields, zodRoutes } from "../zod"; + +export const ZFormMutationInputSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + disabled: z.boolean().optional(), + fields: zodFields, + routes: zodRoutes, + addFallback: z.boolean().optional(), + duplicateFrom: z.string().nullable().optional(), + shouldConnect: z.boolean().optional(), + settings: RoutingFormSettings.optional(), +}); + +export type TFormMutationInputSchema = z.infer; diff --git a/packages/app-store/routing-forms/trpc/formQuery.handler.ts b/packages/app-store/routing-forms/trpc/formQuery.handler.ts new file mode 100644 index 0000000000..61b8a30e54 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/formQuery.handler.ts @@ -0,0 +1,36 @@ +import type { PrismaClient } from "@prisma/client"; + +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { getSerializableForm } from "../lib/getSerializableForm"; +import type { TFormQueryInputSchema } from "./formQuery.schema"; + +interface FormsHandlerOptions { + ctx: { + prisma: PrismaClient; + user: NonNullable; + }; + input: TFormQueryInputSchema; +} +export const formQueryHandler = async ({ ctx, input }: FormsHandlerOptions) => { + const { prisma, user } = ctx; + const form = await prisma.app_RoutingForms_Form.findFirst({ + where: { + userId: user.id, + id: input.id, + }, + include: { + _count: { + select: { + responses: true, + }, + }, + }, + }); + + if (!form) { + return null; + } + + return await getSerializableForm(form); +}; diff --git a/packages/app-store/routing-forms/trpc/formQuery.schema.ts b/packages/app-store/routing-forms/trpc/formQuery.schema.ts new file mode 100644 index 0000000000..704fe59689 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/formQuery.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZFormQueryInputSchema = z.object({ + id: z.string(), +}); + +export type TFormQueryInputSchema = z.infer; diff --git a/packages/app-store/routing-forms/trpc/forms.handler.ts b/packages/app-store/routing-forms/trpc/forms.handler.ts new file mode 100644 index 0000000000..b992b6ab1e --- /dev/null +++ b/packages/app-store/routing-forms/trpc/forms.handler.ts @@ -0,0 +1,37 @@ +import type { PrismaClient } from "@prisma/client"; + +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { getSerializableForm } from "../lib/getSerializableForm"; + +interface FormsHandlerOptions { + ctx: { + prisma: PrismaClient; + user: NonNullable; + }; +} + +export const formsHandler = async ({ ctx }: FormsHandlerOptions) => { + const { prisma, user } = ctx; + const forms = await prisma.app_RoutingForms_Form.findMany({ + where: { + userId: user.id, + }, + orderBy: { + createdAt: "desc", + }, + include: { + _count: { + select: { + responses: true, + }, + }, + }, + }); + + const serializableForms = []; + for (let i = 0; i < forms.length; i++) { + serializableForms.push(await getSerializableForm(forms[i])); + } + return serializableForms; +}; diff --git a/packages/app-store/routing-forms/trpc/report.handler.ts b/packages/app-store/routing-forms/trpc/report.handler.ts new file mode 100644 index 0000000000..b2b80b2518 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/report.handler.ts @@ -0,0 +1,80 @@ +import type { PrismaClient } from "@prisma/client"; + +import logger from "@calcom/lib/logger"; +import { TRPCError } from "@calcom/trpc/server"; + +import { jsonLogicToPrisma } from "../jsonLogicToPrisma"; +import { getSerializableForm } from "../lib/getSerializableForm"; +import type { Response } from "../types/types"; +import type { TReportInputSchema } from "./report.schema"; + +interface ReportHandlerOptions { + ctx: { + prisma: PrismaClient; + }; + input: TReportInputSchema; +} +export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => { + // Can be any prisma `where` clause + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const prismaWhere: Record = input.jsonLogicQuery + ? jsonLogicToPrisma(input.jsonLogicQuery) + : {}; + const skip = input.cursor ?? 0; + const take = 50; + logger.debug( + `Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify( + input.jsonLogicQuery + )}` + ); + const form = await prisma.app_RoutingForms_Form.findUnique({ + where: { + id: input.formId, + }, + }); + + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Form not found", + }); + } + // TODO: Second argument is required to return deleted operators. + const serializedForm = await getSerializableForm(form, true); + + const rows = await prisma.app_RoutingForms_FormResponse.findMany({ + where: { + formId: input.formId, + ...prismaWhere, + }, + take, + skip, + }); + const fields = serializedForm?.fields || []; + const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : "")); + const responses: string[][] = []; + rows.forEach((r) => { + const rowResponses: string[] = []; + responses.push(rowResponses); + fields.forEach((field) => { + if (!r.response) { + return; + } + const response = r.response as Response; + const value = response[field.id]?.value || ""; + let stringValue = ""; + if (value instanceof Array) { + stringValue = value.join(", "); + } else { + stringValue = value; + } + rowResponses.push(stringValue); + }); + }); + const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take; + return { + headers, + responses, + nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length, + }; +}; diff --git a/packages/app-store/routing-forms/trpc/report.schema.ts b/packages/app-store/routing-forms/trpc/report.schema.ts new file mode 100644 index 0000000000..2b7799f14b --- /dev/null +++ b/packages/app-store/routing-forms/trpc/report.schema.ts @@ -0,0 +1,11 @@ +import z from "zod"; + +export const ZReportInputSchema = z.object({ + formId: z.string(), + jsonLogicQuery: z.object({ + logic: z.union([z.record(z.any()), z.null()]), + }), + cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type +}); + +export type TReportInputSchema = z.infer; diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts new file mode 100644 index 0000000000..7a8c1d4ef5 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/response.handler.ts @@ -0,0 +1,101 @@ +import type { PrismaClient } from "@prisma/client"; +import { Prisma } from "@prisma/client"; +import { z } from "zod"; + +import { TRPCError } from "@calcom/trpc/server"; + +import { getSerializableForm } from "../lib/getSerializableForm"; +import type { Response } from "../types/types"; +import type { TResponseInputSchema } from "./response.schema"; +import { onFormSubmission } from "./utils"; + +interface ResponseHandlerOptions { + ctx: { + prisma: PrismaClient; + }; + input: TResponseInputSchema; +} +export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => { + const { prisma } = ctx; + try { + const { response, formId } = input; + const form = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: formId, + }, + include: { + user: true, + }, + }); + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", + }); + } + + const serializableForm = await getSerializableForm(form); + if (!serializableForm.fields) { + // There is no point in submitting a form that doesn't have fields defined + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } + + const serializableFormWithFields = { + ...serializableForm, + fields: serializableForm.fields, + }; + + const missingFields = serializableFormWithFields.fields + .filter((field) => !(field.required ? response[field.id]?.value : true)) + .map((f) => f.label); + + if (missingFields.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Missing required fields ${missingFields.join(", ")}`, + }); + } + const invalidFields = serializableFormWithFields.fields + .filter((field) => { + const fieldValue = response[field.id]?.value; + // The field isn't required at this point. Validate only if it's set + if (!fieldValue) { + return false; + } + let schema; + if (field.type === "email") { + schema = z.string().email(); + } else if (field.type === "phone") { + schema = z.any(); + } else { + schema = z.any(); + } + return !schema.safeParse(fieldValue).success; + }) + .map((f) => ({ label: f.label, type: f.type })); + + if (invalidFields.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid fields ${invalidFields.map((f) => `${f.label}: ${f.type}`)}`, + }); + } + + const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({ + data: input, + }); + + await onFormSubmission(serializableFormWithFields, dbFormResponse.response as Response); + return dbFormResponse; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === "P2002") { + throw new TRPCError({ + code: "CONFLICT", + }); + } + } + throw e; + } +}; diff --git a/packages/app-store/routing-forms/trpc/response.schema.ts b/packages/app-store/routing-forms/trpc/response.schema.ts new file mode 100644 index 0000000000..9020de630a --- /dev/null +++ b/packages/app-store/routing-forms/trpc/response.schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const ZResponseInputSchema = z.object({ + formId: z.string(), + formFillerId: z.string(), + response: z.record( + z.object({ + label: z.string(), + value: z.union([z.string(), z.array(z.string())]), + }) + ), +}); + +export type TResponseInputSchema = z.infer; diff --git a/packages/app-store/routing-forms/trpc/utils.ts b/packages/app-store/routing-forms/trpc/utils.ts new file mode 100644 index 0000000000..1cb4cb65a0 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/utils.ts @@ -0,0 +1,66 @@ +import type { App_RoutingForms_Form, User } from "@prisma/client"; + +import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; +import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; +import logger from "@calcom/lib/logger"; +import { WebhookTriggerEvents } from "@calcom/prisma/client"; +import type { Ensure } from "@calcom/types/utils"; + +import ResponseEmail from "../emails/templates/response-email"; +import type { Response, SerializableForm } from "../types/types"; + +export async function onFormSubmission( + form: Ensure & { user: User }, "fields">, + response: Response +) { + const fieldResponsesByName: Record = {}; + + for (const [fieldId, fieldResponse] of Object.entries(response)) { + // Use the label lowercased as the key to identify a field. + const key = + form.fields.find((f) => f.id === fieldId)?.identifier || + (fieldResponse.label as keyof typeof fieldResponsesByName); + fieldResponsesByName[key] = fieldResponse.value; + } + + const subscriberOptions = { + userId: form.user.id, + // It isn't an eventType webhook + eventTypeId: -1, + triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, + }; + + const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => { + sendGenericWebhookPayload( + webhook.secret, + "FORM_SUBMITTED", + new Date().toISOString(), + webhook, + fieldResponsesByName + ).catch((e) => { + console.error(`Error executing routing form webhook`, webhook, e); + }); + }); + + await Promise.all(promises); + if (form.settings?.emailOwnerOnSubmission) { + logger.debug( + `Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}` + ); + await sendResponseEmail(form, response, form.user.email); + } +} + +export const sendResponseEmail = async ( + form: Pick, + response: Response, + ownerEmail: string +) => { + try { + const email = new ResponseEmail({ form: form, toAddresses: [ownerEmail], response: response }); + await email.sendEmail(); + } catch (e) { + logger.error("Error sending response email", e); + } +}; diff --git a/packages/app-store/stripepayment/lib/PaymentService.ts b/packages/app-store/stripepayment/lib/PaymentService.ts index e7155b1a79..386e5ef028 100644 --- a/packages/app-store/stripepayment/lib/PaymentService.ts +++ b/packages/app-store/stripepayment/lib/PaymentService.ts @@ -4,10 +4,10 @@ import { v4 as uuidv4 } from "uuid"; import z from "zod"; import { sendAwaitingPaymentEmail } from "@calcom/emails"; -import type { IAbstractPaymentService } from "@calcom/lib/PaymentService"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import prisma from "@calcom/prisma"; import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; import { paymentOptionEnum } from "../zod"; import { createPaymentLink } from "./client"; diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index 06887ce96d..91c48dff89 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -23,7 +23,7 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise onTimeSelect(slot.time)} - className="mb-2 flex h-auto min-h-9 w-full flex-col justify-center py-2" + className="min-h-9 mb-2 flex h-auto w-full flex-col justify-center py-2" color="secondary"> {dayjs.utc(slot.time).tz(timezone).format(timeFormat)} {bookingFull &&

{t("booking_full")}

} diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index bee4040357..cc345d433f 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -24,6 +24,7 @@ import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import { BookingStatus, MembershipRole, WorkflowMethods } from "@calcom/prisma/enums"; import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; async function getBookingToDelete(id: number | undefined, uid: string | undefined) { return await prisma.booking.findUnique({ @@ -591,14 +592,16 @@ async function handler(req: CustomRequest) { } // Posible to refactor TODO: - const paymentApp = await appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore]; + 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const PaymentService = paymentApp.lib.PaymentService as unknown as any; + const paymentInstance = new PaymentService(paymentAppCredential) as IAbstractPaymentService; + try { await paymentInstance.refund(successPayment.id); } catch (error) { diff --git a/packages/features/ee/users/server/trpc-router.ts b/packages/features/ee/users/server/trpc-router.ts index 3c4d761738..2273e6cd2c 100644 --- a/packages/features/ee/users/server/trpc-router.ts +++ b/packages/features/ee/users/server/trpc-router.ts @@ -5,7 +5,8 @@ import { defaultAvatarSrc } from "@calcom/lib/defaultAvatarImage"; import { _UserModel as User } from "@calcom/prisma/zod"; import type { inferRouterOutputs } from "@calcom/trpc"; import { TRPCError } from "@calcom/trpc"; -import { authedAdminProcedure, middleware, router } from "@calcom/trpc/server/trpc"; +import { authedAdminProcedure } from "@calcom/trpc/server/procedures/authedProcedure"; +import { router } from "@calcom/trpc/server/trpc"; export type UserAdminRouter = typeof userAdminRouter; export type UserAdminRouterOutputs = inferRouterOutputs; @@ -51,7 +52,9 @@ function exclude(user: UserType, keys: Key } /** Reusable logic that checks for admin permissions and if the requested user exists */ -const authedAdminWithUserMiddleware = middleware(async ({ ctx, next, rawInput }) => { +//const authedAdminWithUserMiddleware = middleware(); + +const authedAdminProcedureWithRequestedUser = authedAdminProcedure.use(async ({ ctx, next, rawInput }) => { const { prisma } = ctx; const parsed = userIdSchema.safeParse(rawInput); if (!parsed.success) throw new TRPCError({ code: "BAD_REQUEST", message: "User id is required" }); @@ -68,8 +71,6 @@ const authedAdminWithUserMiddleware = middleware(async ({ ctx, next, rawInput }) }); }); -const authedAdminProcedureWithRequestedUser = authedAdminProcedure.use(authedAdminWithUserMiddleware); - export const userAdminRouter = router({ get: authedAdminProcedureWithRequestedUser.input(userIdSchema).query(async ({ ctx }) => { const { requestedUser } = ctx; diff --git a/packages/features/flags/server/router.ts b/packages/features/flags/server/router.ts index 413fbd8a02..689425fb39 100644 --- a/packages/features/flags/server/router.ts +++ b/packages/features/flags/server/router.ts @@ -1,6 +1,8 @@ import { z } from "zod"; -import { authedAdminProcedure, publicProcedure, router } from "@calcom/trpc/server/trpc"; +import { authedAdminProcedure } from "@calcom/trpc/server/procedures/authedProcedure"; +import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure"; +import { router } from "@calcom/trpc/server/trpc"; import { getFeatureFlagMap } from "./utils"; diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 3e7d8ad1d8..15671e2e35 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -3,7 +3,8 @@ import md5 from "md5"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; -import { authedProcedure, isAuthed, router } from "@calcom/trpc/server/trpc"; +import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure"; +import { router } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; @@ -13,7 +14,9 @@ const UserBelongsToTeamInput = z.object({ teamId: z.coerce.number().optional().nullable(), }); -const userBelongsToTeamMiddleware = isAuthed.unstable_pipe(async ({ ctx, next, rawInput }) => { +//const userBelongsToTeamMiddleware = isAuthed.unstable_pipe(); + +const userBelongsToTeamProcedure = authedProcedure.use(async ({ ctx, next, rawInput }) => { const parse = UserBelongsToTeamInput.safeParse(rawInput); if (!parse.success) { throw new TRPCError({ code: "BAD_REQUEST" }); @@ -41,8 +44,6 @@ const userBelongsToTeamMiddleware = isAuthed.unstable_pipe(async ({ ctx, next, r return next(); }); -const userBelongsToTeamProcedure = authedProcedure.use(userBelongsToTeamMiddleware); - const UserSelect = { id: true, name: true, diff --git a/packages/lib/payment/deletePayment.ts b/packages/lib/payment/deletePayment.ts index 7de1328d22..195f221ae4 100644 --- a/packages/lib/payment/deletePayment.ts +++ b/packages/lib/payment/deletePayment.ts @@ -2,6 +2,7 @@ import type { Payment, Prisma } from "@prisma/client"; import appStore from "@calcom/app-store"; import type { AppCategories } from "@calcom/prisma/enums"; +import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; const deletePayment = async ( paymentId: Payment["id"], @@ -14,13 +15,14 @@ const deletePayment = async ( } | null; } ): Promise => { - const paymentApp = await appStore[paymentAppCredentials?.app?.dirName as keyof typeof appStore]; + const paymentApp = await appStore[paymentAppCredentials?.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 false; } - const PaymentService = paymentApp.lib.PaymentService; - const paymentInstance = new PaymentService(paymentAppCredentials); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const PaymentService = paymentApp.lib.PaymentService as any; + const paymentInstance = new PaymentService(paymentAppCredentials) as IAbstractPaymentService; const deleted = await paymentInstance.deletePayment(paymentId); return deleted; }; diff --git a/packages/lib/payment/handlePayment.ts b/packages/lib/payment/handlePayment.ts index fd8cf0df2e..7723bc904c 100644 --- a/packages/lib/payment/handlePayment.ts +++ b/packages/lib/payment/handlePayment.ts @@ -4,6 +4,7 @@ import appStore from "@calcom/app-store"; import type { EventTypeAppsList } from "@calcom/app-store/utils"; import type { EventTypeModel } from "@calcom/prisma/zod"; import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; const handlePayment = async ( evt: CalendarEvent, @@ -24,13 +25,15 @@ const handlePayment = async ( }, bookerEmail: string ) => { - const paymentApp = await appStore[paymentAppCredentials?.app?.dirName as keyof typeof appStore]; + const paymentApp = await appStore[paymentAppCredentials?.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(paymentAppCredentials); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const PaymentService = paymentApp.lib.PaymentService as any; + + const paymentInstance = new PaymentService(paymentAppCredentials) as IAbstractPaymentService; const paymentOption = selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].paymentOption || "ON_BOOKING"; diff --git a/packages/trpc/server/createContext.ts b/packages/trpc/server/createContext.ts index ed05bd238a..9030a5c829 100644 --- a/packages/trpc/server/createContext.ts +++ b/packages/trpc/server/createContext.ts @@ -1,35 +1,36 @@ import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from "next"; import type { Session } from "next-auth"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import type { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getLocaleFromHeaders } from "@calcom/lib/i18n"; import prisma from "@calcom/prisma"; -import type { SelectedCalendar, User as PrismaUser, Credential } from "@calcom/prisma/client"; +import type { SelectedCalendar, User as PrismaUser } from "@calcom/prisma/client"; import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; type CreateContextOptions = CreateNextContextOptions | GetServerSidePropsContext; export type CreateInnerContextOptions = { - session: Session | null; + session?: Session | null; locale: string; - user?: Omit< - PrismaUser, - | "locale" - | "twoFactorSecret" - | "emailVerified" - | "password" - | "identityProviderId" - | "invitedTo" - | "allowDynamicBooking" - | "verified" - > & { - locale: NonNullable; - credentials?: Credential[]; - selectedCalendars?: Partial[]; - }; - i18n: Awaited>; + user?: + | Omit< + PrismaUser, + | "locale" + | "twoFactorSecret" + | "emailVerified" + | "password" + | "identityProviderId" + | "invitedTo" + | "allowDynamicBooking" + | "verified" + > & { + locale: Exclude; + credentials?: Credential[]; + selectedCalendars?: Partial[]; + rawAvatar?: string; + }; + i18n?: Awaited>; } & Partial; export type GetSessionFn = @@ -39,8 +40,6 @@ export type GetSessionFn = }) => Promise) | (() => Promise); -const DEFAULT_SESSION_GETTER: GetSessionFn = ({ req, res }) => getServerSession({ req, res }); - /** * Inner context. Will always be available in your procedures, in contrast to the outer context. * @@ -61,16 +60,10 @@ export async function createContextInner(opts: CreateInnerContextOptions) { * Creates context for an incoming request * @link https://trpc.io/docs/context */ -export const createContext = async ( - { req, res }: CreateContextOptions, - sessionGetter: GetSessionFn = DEFAULT_SESSION_GETTER -) => { - // for API-response caching see https://trpc.io/docs/caching - const session = await sessionGetter({ req, res }); - +export const createContext = async ({ req, res }: CreateContextOptions) => { const locale = getLocaleFromHeaders(req); - const i18n = await serverSideTranslations(getLocaleFromHeaders(req), ["common", "vital"]); - const contextInner = await createContextInner({ session, i18n, locale }); + + const contextInner = await createContextInner({ locale }); return { ...contextInner, req, @@ -79,3 +72,8 @@ export const createContext = async ( }; export type TRPCContext = Awaited>; +export type TRPCContextInner = Awaited>; +export type WithLocale = T & + Required>; +export type WithSession = T & + Required>; diff --git a/packages/trpc/server/middlewares/localeMiddleware.ts b/packages/trpc/server/middlewares/localeMiddleware.ts new file mode 100644 index 0000000000..73c29cf2ec --- /dev/null +++ b/packages/trpc/server/middlewares/localeMiddleware.ts @@ -0,0 +1,19 @@ +import sessionMiddleware from "./sessionMiddleware"; + +const localeMiddleware = sessionMiddleware.unstable_pipe(async ({ ctx, next }) => { + const { serverSideTranslations } = await import("next-i18next/serverSideTranslations"); + const { user } = ctx; + + const i18n = await serverSideTranslations( + user?.locale && user?.locale !== ctx.locale ? user.locale : ctx.locale, + ["common", "vital"] + ); + + const locale = user?.locale || ctx.locale; + + return next({ + ctx: { locale, i18n, user: { ...user, locale } }, + }); +}); + +export default localeMiddleware; diff --git a/packages/trpc/server/middlewares/perfMiddleware.ts b/packages/trpc/server/middlewares/perfMiddleware.ts new file mode 100644 index 0000000000..34783f3cb9 --- /dev/null +++ b/packages/trpc/server/middlewares/perfMiddleware.ts @@ -0,0 +1,11 @@ +import { middleware } from "../trpc"; + +const perfMiddleware = middleware(async ({ path, type, next }) => { + performance.mark("Start"); + const result = await next(); + performance.mark("End"); + performance.measure(`[${result.ok ? "OK" : "ERROR"}][$1] ${type} '${path}'`, "Start", "End"); + return result; +}); + +export default perfMiddleware; diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts new file mode 100644 index 0000000000..6d7e2fddfb --- /dev/null +++ b/packages/trpc/server/middlewares/sessionMiddleware.ts @@ -0,0 +1,136 @@ +import type { Session } from "next-auth"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { defaultAvatarSrc } from "@calcom/lib/defaultAvatarImage"; + +import { TRPCError } from "@trpc/server"; +import type { Maybe } from "@trpc/server"; + +import type { TRPCContextInner } from "../createContext"; +import { middleware } from "../trpc"; + +export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe) { + const { prisma } = ctx; + if (!session?.user?.id) { + return null; + } + + const user = await prisma.user.findUnique({ + where: { + id: session.user.id, + }, + select: { + id: true, + username: true, + name: true, + email: true, + bio: true, + timeZone: true, + weekStart: true, + startTime: true, + endTime: true, + defaultScheduleId: true, + bufferTime: true, + theme: true, + createdDate: true, + hideBranding: true, + avatar: true, + twoFactorEnabled: true, + disableImpersonation: true, + identityProvider: true, + brandColor: true, + darkBrandColor: true, + away: true, + credentials: { + select: { + id: true, + type: true, + key: true, + userId: true, + appId: true, + invalid: true, + }, + orderBy: { + id: "asc", + }, + }, + selectedCalendars: { + select: { + externalId: true, + integration: true, + }, + }, + completedOnboarding: true, + destinationCalendar: true, + locale: true, + timeFormat: true, + trialEndsAt: true, + metadata: true, + role: true, + }, + }); + + // some hacks to make sure `username` and `email` are never inferred as `null` + if (!user) { + return null; + } + + const { email, username, id } = user; + if (!email || !id) { + return null; + } + + const rawAvatar = user.avatar; + // This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url + user.avatar = rawAvatar ? `${WEBAPP_URL}/${user.username}/avatar.png` : defaultAvatarSrc({ email }); + const locale = user?.locale || ctx.locale; + return { + ...user, + id, + rawAvatar, + email, + username, + locale, + }; +} + +export type UserFromSession = Awaited>; + +const getUserSession = async (ctx: TRPCContextInner) => { + const { getServerSession } = await import("@calcom/features/auth/lib/getServerSession"); + const { req, res } = ctx; + + const session = req ? await getServerSession({ req, res }) : null; + const user = session ? await getUserFromSession(ctx, session) : null; + + return { user, session }; +}; +const sessionMiddleware = middleware(async ({ ctx, next }) => { + const { user, session } = await getUserSession(ctx); + + return next({ + ctx: { user, session }, + }); +}); + +export const isAuthed = middleware(async ({ ctx, next }) => { + const { user, session } = await getUserSession(ctx); + + if (!user || !session) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + return next({ + ctx: { ...ctx, user, session }, + }); +}); + +export const isAdminMiddleware = isAuthed.unstable_pipe(({ ctx, next }) => { + const { user } = ctx; + if (user?.role !== "ADMIN") { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ ctx: { ...ctx, user: user } }); +}); + +export default sessionMiddleware; diff --git a/packages/trpc/server/procedures/authedProcedure.ts b/packages/trpc/server/procedures/authedProcedure.ts new file mode 100644 index 0000000000..8be31a0d05 --- /dev/null +++ b/packages/trpc/server/procedures/authedProcedure.ts @@ -0,0 +1,31 @@ +import perfMiddleware from "../middlewares/perfMiddleware"; +import { isAdminMiddleware, isAuthed } from "../middlewares/sessionMiddleware"; +import { procedure } from "../trpc"; +import publicProcedure from "./publicProcedure"; + +/*interface IRateLimitOptions { + intervalInMs: number; + limit: number; +} +const isRateLimitedByUserIdMiddleware = ({ intervalInMs, limit }: IRateLimitOptions) => + middleware(({ ctx, next }) => { + // validate user exists + if (!ctx.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const { isRateLimited } = rateLimit({ intervalInMs }).check(limit, ctx.user.id.toString()); + + if (isRateLimited) { + throw new TRPCError({ code: "TOO_MANY_REQUESTS" }); + } + + return next({ ctx: { user: ctx.user, session: ctx.session } }); + }); +*/ +const authedProcedure = procedure.use(perfMiddleware).use(isAuthed); +/*export const authedRateLimitedProcedure = ({ intervalInMs, limit }: IRateLimitOptions) => +authedProcedure.use(isRateLimitedByUserIdMiddleware({ intervalInMs, limit }));*/ +export const authedAdminProcedure = publicProcedure.use(isAdminMiddleware); + +export default authedProcedure; diff --git a/packages/trpc/server/procedures/publicProcedure.ts b/packages/trpc/server/procedures/publicProcedure.ts new file mode 100644 index 0000000000..1b27a25d47 --- /dev/null +++ b/packages/trpc/server/procedures/publicProcedure.ts @@ -0,0 +1,6 @@ +import perfMiddleware from "../middlewares/perfMiddleware"; +import { tRPCContext } from "../trpc"; + +const publicProcedure = tRPCContext.procedure.use(perfMiddleware); + +export default publicProcedure; diff --git a/packages/trpc/server/routers/loggedInViewer/_router.tsx b/packages/trpc/server/routers/loggedInViewer/_router.tsx index c4d8414139..c1a3a424f2 100644 --- a/packages/trpc/server/routers/loggedInViewer/_router.tsx +++ b/packages/trpc/server/routers/loggedInViewer/_router.tsx @@ -1,4 +1,5 @@ -import { authedProcedure, router } from "../../trpc"; +import authedProcedure from "../../procedures/authedProcedure"; +import { router } from "../../trpc"; import { ZAppByIdInputSchema } from "./appById.schema"; import { ZAppCredentialsByTypeInputSchema } from "./appCredentialsByType.schema"; import { ZAppsInputSchema } from "./apps.schema"; diff --git a/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.handler.ts b/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.handler.ts index 360184e605..056203991b 100644 --- a/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.handler.ts @@ -4,11 +4,11 @@ import { IS_SELF_HOSTED } from "@calcom/lib/constants"; import { TRPCError } from "@trpc/server"; -import type { CreateInnerContextOptions } from "../../createContext"; +import type { WithSession } from "../../createContext"; import type { TGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema"; type GetDownloadLinkOfCalVideoRecordingsHandlerOptions = { - ctx: CreateInnerContextOptions; + ctx: WithSession; input: TGetDownloadLinkOfCalVideoRecordingsInputSchema; }; diff --git a/packages/trpc/server/routers/publicViewer/_router.tsx b/packages/trpc/server/routers/publicViewer/_router.tsx index 6db1cca597..421adedb04 100644 --- a/packages/trpc/server/routers/publicViewer/_router.tsx +++ b/packages/trpc/server/routers/publicViewer/_router.tsx @@ -1,4 +1,7 @@ -import { publicProcedure, router } from "../../trpc"; +import localeMiddleware from "../../middlewares/localeMiddleware"; +import sessionMiddleware from "../../middlewares/sessionMiddleware"; +import publicProcedure from "../../procedures/publicProcedure"; +import { router } from "../../trpc"; import { slotsRouter } from "../viewer/slots/_router"; import { ZEventInputSchema } from "./event.schema"; import { ZSamlTenantProductInputSchema } from "./samlTenantProduct.schema"; @@ -18,7 +21,7 @@ const UNSTABLE_HANDLER_CACHE: PublicViewerRouterHandlerCache = {}; // things that unauthenticated users can query about themselves export const publicViewerRouter = router({ - session: publicProcedure.query(async ({ ctx }) => { + session: publicProcedure.use(sessionMiddleware).query(async ({ ctx }) => { if (!UNSTABLE_HANDLER_CACHE.session) { UNSTABLE_HANDLER_CACHE.session = await import("./session.handler").then((mod) => mod.sessionHandler); } @@ -33,7 +36,7 @@ export const publicViewerRouter = router({ }); }), - i18n: publicProcedure.query(async ({ ctx }) => { + i18n: publicProcedure.use(localeMiddleware).query(async ({ ctx }) => { if (!UNSTABLE_HANDLER_CACHE.i18n) { UNSTABLE_HANDLER_CACHE.i18n = await import("./i18n.handler").then((mod) => mod.i18nHandler); } @@ -43,9 +46,7 @@ export const publicViewerRouter = router({ throw new Error("Failed to load handler"); } - return UNSTABLE_HANDLER_CACHE.i18n({ - ctx, - }); + return UNSTABLE_HANDLER_CACHE.i18n({ ctx }); }), countryCode: publicProcedure.query(async ({ ctx }) => { @@ -83,27 +84,24 @@ export const publicViewerRouter = router({ }); }), - 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 - ); - } + stripeCheckoutSession: publicProcedure.input(ZStripeCheckoutSessionInputSchema).query(async ({ 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"); - } + // 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, - }); - }), + return UNSTABLE_HANDLER_CACHE.stripeCheckoutSession({ + input, + }); + }), - cityTimezones: publicProcedure.query(async ({ ctx }) => { + cityTimezones: publicProcedure.query(async () => { if (!UNSTABLE_HANDLER_CACHE.cityTimezones) { UNSTABLE_HANDLER_CACHE.cityTimezones = await import("./cityTimezones.handler").then( (mod) => mod.cityTimezonesHandler @@ -115,9 +113,7 @@ export const publicViewerRouter = router({ throw new Error("Failed to load handler"); } - return UNSTABLE_HANDLER_CACHE.cityTimezones({ - ctx, - }); + return UNSTABLE_HANDLER_CACHE.cityTimezones(); }), // REVIEW: This router is part of both the public and private viewer router? diff --git a/packages/trpc/server/routers/publicViewer/cityTimezones.handler.ts b/packages/trpc/server/routers/publicViewer/cityTimezones.handler.ts index 2228fe65f7..7dc896aedc 100644 --- a/packages/trpc/server/routers/publicViewer/cityTimezones.handler.ts +++ b/packages/trpc/server/routers/publicViewer/cityTimezones.handler.ts @@ -1,12 +1,4 @@ -import type { Session } from "next-auth"; - -type CityTimezonesOptions = { - ctx: { - session: Session | null; - }; -}; - -export const cityTimezonesHandler = async ({ ctx: _ctx }: CityTimezonesOptions) => { +export const cityTimezonesHandler = async () => { /** * Lazy loads third party dependency to avoid loading 1.5Mb for ALL tRPC procedures. * Thanks @roae for the tip 🙏 diff --git a/packages/trpc/server/routers/publicViewer/i18n.handler.ts b/packages/trpc/server/routers/publicViewer/i18n.handler.ts index 82953994b7..494e4b53b4 100644 --- a/packages/trpc/server/routers/publicViewer/i18n.handler.ts +++ b/packages/trpc/server/routers/publicViewer/i18n.handler.ts @@ -1,12 +1,16 @@ -import type { CreateInnerContextOptions } from "../../createContext"; -import { getLocale } from "../../trpc"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import type { WithLocale } from "../../createContext"; type I18nOptions = { - ctx: CreateInnerContextOptions; + ctx: WithLocale & { + req: NextApiRequest | undefined; + res: NextApiResponse | undefined; + }; }; export const i18nHandler = async ({ ctx }: I18nOptions) => { - const { locale, i18n } = await getLocale(ctx); + const { locale, i18n } = ctx; return { i18n, diff --git a/packages/trpc/server/routers/publicViewer/samlTenantProduct.handler.ts b/packages/trpc/server/routers/publicViewer/samlTenantProduct.handler.ts index 272760e7d1..1bdfb8af5b 100644 --- a/packages/trpc/server/routers/publicViewer/samlTenantProduct.handler.ts +++ b/packages/trpc/server/routers/publicViewer/samlTenantProduct.handler.ts @@ -1,19 +1,19 @@ -import type { Session } from "next-auth"; +import type { PrismaClient } from "@prisma/client"; 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; + prisma: PrismaClient; }; input: TSamlTenantProductInputSchema; }; -export const samlTenantProductHandler = async ({ ctx: _ctx, input }: SamlTenantProductOptions) => { +export const samlTenantProductHandler = ({ ctx, input }: SamlTenantProductOptions) => { + const { prisma } = ctx; const { email } = input; - return await samlTenantProduct(prisma, email); + return samlTenantProduct(prisma, email); }; diff --git a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts index 7bc54152a6..8e65440eed 100644 --- a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts +++ b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts @@ -1,13 +1,8 @@ -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; }; diff --git a/packages/trpc/server/routers/viewer/apiKeys/_router.tsx b/packages/trpc/server/routers/viewer/apiKeys/_router.tsx index 35d5f4b285..8607d57203 100644 --- a/packages/trpc/server/routers/viewer/apiKeys/_router.tsx +++ b/packages/trpc/server/routers/viewer/apiKeys/_router.tsx @@ -1,4 +1,5 @@ -import { authedProcedure, router } from "../../../trpc"; +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; import { ZCreateInputSchema } from "./create.schema"; import { ZDeleteInputSchema } from "./delete.schema"; import { ZEditInputSchema } from "./edit.schema"; diff --git a/packages/trpc/server/routers/viewer/apps/_router.tsx b/packages/trpc/server/routers/viewer/apps/_router.tsx index 1589dae1d7..7a9c633017 100644 --- a/packages/trpc/server/routers/viewer/apps/_router.tsx +++ b/packages/trpc/server/routers/viewer/apps/_router.tsx @@ -1,4 +1,5 @@ -import { authedAdminProcedure, authedProcedure, router } from "../../../trpc"; +import authedProcedure, { authedAdminProcedure } from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; import { ZListLocalInputSchema } from "./listLocal.schema"; import { ZQueryForDependenciesInputSchema } from "./queryForDependencies.schema"; import { ZSaveKeysInputSchema } from "./saveKeys.schema"; diff --git a/packages/trpc/server/routers/viewer/auth/_router.tsx b/packages/trpc/server/routers/viewer/auth/_router.tsx index 04f3e72f6b..c8b4c2d999 100644 --- a/packages/trpc/server/routers/viewer/auth/_router.tsx +++ b/packages/trpc/server/routers/viewer/auth/_router.tsx @@ -1,4 +1,5 @@ -import { router, authedProcedure } from "../../../trpc"; +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; import { ZChangePasswordInputSchema } from "./changePassword.schema"; import { ZVerifyPasswordInputSchema } from "./verifyPassword.schema"; diff --git a/packages/trpc/server/routers/viewer/availability/_router.tsx b/packages/trpc/server/routers/viewer/availability/_router.tsx index e274382ca1..c8a777ceac 100644 --- a/packages/trpc/server/routers/viewer/availability/_router.tsx +++ b/packages/trpc/server/routers/viewer/availability/_router.tsx @@ -1,4 +1,5 @@ -import { authedProcedure, router } from "../../../trpc"; +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; import { scheduleRouter } from "./schedule/_router"; import { ZUserInputSchema } from "./user.schema"; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx b/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx index 9c1af485ca..d2c6bf62ca 100644 --- a/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx +++ b/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx @@ -1,4 +1,5 @@ -import { authedProcedure, router } from "../../../../trpc"; +import authedProcedure from "../../../../procedures/authedProcedure"; +import { router } from "../../../../trpc"; import { ZCreateInputSchema } from "./create.schema"; import { ZDeleteInputSchema } from "./delete.schema"; import { ZGetInputSchema } from "./get.schema"; diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index e539268686..8e0c15a9a9 100644 --- a/packages/trpc/server/routers/viewer/bookings/_router.tsx +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -1,4 +1,5 @@ -import { authedProcedure, router } from "../../../trpc"; +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; import { ZConfirmInputSchema } from "./confirm.schema"; import { ZEditLocationInputSchema } from "./editLocation.schema"; import { ZGetInputSchema } from "./get.schema"; diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index e58a5ffe4b..7b2a3b9941 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -9,6 +9,7 @@ import { getTranslation } from "@calcom/lib/server"; import { prisma } from "@calcom/prisma"; import { BookingStatus, MembershipRole, SchedulingType } from "@calcom/prisma/enums"; import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; import { TRPCError } from "@trpc/server"; @@ -274,14 +275,15 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { } // Posible to refactor TODO: - const paymentApp = await appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore]; + 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const PaymentService = paymentApp.lib.PaymentService as any; + const paymentInstance = new PaymentService(paymentAppCredential) as IAbstractPaymentService; const paymentData = await paymentInstance.refund(successPayment.id); if (!paymentData.refunded) { throw new Error("Payment could not be refunded"); diff --git a/packages/trpc/server/routers/viewer/bookings/util.ts b/packages/trpc/server/routers/viewer/bookings/util.ts index c29e939387..ae8668987c 100644 --- a/packages/trpc/server/routers/viewer/bookings/util.ts +++ b/packages/trpc/server/routers/viewer/bookings/util.ts @@ -13,7 +13,7 @@ import { SchedulingType } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; -import { authedProcedure } from "../../../trpc"; +import authedProcedure from "../../../procedures/authedProcedure"; import { commonBookingSchema } from "./types"; export const bookingsProcedure = authedProcedure diff --git a/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx b/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx index c154c1b8f4..f823961842 100644 --- a/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx +++ b/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx @@ -1,4 +1,5 @@ -import { router, authedAdminProcedure } from "../../../trpc"; +import { authedAdminProcedure } from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; import { ZUpdateInputSchema } from "./update.schema"; type DeploymentSetupRouterHandlerCache = { diff --git a/packages/trpc/server/routers/viewer/eventTypes/_router.ts b/packages/trpc/server/routers/viewer/eventTypes/_router.ts index 37fcebc627..f84760a0d2 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/_router.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/_router.ts @@ -2,7 +2,8 @@ import { z } from "zod"; import { logP } from "@calcom/lib/perf"; -import { authedProcedure, router } from "../../../trpc"; +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; import { ZCreateInputSchema } from "./create.schema"; import { ZDeleteInputSchema } from "./delete.schema"; import { ZDuplicateInputSchema } from "./duplicate.schema"; diff --git a/packages/trpc/server/routers/viewer/eventTypes/util.ts b/packages/trpc/server/routers/viewer/eventTypes/util.ts index 21c63a2c7f..be1483f394 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/util.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/util.ts @@ -5,7 +5,7 @@ import type { CustomInputSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; -import { authedProcedure } from "../../../trpc"; +import authedProcedure from "../../../procedures/authedProcedure"; import type { EventTypeUpdateInput } from "./types"; export const eventOwnerProcedure = authedProcedure diff --git a/packages/trpc/server/routers/viewer/payments.tsx b/packages/trpc/server/routers/viewer/payments.tsx index a044a5c53a..ec4766e85a 100644 --- a/packages/trpc/server/routers/viewer/payments.tsx +++ b/packages/trpc/server/routers/viewer/payments.tsx @@ -8,7 +8,8 @@ import type { CalendarEvent } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; -import { router, authedProcedure } from "../../trpc"; +import authedProcedure from "../../procedures/authedProcedure"; +import { router } from "../../trpc"; export const paymentsRouter = router({ chargeCard: authedProcedure diff --git a/packages/trpc/server/routers/viewer/payments/_router.ts b/packages/trpc/server/routers/viewer/payments/_router.ts index f78f105c4b..56bb2814ea 100644 --- a/packages/trpc/server/routers/viewer/payments/_router.ts +++ b/packages/trpc/server/routers/viewer/payments/_router.ts @@ -1,4 +1,5 @@ -import { router, authedProcedure } from "../../../trpc"; +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; import { ZChargerCardInputSchema } from "./chargeCard.schema"; interface PaymentsRouterHandlerCache { diff --git a/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts b/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts index 24f29f6f25..17bb7bc520 100644 --- a/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts +++ b/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts @@ -4,6 +4,7 @@ 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 type { IAbstractPaymentService } from "@calcom/types/PaymentService"; import { TRPCError } from "@trpc/server"; @@ -93,14 +94,14 @@ export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid payment credential" }); } - const paymentApp = await appStore[paymentCredential?.app?.dirName as keyof typeof appStore]; + 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const PaymentService = paymentApp.lib.PaymentService as any; + const paymentInstance = new PaymentService(paymentCredential) as IAbstractPaymentService; try { const paymentData = await paymentInstance.chargeCard(booking.payment[0]); diff --git a/packages/trpc/server/routers/viewer/slots/_router.tsx b/packages/trpc/server/routers/viewer/slots/_router.tsx index bd1c9619dc..c38b4e4441 100644 --- a/packages/trpc/server/routers/viewer/slots/_router.tsx +++ b/packages/trpc/server/routers/viewer/slots/_router.tsx @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import { router, publicProcedure } from "../../../trpc"; +import publicProcedure from "../../../procedures/publicProcedure"; +import { router } from "../../../trpc"; import { ZGetScheduleInputSchema } from "./getSchedule.schema"; import { ZReserveSlotInputSchema } from "./reserveSlot.schema"; diff --git a/packages/trpc/server/routers/viewer/sso/_router.tsx b/packages/trpc/server/routers/viewer/sso/_router.tsx index 0751262350..33498ffece 100644 --- a/packages/trpc/server/routers/viewer/sso/_router.tsx +++ b/packages/trpc/server/routers/viewer/sso/_router.tsx @@ -1,4 +1,5 @@ -import { router, authedProcedure } from "../../../trpc"; +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; import { ZDeleteInputSchema } from "./delete.schema"; import { ZGetInputSchema } from "./get.schema"; import { ZUpdateInputSchema } from "./update.schema"; diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index 5d3aca2ba3..d98494ce3f 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -1,4 +1,5 @@ -import { authedProcedure, router } from "../../../trpc"; +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema"; import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema"; import { ZCreateInputSchema } from "./create.schema"; diff --git a/packages/trpc/server/routers/viewer/webhook/util.ts b/packages/trpc/server/routers/viewer/webhook/util.ts index 5e1861dafa..9157dfeef4 100644 --- a/packages/trpc/server/routers/viewer/webhook/util.ts +++ b/packages/trpc/server/routers/viewer/webhook/util.ts @@ -2,7 +2,7 @@ import { prisma } from "@calcom/prisma"; import { TRPCError } from "@trpc/server"; -import { authedProcedure } from "../../../trpc"; +import authedProcedure from "../../../procedures/authedProcedure"; import { webhookIdAndEventTypeIdSchema } from "./types"; export const webhookProcedure = authedProcedure diff --git a/packages/trpc/server/routers/viewer/workflows/_router.tsx b/packages/trpc/server/routers/viewer/workflows/_router.tsx index a273781f8d..8b8033b37b 100644 --- a/packages/trpc/server/routers/viewer/workflows/_router.tsx +++ b/packages/trpc/server/routers/viewer/workflows/_router.tsx @@ -1,4 +1,5 @@ -import { authedProcedure, router } from "../../../trpc"; +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; import { ZActivateEventTypeInputSchema } from "./activateEventType.schema"; import { ZCreateInputSchema } from "./create.schema"; import { ZDeleteInputSchema } from "./delete.schema"; diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index 6d4d36b10d..7237180faf 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -1,176 +1,17 @@ -import type { Session } from "next-auth"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import superjson from "superjson"; -import { WEBAPP_URL } from "@calcom/lib/constants"; -import { defaultAvatarSrc } from "@calcom/lib/defaultAvatarImage"; -import rateLimit from "@calcom/lib/rateLimit"; -import prisma from "@calcom/prisma"; +import { initTRPC } from "@trpc/server"; -import type { Maybe } from "@trpc/server"; -import { initTRPC, TRPCError } from "@trpc/server"; +import type { createContextInner } from "./createContext"; +import type { UserFromSession } from "./middlewares/sessionMiddleware"; -import type { createContextInner, CreateInnerContextOptions } from "./createContext"; - -async function getUserFromSession({ session }: { session: Maybe }) { - if (!session?.user?.id) { - return null; - } - - const user = await prisma.user.findUnique({ - where: { - id: session.user.id, - }, - select: { - id: true, - username: true, - name: true, - email: true, - bio: true, - timeZone: true, - weekStart: true, - startTime: true, - endTime: true, - defaultScheduleId: true, - bufferTime: true, - theme: true, - createdDate: true, - hideBranding: true, - avatar: true, - twoFactorEnabled: true, - disableImpersonation: true, - identityProvider: true, - brandColor: true, - darkBrandColor: true, - away: true, - credentials: { - select: { - id: true, - type: true, - key: true, - userId: true, - appId: true, - invalid: true, - }, - orderBy: { - id: "asc", - }, - }, - selectedCalendars: { - select: { - externalId: true, - integration: true, - }, - }, - completedOnboarding: true, - destinationCalendar: true, - locale: true, - timeFormat: true, - trialEndsAt: true, - metadata: true, - role: true, - }, - }); - - // some hacks to make sure `username` and `email` are never inferred as `null` - if (!user) { - return null; - } - const { email, username } = user; - if (!email) { - return null; - } - const rawAvatar = user.avatar; - // This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url - user.avatar = rawAvatar ? `${WEBAPP_URL}/${user.username}/avatar.png` : defaultAvatarSrc({ email }); - - return { - ...user, - rawAvatar, - email, - username, - }; -} - -export type TrpcSessionUser = Awaited>; - -const t = initTRPC.context().create({ +export const tRPCContext = 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; -}); +export const router = tRPCContext.router; +export const mergeRouters = tRPCContext.mergeRouters; +export const middleware = tRPCContext.middleware; +export const procedure = tRPCContext.procedure; -export const getLocale = async (ctx: CreateInnerContextOptions) => { - const user = await getUserFromSession({ session: ctx.session }); - - const i18n = - user?.locale && user?.locale !== ctx.locale - ? await serverSideTranslations(user.locale, ["common", "vital"]) - : ctx.i18n; - const locale = user?.locale || ctx.locale; - return { user, i18n, session: ctx.session, locale }; -}; - -export const isAuthed = t.middleware(async ({ ctx, next }) => { - const { user, session, locale, i18n } = await getLocale(ctx); - if (!user || !session) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - return next({ - ctx: { locale, i18n, user: { ...user, locale }, session }, - }); -}); - -const isAdminMiddleware = isAuthed.unstable_pipe(({ ctx, next }) => { - if (ctx.user.role !== "ADMIN") { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - return next({ - ctx: { user: ctx.user }, - }); -}); - -interface IRateLimitOptions { - intervalInMs: number; - limit: number; -} -const isRateLimitedByUserIdMiddleware = ({ intervalInMs, limit }: IRateLimitOptions) => - t.middleware(({ ctx, next }) => { - // validate user exists - if (!ctx.user) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const { isRateLimited } = rateLimit({ intervalInMs }).check(limit, ctx.user.id.toString()); - - if (isRateLimited) { - throw new TRPCError({ code: "TOO_MANY_REQUESTS" }); - } - - return next({ - ctx: { - // infers that `user` and `session` are non-nullable to downstream procedures - session: ctx.session, - user: ctx.user, - }, - }); - }); - -export const router = t.router; -export const mergeRouters = t.mergeRouters; -export const middleware = t.middleware; -export const publicProcedure = t.procedure.use(perfMiddleware); -export const authedProcedure = t.procedure.use(perfMiddleware).use(isAuthed); -export const authedRateLimitedProcedure = ({ intervalInMs, limit }: IRateLimitOptions) => - authedProcedure.use(isRateLimitedByUserIdMiddleware({ intervalInMs, limit })); -export const authedAdminProcedure = t.procedure.use(perfMiddleware).use(isAdminMiddleware); +export type TrpcSessionUser = UserFromSession; diff --git a/packages/lib/PaymentService.ts b/packages/types/PaymentService.d.ts similarity index 97% rename from packages/lib/PaymentService.ts rename to packages/types/PaymentService.d.ts index 075b0ae198..1e7fdfff7f 100644 --- a/packages/lib/PaymentService.ts +++ b/packages/types/PaymentService.d.ts @@ -19,7 +19,7 @@ export interface IAbstractPaymentService { ): Promise; chargeCard( payment: Pick, - bookingId: Booking["id"] + bookingId?: Booking["id"] ): Promise; update(paymentId: Payment["id"], data: Partial): Promise; refund(paymentId: Payment["id"]): Promise;