2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { ZDeleteFormInputSchema } from "./deleteForm.schema";
import { ZFormMutationInputSchema } from "./formMutation.schema";
import { ZFormQueryInputSchema } from "./formQuery.schema";
import { forms } from "./procedures/forms";
import { ZReportInputSchema } from "./report.schema";
import { ZResponseInputSchema } from "./response.schema";
// eslint-disable-next-line @typescript-eslint/ban-types
const UNSTABLE_HANDLER_CACHE: Record<string, Function> = {};
// TODO: Move getHandler and UNSTABLE_HANDLER_CACHE to a common utils file making sure that there is no name collision across routes
/**
* This function will import the module defined in importer just once and then cache the default export of that module.
*
* It gives you the default export of the module.
*
* **Note: It is your job to ensure that the name provided is unique across all routes.**
*/
const getHandler = async <
T extends {
// eslint-disable-next-line @typescript-eslint/ban-types
default: Function;
}
>(
/**
* The name of the handler in cache. It has to be unique across all routes
*/
name: string,
importer: () => Promise<T>
) => {
const nameInCache = name as keyof typeof UNSTABLE_HANDLER_CACHE;
if (!UNSTABLE_HANDLER_CACHE[nameInCache]) {
const importedModule = await importer();
UNSTABLE_HANDLER_CACHE[nameInCache] = importedModule.default;
return importedModule.default as T["default"];
}
return UNSTABLE_HANDLER_CACHE[nameInCache] as unknown as T["default"];
};
const appRoutingForms = router({
public: router({
response: publicProcedure.input(ZResponseInputSchema).mutation(async ({ ctx, input }) => {
const handler = await getHandler("response", () => import("./response.handler"));
return handler({ ctx, input });
}),
}),
forms,
formQuery: authedProcedure.input(ZFormQueryInputSchema).query(async ({ ctx, input }) => {
const handler = await getHandler("formQuery", () => import("./formQuery.handler"));
return handler({ ctx, input });
}),
formMutation: authedProcedure.input(ZFormMutationInputSchema).mutation(async ({ ctx, input }) => {
const handler = await getHandler("formMutation", () => import("./formMutation.handler"));
return handler({ ctx, input });
}),
deleteForm: authedProcedure.input(ZDeleteFormInputSchema).mutation(async ({ ctx, input }) => {
const handler = await getHandler("deleteForm", () => import("./deleteForm.handler"));
return handler({ ctx, input });
}),
report: authedProcedure.input(ZReportInputSchema).query(async ({ ctx, input }) => {
const handler = await getHandler("report", () => import("./report.handler"));
return handler({ ctx, input });
}),
});
export default appRoutingForms;

View File

@@ -0,0 +1,55 @@
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import getConnectedForms from "../lib/getConnectedForms";
import { isFormCreateEditAllowed } from "../lib/isFormCreateEditAllowed";
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 isFormCreateEditAllowed({ userId: user.id, formId: input.id, targetTeamId: null }))) {
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.",
});
}
const deletedRes = await prisma.app_RoutingForms_Form.deleteMany({
where: {
id: input.id,
...entityPrismaWhereClause({ userId: user.id }),
},
});
if (!deletedRes.count) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Form seems to be already deleted.",
});
}
return deletedRes;
};
export default deleteFormHandler;

View File

@@ -0,0 +1,7 @@
import z from "zod";
export const ZDeleteFormInputSchema = z.object({
id: z.string(),
});
export type TDeleteFormInputSchema = z.infer<typeof ZDeleteFormInputSchema>;

View File

@@ -0,0 +1,390 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils";
import type { PrismaClient } from "@calcom/prisma";
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 { isFormCreateEditAllowed } from "../lib/isFormCreateEditAllowed";
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, disabled, addFallback, duplicateFrom, shouldConnect } = input;
let teamId = input.teamId;
const settings = input.settings;
if (!(await isFormCreateEditAllowed({ userId: user.id, formId: id, targetTeamId: teamId }))) {
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,
teamId: true,
position: true,
},
});
const dbSerializedForm = dbForm
? await getSerializableForm({ form: dbForm, withDeletedFields: true })
: null;
if (duplicateFrom) {
({ teamId, routes, fields } = await getRoutesAndFieldsForDuplication({ duplicateFrom, userId: user.id }));
} 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());
}
}
// Validate the users passed
if (teamId && settings?.sendUpdatesTo?.length) {
const sendUpdatesTo = await prisma.membership.findMany({
where: {
teamId,
userId: {
in: settings.sendUpdatesTo,
},
},
select: {
userId: true,
},
});
settings.sendUpdatesTo = sendUpdatesTo.map((member) => member.userId);
// If its not a team, the user is sending the value, we will just ignore it
} else if (!teamId && settings?.sendUpdatesTo) {
delete settings.sendUpdatesTo;
}
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,
...(teamId
? {
team: {
connect: {
id: teamId ?? undefined,
},
},
}
: null),
},
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,
userId,
}: {
duplicateFrom: DuplicateFrom;
userId: number;
}) {
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
where: {
...entityPrismaWhereClause({ userId }),
id: duplicateFrom,
},
select: {
id: true,
fields: true,
routes: true,
userId: true,
teamId: true,
team: {
select: {
id: true,
members: true,
},
},
},
});
if (!sourceForm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Form to duplicate: ${duplicateFrom} not found`,
});
}
if (!canEditEntity(sourceForm, userId)) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Form to duplicate: ${duplicateFrom} not found or you are unauthorized`,
});
}
//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 ? fieldsParsed.data.filter((f) => !f.deleted) : [];
}
return { teamId: sourceForm.teamId, 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.",
});
}
}
}
};
export default formMutationHandler;

