* wip * Add preview mode in router * only fetch bookings of this month for weighted rr * test set up * only get bookings of current month * reverts test setup * first changes for weight adjustments * reset booking count + adjust calibration * depreciate weightAdjustment * get only bookings created this month * fix typo * make sure createdAt for hosts is correct * use earliest possibel date as fallback * add missing createdAt date to tests * fix typo * clean up changes in tests * fix typo * change end date to current date * fix: Fall back to empty host array when no hosts are found * fix: Restructure code a little * fixed test, incorrectly used now outdated var * perf: remove Dayjs from getLuckyUser * Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array * Better mock for host.findMany * Remove team-event-types.test.ts, move to appropriate package * TypeScript cannot auto-infer that an array is non-empty when assigning to a var * fix: Type Fixes and DistributionMethod enum add * Optimise tests * Added test to show that bookings made before a newHost was added affect the lucky user result * Throw error when the usersWithHighestPriority is empty, which should never happen * WIP * remove comment * update migrations * get attributes weights and virtual queue data * use attribute weights and use bookings of virtual queue only * clean up migrations * Add shortfall column and add tests * code clean up from feedback * wrapper function for getLuckyUser * code clean up * fetch routingFormResponse in handleNewBooking * fix type errors * fix type errors in tests * fix getAttributesQueryValue import for tests * fix totalWeight * add test for attributes weights and virtual queues * clean up code * add test for prepareQueuesAndAttributesData * remove console.log * use lazy import * Add more tests and more columns to matching members queue * fix issue from merge * always send usersAndTheirBookingShortfalls --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
244 lines
7.9 KiB
TypeScript
244 lines
7.9 KiB
TypeScript
import { Prisma } from "@prisma/client";
|
|
import { z } from "zod";
|
|
|
|
import { emailSchema } from "@calcom/lib/emailSchema";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import type { PrismaClient } from "@calcom/prisma";
|
|
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
|
import { TRPCError } from "@calcom/trpc/server";
|
|
|
|
// import { RoutingFormFieldType } from "../lib/FieldTypes";
|
|
import { findTeamMembersMatchingAttributeLogicOfRoute } from "../lib/findTeamMembersMatchingAttributeLogicOfRoute";
|
|
import { getSerializableForm } from "../lib/getSerializableForm";
|
|
import type { FormResponse } from "../types/types";
|
|
import type { TResponseInputSchema } from "./response.schema";
|
|
import { onFormSubmission } from "./utils";
|
|
|
|
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/response.handler"] });
|
|
|
|
interface ResponseHandlerOptions {
|
|
ctx: {
|
|
prisma: PrismaClient;
|
|
};
|
|
input: TResponseInputSchema;
|
|
}
|
|
export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => {
|
|
const { prisma } = ctx;
|
|
try {
|
|
const { response, formId, chosenRouteId } = 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 = emailSchema;
|
|
} 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 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);
|
|
}
|
|
|
|
const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId);
|
|
if (!chosenRoute) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Chosen route not found",
|
|
});
|
|
}
|
|
|
|
const teamMembersMatchingAttributeLogicWithResult =
|
|
form.teamId && chosenRouteId
|
|
? await findTeamMembersMatchingAttributeLogicOfRoute({
|
|
response,
|
|
route: chosenRoute,
|
|
form: serializableForm,
|
|
teamId: form.teamId,
|
|
})
|
|
: null;
|
|
|
|
moduleLogger.debug(
|
|
"teamMembersMatchingAttributeLogic",
|
|
safeStringify({ teamMembersMatchingAttributeLogicWithResult })
|
|
);
|
|
|
|
const teamMemberIdsMatchingAttributeLogic =
|
|
teamMembersMatchingAttributeLogicWithResult?.teamMembersMatchingAttributeLogic
|
|
? teamMembersMatchingAttributeLogicWithResult.teamMembersMatchingAttributeLogic.map(
|
|
(member) => member.userId
|
|
)
|
|
: null;
|
|
|
|
// const chosenRouteName = `Route ${chosenRouteIndex + 1}`;
|
|
|
|
// if (input.isPreview) {
|
|
// // Detect if response has value for a field that isn't in the field list
|
|
// const formFields = serializableFormWithFields.fields.map((field) => field.id);
|
|
// const extraFields = Object.keys(response).filter((fieldId) => !formFields.includes(fieldId));
|
|
// const attributeRoutingConfig =
|
|
// "attributeRoutingConfig" in chosenRoute ? chosenRoute.attributeRoutingConfig ?? null : null;
|
|
|
|
// let previewData = {
|
|
// teamMemberIdsMatchingAttributeLogic,
|
|
// chosenRoute: {
|
|
// name: chosenRouteName,
|
|
// action: "action" in chosenRoute ? chosenRoute.action : null,
|
|
// },
|
|
// skipContactOwner: attributeRoutingConfig?.skipContactOwner ?? false,
|
|
// warnings: [] as string[],
|
|
// errors: [] as string[],
|
|
// };
|
|
|
|
// if (extraFields.length > 0) {
|
|
// // If response submitted directly through the /response.handler, it is useful to know which fields were non-existent
|
|
// // If we reach here through router, all extra fields are already removed from here
|
|
// previewData.warnings.push(
|
|
// `Response contains values for non-existent fields: ${extraFields.join(", ")}`
|
|
// );
|
|
// }
|
|
|
|
// // Check for values not present in options for SINGLE_SELECT and MULTISELECT fields
|
|
// serializableFormWithFields.fields.forEach((field) => {
|
|
// if (
|
|
// field.type !== RoutingFormFieldType.SINGLE_SELECT &&
|
|
// field.type !== RoutingFormFieldType.MULTI_SELECT
|
|
// ) {
|
|
// return;
|
|
// }
|
|
|
|
// const fieldResponse = response[field.id];
|
|
|
|
// if (fieldResponse && fieldResponse.value) {
|
|
// const values = Array.isArray(fieldResponse.value) ? fieldResponse.value : [fieldResponse.value];
|
|
// const invalidValues = values.filter(
|
|
// (value) => !field.options?.some((option) => option.id === value || option.label === value)
|
|
// );
|
|
// if (invalidValues.length > 0) {
|
|
// previewData.errors.push(`Invalid value(s) for ${field.label}: ${invalidValues.join(", ")}`);
|
|
// }
|
|
// }
|
|
// });
|
|
|
|
// return {
|
|
// isPreview: true,
|
|
// previewData,
|
|
// formResponse: null,
|
|
// teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic,
|
|
// };
|
|
// }
|
|
|
|
const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({
|
|
data: {
|
|
formId,
|
|
response: response,
|
|
chosenRouteId,
|
|
},
|
|
});
|
|
|
|
await onFormSubmission(
|
|
{ ...serializableFormWithFields, userWithEmails },
|
|
dbFormResponse.response as FormResponse,
|
|
dbFormResponse.id,
|
|
"action" in chosenRoute ? chosenRoute.action : undefined
|
|
);
|
|
|
|
return {
|
|
isPreview: false,
|
|
formResponse: dbFormResponse,
|
|
teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic,
|
|
attributeRoutingConfig:
|
|
"attributeRoutingConfig" in chosenRoute ? chosenRoute.attributeRoutingConfig ?? null : null,
|
|
};
|
|
} catch (e) {
|
|
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
|
if (e.code === "P2002") {
|
|
throw new TRPCError({
|
|
code: "CONFLICT",
|
|
});
|
|
}
|
|
}
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
export default responseHandler;
|