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:
Joe Au-Yeung
2024-11-21 16:27:19 +00:00
committed by GitHub
parent 5d51c79739
commit ff79e5c70e
7 changed files with 206 additions and 2 deletions
@@ -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({
+6 -1
View File
@@ -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 });
}
+1 -1
View File
@@ -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;
+18
View File
@@ -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
}