From ff79e5c70eebf9710ac180e6a4db626041634c06 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Thu, 21 Nov 2024 11:27:19 -0500 Subject: [PATCH] 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 --- .../features/bookings/lib/handleNewBooking.ts | 11 ++ .../AssignmentReasonRecorder.ts | 144 ++++++++++++++++++ .../roundRobinManualReassignment.ts | 7 + packages/lib/raqb/getAttributes.ts | 7 +- packages/lib/raqb/raqbUtils.ts | 2 +- .../migration.sql | 19 +++ packages/prisma/schema.prisma | 18 +++ 7 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts create mode 100644 packages/prisma/migrations/20241121043631_add_assignment_reason_table/migration.sql diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 48582a4fe5..052bd5cdf5 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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) { diff --git a/packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts b/packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts new file mode 100644 index 0000000000..435bf8a3cb --- /dev/null +++ b/packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts @@ -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, + }, + }); + } +} diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.ts index 2237119e9d..762e568f68 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.ts @@ -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({ diff --git a/packages/lib/raqb/getAttributes.ts b/packages/lib/raqb/getAttributes.ts index 6656a35b63..01542a528f 100644 --- a/packages/lib/raqb/getAttributes.ts +++ b/packages/lib/raqb/getAttributes.ts @@ -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 }); +} diff --git a/packages/lib/raqb/raqbUtils.ts b/packages/lib/raqb/raqbUtils.ts index e8aee1fe05..107888a457 100644 --- a/packages/lib/raqb/raqbUtils.ts +++ b/packages/lib/raqb/raqbUtils.ts @@ -220,7 +220,7 @@ function getAttributesData({ return acc; }, {} as Record); } - +/** Gets the attributes that were used to generate the chosen route and their values */ function getAttributesQueryValue({ attributesQueryValue, attributes, diff --git a/packages/prisma/migrations/20241121043631_add_assignment_reason_table/migration.sql b/packages/prisma/migrations/20241121043631_add_assignment_reason_table/migration.sql new file mode 100644 index 0000000000..6e7625cf31 --- /dev/null +++ b/packages/prisma/migrations/20241121043631_add_assignment_reason_table/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 868613c474..9074c06404 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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 }