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 <keithwillcode@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
Efraín Rochín
2023-05-09 19:27:05 +00:00
committed by GitHub
co-authored by Keith Williams Hariom Balhara
parent 03548d8381
commit d6fb0df64f
63 changed files with 1818 additions and 1019 deletions
+1
View File
@@ -23,6 +23,7 @@ class MyDocument extends Document<Props> {
// 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");
+2 -17
View File
@@ -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<Response>) {
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,
+6 -7
View File
@@ -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"
+1 -1
View File
@@ -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;
+29 -30
View File
@@ -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;
+2 -1
View File
@@ -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";
+1 -665
View File
@@ -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<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
response: Response
) {
const fieldResponsesByName: Record<string, (typeof response)[keyof typeof response]["value"]> = {};
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<App_RoutingForms_Form, "id" | "name">,
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<typeof duplicateFrom>;
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<string, boolean> = {};
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<App_RoutingForms_Form>,
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<App_RoutingForms_Form>,
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<string, any> = 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";
@@ -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<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
response: Response
) {
const fieldResponsesByName: Record<string, (typeof response)[keyof typeof response]["value"]> = {};
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<App_RoutingForms_Form, "id" | "name">,
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<typeof duplicateFrom>;
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<string, boolean> = {};
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<App_RoutingForms_Form>,
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<App_RoutingForms_Form>,
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<string, any> = 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;
@@ -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<TrpcSessionUser>;
};
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,
},
});
};
@@ -0,0 +1,7 @@
import z from "zod";
export const ZDeleteFormInputSchema = z.object({
id: z.string(),
});
export type TDeleteFormInputSchema = z.infer<typeof ZDeleteFormInputSchema>;
@@ -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<TrpcSessionUser>;
};
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<typeof duplicateFrom>;
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<string, boolean> = {};
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<App_RoutingForms_Form>,
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<App_RoutingForms_Form>,
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.",
});
}
}
}
};
@@ -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<typeof ZFormMutationInputSchema>;
@@ -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<TrpcSessionUser>;
};
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);
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZFormQueryInputSchema = z.object({
id: z.string(),
});
export type TFormQueryInputSchema = z.infer<typeof ZFormQueryInputSchema>;
@@ -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<TrpcSessionUser>;
};
}
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;
};
@@ -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<string, any> = 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,
};
};
@@ -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<typeof ZReportInputSchema>;
@@ -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;
}
};
@@ -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<typeof ZResponseInputSchema>;
@@ -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<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
response: Response
) {
const fieldResponsesByName: Record<string, (typeof response)[keyof typeof response]["value"]> = {};
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<App_RoutingForms_Form, "id" | "name">,
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);
}
};
@@ -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";
+1 -1
View File
@@ -23,7 +23,7 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise<V
for (const cred of withCredentials) {
const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
const app = await appStore[appName as keyof typeof appStore];
const app = await appStore[appName as keyof typeof appStore]();
if (app && "lib" in app && "VideoApiAdapter" in app.lib) {
const makeVideoApiAdapter = app.lib.VideoApiAdapter as VideoApiAdapterFactory;
@@ -78,7 +78,7 @@ export const AvailableTimes = ({
data-testid="time"
data-time={slot.time}
onClick={() => 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 && <p className="text-sm">{t("booking_full")}</p>}
@@ -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) {
@@ -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<UserAdminRouter>;
@@ -51,7 +52,9 @@ function exclude<UserType, Key extends keyof UserType>(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;
+3 -1
View File
@@ -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";
@@ -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,
+5 -3
View File
@@ -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<boolean> => {
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;
};
+6 -3
View File
@@ -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";
+29 -31
View File
@@ -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<PrismaUser["locale"]>;
credentials?: Credential[];
selectedCalendars?: Partial<SelectedCalendar>[];
};
i18n: Awaited<ReturnType<typeof serverSideTranslations>>;
user?:
| Omit<
PrismaUser,
| "locale"
| "twoFactorSecret"
| "emailVerified"
| "password"
| "identityProviderId"
| "invitedTo"
| "allowDynamicBooking"
| "verified"
> & {
locale: Exclude<PrismaUser["locale"], null>;
credentials?: Credential[];
selectedCalendars?: Partial<SelectedCalendar>[];
rawAvatar?: string;
};
i18n?: Awaited<ReturnType<typeof serverSideTranslations>>;
} & Partial<CreateContextOptions>;
export type GetSessionFn =
@@ -39,8 +40,6 @@ export type GetSessionFn =
}) => Promise<Session | null>)
| (() => Promise<Session | null>);
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<ReturnType<typeof createContext>>;
export type TRPCContextInner = Awaited<ReturnType<typeof createContextInner>>;
export type WithLocale<T extends TRPCContext = any> = T &
Required<Pick<CreateInnerContextOptions, "i18n" | "locale">>;
export type WithSession<T extends TRPCContext = any> = T &
Required<Pick<CreateInnerContextOptions, "session">>;
@@ -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;
@@ -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;
@@ -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<Session>) {
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<ReturnType<typeof getUserFromSession>>;
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;
@@ -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;
@@ -0,0 +1,6 @@
import perfMiddleware from "../middlewares/perfMiddleware";
import { tRPCContext } from "../trpc";
const publicProcedure = tRPCContext.procedure.use(perfMiddleware);
export default publicProcedure;
@@ -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";
@@ -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;
};
@@ -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?
@@ -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 🙏
@@ -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,
@@ -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);
};
@@ -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;
};
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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");
@@ -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
@@ -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 = {
@@ -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";
@@ -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
@@ -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
@@ -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 {
@@ -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]);
@@ -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";
@@ -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";
@@ -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";
@@ -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
@@ -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";
+9 -168
View File
@@ -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<Session> }) {
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<ReturnType<typeof getUserFromSession>>;
const t = initTRPC.context<typeof createContextInner>().create({
export const tRPCContext = initTRPC.context<typeof createContextInner>().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;
@@ -19,7 +19,7 @@ export interface IAbstractPaymentService {
): Promise<Payment>;
chargeCard(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"]
bookingId?: Booking["id"]
): Promise<Payment>;
update(paymentId: Payment["id"], data: Partial<Prisma.PaymentUncheckedCreateInput>): Promise<Payment>;
refund(paymentId: Payment["id"]): Promise<Payment>;