Files
calendar/packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts
T
Joe Au-YeungGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Alex van Andel
3784421f02 feat: include record IDs in Salesforce assignment reason strings (#22561)
* feat: include record IDs in Salesforce assignment reason strings

- Add recordId parameter to assignmentReasonHandler function
- Include Contact ID, Lead ID, and Account ID in assignment reason strings
- Update entire call chain to pass record IDs from CRM service
- Maintain backward compatibility with optional recordId parameter

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: resolve lint warnings in assignment reason handler implementation

- Change Record<string, any> to Record<string, unknown> in BookingHandlerInput type
- Remove unused eventTypeId variable in getAttributeRoutingConfig function

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: revert to Record<string, any> with ESLint disable for BookingHandlerInput

- Revert from Record<string, unknown> to Record<string, any> to maintain type compatibility
- Add ESLint disable comment to suppress no-explicit-any warning
- Maintains consistency with handleNewRecurringBooking.ts pattern

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: pass CRM record ID from booker state to handleNewBooking

- Add crmRecordId field to booker store interface and initialization
- Update mapBookingToMutationInput to include record ID from booker state
- Modify handleNewBooking to extract record ID from bookingData parameter
- Add crmRecordId to BookingCreateBody schema in Prisma layer
- Follow existing pattern for CRM fields (teamMemberEmail, crmOwnerRecordType, crmAppSlug)
- Ensures record ID flows: booker store → booking form → mapBookingToMutationInput → handleNewBooking

This replaces the previous backend CRM service extraction approach with
frontend booker state approach as requested by the user.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Pass crmRecordId as prop

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-21 14:31:31 +00:00

236 lines
6.4 KiB
TypeScript

import type { FormResponse, Fields } from "@calcom/app-store/routing-forms/types/types";
import { zodRoutes } from "@calcom/app-store/routing-forms/zod";
import { acrossQueryValueCompatiblity } from "@calcom/lib/raqb/raqbUtils";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { getUsersAttributes } from "@calcom/lib/service/attribute/server/getAttributes";
import prisma from "@calcom/prisma";
import { AssignmentReasonEnum } from "@calcom/prisma/enums";
const { getAttributesQueryValue } = acrossQueryValueCompatiblity;
export enum RRReassignmentType {
ROUND_ROBIN = "round_robin",
MANUAL = "manual",
}
export default class AssignmentReasonRecorder {
/**
* We should use decorators to wrap the methods with withReporting
* but we can't don't have support for static methods in decorators
* so this is a workaround to wrap the methods with withReporting.
*/
static routingFormRoute = withReporting(
AssignmentReasonRecorder._routingFormRoute,
"AssignmentReasonRecorder.routingFormRoute"
);
static async _routingFormRoute({
bookingId,
routingFormResponseId,
organizerId,
teamId,
isRerouting,
reroutedByEmail,
}: {
bookingId: number;
routingFormResponseId: number;
organizerId: number;
teamId: number;
isRerouting: boolean;
reroutedByEmail?: string | null;
}) {
// Get the routing form data
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
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}`);
}
}
const reasonEnum = isRerouting
? AssignmentReasonEnum.REROUTED
: AssignmentReasonEnum.ROUTING_FORM_ROUTING;
const reasonString = `${
isRerouting && reroutedByEmail ? `Rerouted by ${reroutedByEmail}` : ""
} ${attributeValues.join(", ")}`;
await prisma.assignmentReason.create({
data: {
bookingId: bookingId,
reasonEnum,
reasonString,
},
});
return {
reasonEnum,
reasonString,
};
}
// Separate method to handle rerouting
static CRMOwnership = withReporting(
AssignmentReasonRecorder._CRMOwnership,
"AssignmentReasonRecorder.CRMOwnership"
);
static async _CRMOwnership({
bookingId,
crmAppSlug,
teamMemberEmail,
recordType,
routingFormResponseId,
recordId,
}: {
bookingId: number;
crmAppSlug: string;
teamMemberEmail: string;
recordType: string;
routingFormResponseId: number;
recordId?: string;
}) {
const appAssignmentReasonHandler = (await import("./appAssignmentReasonHandler")).default;
const appHandler = appAssignmentReasonHandler[crmAppSlug];
if (!appHandler) return;
const crmRoutingReason = await appHandler({
recordType,
teamMemberEmail,
routingFormResponseId,
recordId,
});
if (!crmRoutingReason || !crmRoutingReason.assignmentReason) return;
const { reasonEnum, assignmentReason } = crmRoutingReason;
await prisma.assignmentReason.create({
data: {
bookingId,
reasonEnum,
reasonString: assignmentReason,
},
});
return {
reasonEnum,
reasonString: assignmentReason,
};
}
static roundRobinReassignment = withReporting(
AssignmentReasonRecorder._roundRobinReassignment,
"AssignmentReasonRecorder.roundRobinReassignment"
);
static async _roundRobinReassignment({
bookingId,
reassignById,
reassignReason,
reassignmentType,
}: {
bookingId: number;
reassignById: number;
reassignReason?: string;
reassignmentType: RRReassignmentType;
}) {
const reassignedBy = await prisma.user.findUnique({
where: {
id: reassignById,
},
select: {
username: true,
},
});
const reasonEnum =
reassignmentType === RRReassignmentType.MANUAL
? AssignmentReasonEnum.REASSIGNED
: AssignmentReasonEnum.RR_REASSIGNED;
const reasonString = `Reassigned by: ${reassignedBy?.username || "team member"}. ${
reassignReason ? `Reason: ${reassignReason}` : ""
}`;
await prisma.assignmentReason.create({
data: {
bookingId: bookingId,
reasonEnum,
reasonString,
},
});
return {
reasonEnum,
reasonString,
};
}
}