View File

@@ -0,0 +1,21 @@
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(),
teamId: z.number().nullish().default(null),
shouldConnect: z.boolean().optional(),
settings: RoutingFormSettings.optional(),
});
export type TFormMutationInputSchema = z.infer<typeof ZFormMutationInputSchema>;

View File

@@ -0,0 +1,44 @@
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils";
import type { PrismaClient } from "@calcom/prisma";
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: {
AND: [
entityPrismaWhereClause({ userId: user.id }),
{
id: input.id,
},
],
},
include: {
team: { select: { slug: true, name: true } },
_count: {
select: {
responses: true,
},
},
},
});
if (!form) {
return null;
}
return await getSerializableForm({ form });
};
export default formQueryHandler;

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZFormQueryInputSchema = z.object({
id: z.string(),
});
export type TFormQueryInputSchema = z.infer<typeof ZFormQueryInputSchema>;

View File

@@ -0,0 +1,148 @@
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils";
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { entries } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { TFormSchema } from "./forms.schema";
interface FormsHandlerOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
};
input: TFormSchema;
}
const log = logger.getSubLogger({ prefix: ["[formsHandler]"] });
export const formsHandler = async ({ ctx, input }: FormsHandlerOptions) => {
const { prisma, user } = ctx;
const where = getPrismaWhereFromFilters(user, input?.filters);
log.debug("Getting forms where", JSON.stringify(where));
const forms = await prisma.app_RoutingForms_Form.findMany({
where,
orderBy: [
{
position: "desc",
},
{
createdAt: "asc",
},
],
include: {
team: {
include: {
members: true,
},
},
_count: {
select: {
responses: true,
},
},
},
});
const totalForms = await prisma.app_RoutingForms_Form.count({
where: entityPrismaWhereClause({
userId: user.id,
}),
});
const serializableForms = [];
for (let i = 0; i < forms.length; i++) {
const form = forms[i];
const hasWriteAccess = canEditEntity(form, user.id);
serializableForms.push({
form: await getSerializableForm({ form: forms[i] }),
readOnly: !hasWriteAccess,
});
}
return {
filtered: serializableForms,
totalCount: totalForms,
};
};
export default formsHandler;
type SupportedFilters = Omit<NonNullable<NonNullable<TFormSchema>["filters"]>, "upIds"> | undefined;
export function getPrismaWhereFromFilters(
user: {
id: number;
},
filters: SupportedFilters
) {
const where = {
OR: [] as Prisma.App_RoutingForms_FormWhereInput[],
};
const prismaQueries: Record<
keyof NonNullable<typeof filters>,
(...args: [number[]]) => Prisma.App_RoutingForms_FormWhereInput
> & {
all: () => Prisma.App_RoutingForms_FormWhereInput;
} = {
userIds: (userIds: number[]) => ({
userId: {
in: userIds,
},
teamId: null,
}),
teamIds: (teamIds: number[]) => ({
team: {
id: {
in: teamIds ?? [],
},
members: {
some: {
userId: user.id,
accepted: true,
},
},
},
}),
all: () => ({
OR: [
{
userId: user.id,
},
{
team: {
members: {
some: {
userId: user.id,
accepted: true,
},
},
},
},
],
}),
};
if (!filters || !hasFilter(filters)) {
where.OR.push(prismaQueries.all());
} else {
for (const entry of entries(filters)) {
if (!entry) {
continue;
}
const [filterName, filter] = entry;
const getPrismaQuery = prismaQueries[filterName];
// filter might be accidentally set undefined as well
if (!getPrismaQuery || !filter) {
continue;
}
where.OR.push(getPrismaQuery(filter));
}
}
return where;
}

View File

@@ -0,0 +1,13 @@
"use client";
import { z } from "zod";
import { filterQuerySchemaStrict } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
export const ZFormsInputSchema = z
.object({
filters: filterQuerySchemaStrict.optional(),
})
.nullish();
export type TFormSchema = z.infer<typeof ZFormsInputSchema>;

