first commit
This commit is contained in:
73
calcom/packages/app-store/routing-forms/trpc/_router.ts
Normal file
73
calcom/packages/app-store/routing-forms/trpc/_router.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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,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;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZFormQueryInputSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type TFormQueryInputSchema = z.infer<typeof ZFormQueryInputSchema>;
|
||||
148
calcom/packages/app-store/routing-forms/trpc/forms.handler.ts
Normal file
148
calcom/packages/app-store/routing-forms/trpc/forms.handler.ts
Normal 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;
|
||||
}
|
||||
13
calcom/packages/app-store/routing-forms/trpc/forms.schema.ts
Normal file
13
calcom/packages/app-store/routing-forms/trpc/forms.schema.ts
Normal 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>;
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
135
calcom/packages/app-store/routing-forms/trpc/response.handler.ts
Normal file
135
calcom/packages/app-store/routing-forms/trpc/response.handler.ts
Normal 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;
|
||||
@@ -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>;
|
||||
114
calcom/packages/app-store/routing-forms/trpc/utils.ts
Normal file
114
calcom/packages/app-store/routing-forms/trpc/utils.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user