feat: Record assignment reasons for routing form and reassignment (#17767)
* Add assignment reason table * Increment id * Update migration * Record assignment when manual reassignment is used * Add comment to `getAttributesQueryValue` * Add `getUsersAttributes` * Record routing form routing reason * Type fixes * Remove unique property from bookingId * Handle nested values
This commit is contained in:
@@ -31,6 +31,7 @@ import getICalUID from "@calcom/emails/lib/getICalUID";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
|
||||
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
|
||||
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
|
||||
import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
allowDisablingHostConfirmationEmails,
|
||||
@@ -1165,6 +1166,16 @@ async function handler(
|
||||
await usersRepository.updateLastActiveAt(booking.userId);
|
||||
}
|
||||
|
||||
if (routingFormResponseId && teamId) {
|
||||
await AssignmentReasonRecorder.routingFormRoute({
|
||||
bookingId: booking.id,
|
||||
routingFormResponseId,
|
||||
organizerId: organizerUser.id,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
evt.uid = booking.uid ?? null;
|
||||
evt.oneTimePassword = booking.oneTimePassword ?? null;
|
||||
if (booking && booking.id && eventType.seatsPerTimeSlot) {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { FormResponse, Fields } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { zodRoutes } from "@calcom/app-store/routing-forms/zod";
|
||||
import { getUsersAttributes } from "@calcom/lib/raqb/getAttributes";
|
||||
import { acrossQueryValueCompatiblity } from "@calcom/lib/raqb/raqbUtils";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { AssignmentReasonEnum } from "@calcom/prisma/enums";
|
||||
|
||||
const { getAttributesQueryValue } = acrossQueryValueCompatiblity;
|
||||
|
||||
export default class AssignmentReasonRecorder {
|
||||
static async routingFormRoute({
|
||||
bookingId,
|
||||
routingFormResponseId,
|
||||
organizerId,
|
||||
teamId,
|
||||
}: {
|
||||
bookingId: number;
|
||||
routingFormResponseId: number;
|
||||
organizerId: number;
|
||||
teamId: number;
|
||||
}) {
|
||||
// Get the routing form data
|
||||
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findFirst({
|
||||
where: {
|
||||
id: routingFormResponseId,
|
||||
},
|
||||
include: {
|
||||
form: {
|
||||
select: {
|
||||
routes: true,
|
||||
fields: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!routingFormResponse) return;
|
||||
// Figure out which route was called
|
||||
const { form } = routingFormResponse;
|
||||
if (!form.routes || !form.fields) return;
|
||||
|
||||
const parsedRoutes = zodRoutes.safeParse(form.routes);
|
||||
|
||||
if (!parsedRoutes.success || !parsedRoutes.data) return;
|
||||
|
||||
const chosenRouteId = routingFormResponse.chosenRouteId;
|
||||
|
||||
const takenRoute = parsedRoutes.data.find((route) => route.id === chosenRouteId);
|
||||
|
||||
if (!takenRoute) return;
|
||||
|
||||
// Get a user's attributes
|
||||
const usersAttributes = await getUsersAttributes({ userId: organizerId, teamId });
|
||||
|
||||
if (!("attributesQueryValue" in takenRoute)) return;
|
||||
|
||||
const formAttributesQuery = takenRoute.attributesQueryValue;
|
||||
|
||||
// Figure out the attributes associated with that route
|
||||
const attributesQueryValue = getAttributesQueryValue({
|
||||
attributesQueryValue: formAttributesQuery,
|
||||
attributes: usersAttributes,
|
||||
dynamicFieldValueOperands: {
|
||||
fields: (form.fields as Fields) || [],
|
||||
response: routingFormResponse.response as FormResponse,
|
||||
},
|
||||
});
|
||||
|
||||
if (!attributesQueryValue) return;
|
||||
|
||||
const attributesUsedToRoute = attributesQueryValue.children1;
|
||||
|
||||
if (!attributesUsedToRoute) return;
|
||||
|
||||
const attributeValues: string[] = [];
|
||||
|
||||
for (const attribute of Object.keys(attributesUsedToRoute)) {
|
||||
const attributeToFilter = attributesUsedToRoute[attribute].properties;
|
||||
|
||||
if (!attributeToFilter) continue;
|
||||
|
||||
const userAttribute = usersAttributes.find((attribute) => attributeToFilter.field === attribute.id);
|
||||
|
||||
const attributeValue = attributeToFilter.value;
|
||||
|
||||
if (!userAttribute || !attributeValue || typeof attributeValue[0] === null) continue;
|
||||
|
||||
if (attributeValue && attributeValue[0]) {
|
||||
const attributeValueString = (() => {
|
||||
if (Array.isArray(attributeValue[0])) {
|
||||
return attributeValue[0][0];
|
||||
} else {
|
||||
return attributeValue[0];
|
||||
}
|
||||
})();
|
||||
|
||||
attributeValues.push(`${userAttribute?.name}: ${attributeValueString}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.assignmentReason.create({
|
||||
data: {
|
||||
bookingId: bookingId,
|
||||
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
|
||||
reasonString: attributeValues.join(", "),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Separate method to handle rerouting
|
||||
|
||||
// Routing via CRM ownership, need to keep each specific CRM logic in it's own package
|
||||
|
||||
static async roundRobinReassignment({
|
||||
bookingId,
|
||||
reassignById,
|
||||
reassignReason,
|
||||
}: {
|
||||
bookingId: number;
|
||||
reassignById: number;
|
||||
reassignReason?: string;
|
||||
}) {
|
||||
const reassignedBy = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: reassignById,
|
||||
},
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
|
||||
const reasonString = `Reassigned by: ${reassignedBy?.username || "team member"}. ${
|
||||
reassignReason ? `Reason: ${reassignReason}` : ""
|
||||
}`;
|
||||
|
||||
await prisma.assignmentReason.create({
|
||||
data: {
|
||||
bookingId: bookingId,
|
||||
reasonEnum: AssignmentReasonEnum.REASSIGNED,
|
||||
reasonString,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { sendRoundRobinCancelledEmailsAndSMS, sendRoundRobinScheduledEmailsAndSM
|
||||
import getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
|
||||
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
|
||||
import {
|
||||
scheduleEmailReminder,
|
||||
deleteScheduledEmailReminder,
|
||||
@@ -170,6 +171,12 @@ export const roundRobinManualReassignment = async ({
|
||||
},
|
||||
select: bookingSelect,
|
||||
});
|
||||
|
||||
await AssignmentReasonRecorder.roundRobinReassignment({
|
||||
bookingId,
|
||||
reassignReason,
|
||||
reassignById: reassignedById,
|
||||
});
|
||||
} else if (currentRRHost) {
|
||||
// Update the round-robin host attendee
|
||||
await prisma.attendee.update({
|
||||
|
||||
@@ -52,7 +52,7 @@ async function getAttributeToUserWithMembershipAndAttributesForTeam({ teamId }:
|
||||
return attributesToUser;
|
||||
}
|
||||
|
||||
async function getAttributesAssignedToMembersOfTeam({ teamId }: { teamId: number }) {
|
||||
async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId: number; userId?: number }) {
|
||||
const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] });
|
||||
|
||||
const whereClauseForAttributesAssignedToMembersOfTeam = {
|
||||
@@ -61,6 +61,7 @@ async function getAttributesAssignedToMembersOfTeam({ teamId }: { teamId: number
|
||||
assignedUsers: {
|
||||
some: {
|
||||
member: {
|
||||
userId,
|
||||
user: {
|
||||
teams: {
|
||||
some: {
|
||||
@@ -148,3 +149,7 @@ export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamI
|
||||
|
||||
return Object.values(teamMembers);
|
||||
}
|
||||
|
||||
export async function getUsersAttributes({ userId, teamId }: { userId: number; teamId: number }) {
|
||||
return await getAttributesAssignedToMembersOfTeam({ teamId, userId });
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ function getAttributesData({
|
||||
return acc;
|
||||
}, {} as Record<string, string | string[]>);
|
||||
}
|
||||
|
||||
/** Gets the attributes that were used to generate the chosen route and their values */
|
||||
function getAttributesQueryValue({
|
||||
attributesQueryValue,
|
||||
attributes,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AssignmentReasonEnum" AS ENUM ('ROUTING_FORM_ROUTING', 'ROUTING_FORM_ROUTING_FALLBACK', 'REASSIGNED', 'REROUTED', 'SALESFORCE_ASSIGNMENT');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AssignmentReason" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"bookingId" INTEGER NOT NULL,
|
||||
"reasonEnum" "AssignmentReasonEnum" NOT NULL,
|
||||
"reasonString" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "AssignmentReason_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AssignmentReason_id_key" ON "AssignmentReason"("id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AssignmentReason" ADD CONSTRAINT "AssignmentReason_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -648,6 +648,7 @@ model Booking {
|
||||
rescheduledBy String?
|
||||
// Ah, made a typo here. Should have been routedFromRoutingFormRe"s"ponse. Live with it :(
|
||||
routedFromRoutingFormReponse App_RoutingForms_FormResponse?
|
||||
assignmentReason AssignmentReason[]
|
||||
|
||||
@@index([eventTypeId])
|
||||
@@index([userId])
|
||||
@@ -1608,6 +1609,23 @@ model AttributeToUser {
|
||||
@@unique([memberId, attributeOptionId])
|
||||
}
|
||||
|
||||
enum AssignmentReasonEnum {
|
||||
ROUTING_FORM_ROUTING
|
||||
ROUTING_FORM_ROUTING_FALLBACK
|
||||
REASSIGNED
|
||||
REROUTED
|
||||
SALESFORCE_ASSIGNMENT
|
||||
}
|
||||
|
||||
model AssignmentReason {
|
||||
id Int @id @unique @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
bookingId Int
|
||||
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
reasonEnum AssignmentReasonEnum
|
||||
reasonString String
|
||||
}
|
||||
|
||||
enum EventTypeAutoTranslatedField {
|
||||
DESCRIPTION // Currently the only field we translate
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user