View File

@@ -0,0 +1,8 @@
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import { ZFormsInputSchema } from "../forms.schema";
export const forms = authedProcedure.input(ZFormsInputSchema).query(async ({ ctx, input }) => {
const handler = (await import("../forms.handler")).default;
return handler({ ctx, input });
});

View File

@@ -0,0 +1,81 @@
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
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, withDeletedFields: 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 | number)[][] = [];
rows.forEach((r) => {
const rowResponses: (string | number)[] = [];
responses.push(rowResponses);
fields.forEach((field) => {
if (!r.response) {
return;
}
const response = r.response as Response;
const value = response[field.id]?.value || "";
let transformedValue;
if (value instanceof Array) {
transformedValue = value.join(", ");
} else {
transformedValue = value;
}
rowResponses.push(transformedValue);
});
});
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
return {
headers,
responses,
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
};
};
export default reportHandler;

View File

@@ -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>;

View File

@@ -0,0 +1,135 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import type { PrismaClient } from "@calcom/prisma";
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
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: {
select: {
id: true,
email: 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, value: response[f.id]?.value }));
if (invalidFields.length) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid value for fields ${invalidFields
.map((f) => `'${f.label}' with value '${f.value}' should be valid ${f.type}`)
.join(", ")}`,
});
}
const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({
data: input,
});
const settings = RoutingFormSettings.parse(form.settings);
let userWithEmails: string[] = [];
if (form.teamId && settings?.sendUpdatesTo?.length) {
const userEmails = await prisma.membership.findMany({
where: {
teamId: form.teamId,
userId: {
in: settings.sendUpdatesTo,
},
},
select: {
user: {
select: {
email: true,
},
},
},
});
userWithEmails = userEmails.map((userEmail) => userEmail.user.email);
}
await onFormSubmission(
{ ...serializableFormWithFields, userWithEmails },
dbFormResponse.response as Response
);
return dbFormResponse;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
throw new TRPCError({
code: "CONFLICT",
});
}
}
throw e;
}
};
export default responseHandler;

View File

@@ -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.number(), z.array(z.string())]),
})
),
});
export type TResponseInputSchema = z.infer<typeof ZResponseInputSchema>;

View File

@@ -0,0 +1,114 @@
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 getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { WebhookTriggerEvents } from "@calcom/prisma/client";
import type { Ensure } from "@calcom/types/utils";
import type { OrderedResponses } from "../types/types";
import type { Response, SerializableForm } from "../types/types";
export async function onFormSubmission(
form: Ensure<
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
"fields"
>,
response: Response
) {
const fieldResponsesByName: Record<
string,
{
value: Response[keyof 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] = {
value: fieldResponse.value,
};
}
const { userId, teamId } = getWebhookTargetEntity(form);
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId });
const subscriberOptions = {
userId,
teamId,
orgId,
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
};
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) => {
sendGenericWebhookPayload({
secretKey: webhook.secret,
triggerEvent: "FORM_SUBMITTED",
createdAt: new Date().toISOString(),
webhook,
data: {
formId: form.id,
formName: form.name,
teamId: form.teamId,
responses: fieldResponsesByName,
},
rootData: {
// Send responses unwrapped at root level for backwards compatibility
...Object.entries(fieldResponsesByName).reduce((acc, [key, value]) => {
acc[key] = value.value;
return acc;
}, {} as Record<string, Response[keyof Response]["value"]>),
},
}).catch((e) => {
console.error(`Error executing routing form webhook`, webhook, e);
});
});
await Promise.all(promises);
const orderedResponses = form.fields.reduce((acc, field) => {
acc.push(response[field.id]);
return acc;
}, [] as OrderedResponses);
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, orderedResponses, [form.user.email]);
} else if (form.userWithEmails?.length) {
logger.debug(
`Preparing to send Form Response email for Form:${form.id} to users: ${form.userWithEmails.join(",")}`
);
await sendResponseEmail(form, orderedResponses, form.userWithEmails);
}
}
export const sendResponseEmail = async (
form: Pick<App_RoutingForms_Form, "id" | "name">,
orderedResponses: OrderedResponses,
toAddresses: string[]
) => {
try {
if (typeof window === "undefined") {
const { default: ResponseEmail } = await import("../emails/templates/response-email");
const email = new ResponseEmail({ form: form, toAddresses, orderedResponses });
await email.sendEmail();
}
} catch (e) {
logger.error("Error sending response email", e);
}
};
function getWebhookTargetEntity(form: { teamId?: number | null; user: { id: number } }) {
// If it's a team form, the target must be team webhook
// If it's a user form, the target must be user webhook
const isTeamForm = form.teamId;
return { userId: isTeamForm ? null : form.user.id, teamId: isTeamForm ? form.teamId : null };
}