feat: org-wide workflows (#15083)

* add org to create workflow button

* add save button for testing in dev mode

* add select all to multi select dropdown

* fix select all

* pass isOrg to WorkflowDetailsPage

* add checkbox to apply to all including future

* fix count text on select

* WIP schema

* shows teams in dropdown

* add team option to UI

* [WIP] refactor for update handler

* filter out "all" from activeOn

* fix type error

* create more helper functions

* create reusable function for scheduling all booking reminders

* finish update workflows for orgs (without testing)

* fix isAuthorized check for orgs

* fix getting newActiveOn

* move all helper functions to util file

* more code clean up

* fix deleting reminders for org workflows

* fix adding and editing steps

* update form data when workflow is saved

* code clean up

* fixing creating/deleting reminders when user is member of several teams

* fix setting activeOn for teams in useffect

* add on delete cascade

* make multiSelectSchenbox searchable

* set activeOn correctly when checkbox true

* WIP

* fix type errors in MultiSelectCheckboxes

* implemented scheduling org-wide workfow notifications (not yet tested)

* fix type errors

* type error

* add missing changes from merge conflict

* remote not needed include statement

* fix type errors

* code clean up + some todo comments

* support org workflows for cancelled workflows

* delete reminders from removed members

* remove reminders if isActiveOnAll is turned off

* fix unti test and type error

* code clean up

* create basis for testing to book org team event

* create org workflow with active team

* fix getting active org workflows on team + test setup

* fix creating workflow step for tests

* fix first org test

* add test for user event type with org workflow active

* use deleteAllWorkflowReminders everywhere

* add test for deleteRemindersFromRemovedActiveOn

* fix type errors

* make all tests pass

* fix type error

* fix getSchedule test

* code clean up

* add missing import

* fix type error

* fix tests

* code clean up

* fix imports

* update reminders when trigger was changed

* check for teamId before userId in reminderScheduler

* move getOrgIdFromMemberOrTeamId to different folder

* code clean up

* fix tests

* test setup for scheduleBookingReminders

* fix typo

* add tests for scheduleBookingReminders

* fix prisma default import

* fix workflowStep type

* add scheduleBookingReminders test for sms

* return dummy sid for scheduleSMS testMode

* clean up + fixes

* add lost changes from merge

* get teamId and userId from incoming evt object

* removing not needed select

* add org support for scheduleMandatory email reminder

* add other teams to dropdown

* move getAllWorkflows to seperate file and call it in parent function

* include org wide workflows in createNewSeat

* some fixes + code clean up

* add new team to select text count when including future teams is checked

* fix upsert and remove sms reminder field

* correctly update activeOn if 'including future ...' is enabled

* list active Org workflows in event workflows settings

* fix sms reminder field in all handlers

* add helper function to check if step was edited

* fix active on badge on workflow

* fix type error

* fix double reminders

* add teamId: null for userWorkflow query

* fix activeOnAll with managed event types

* add missing teamId in getAllWorkflows

* add a dafaut to prisma param

* fix managed event types on select all user workflows

* code clean up

* better variable name

* small fixes in update handler

* fix test name to match function name

* add info badge

* fix workflow count in event type settings

* fix getting bookings from children manged event types

* delete reminders when user is not part of any time no more

* implement feedback

* fix disbale workflow in event type settings

* fix remove member

* create new function getAllWorkflowsFromEventType

* add some removed code

* use promise.allSettled when deleting workflow reminders

* create new function deleteWorkflowRemindersOfRemovedMember.ts

* fix userId param

* delete org worklfows when team is disbanded

* don't trigger active on all workflow if not part of any team

* fix active on count badge

* add test for deleteWorkflowRemidnersOfRemovedMember

* trigger workflow also if not member of any subteam

* fix failing test

* remove unused code

* use testId for go back button

* fixes for managed event types & activateEventTypeHandler

* code clean up

* don't activate workflow on locked managed event type

* fix type error

* type error

* more type fixes

* feedback

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2024-07-08 17:44:11 +05:30
1 parent dca2922ef8
commit c4104af8a2
66 files changed
+3633 -1341

No files matched your search

@@ -4,8 +4,8 @@ import { OrganizationsTeamsController } from "@/modules/organizations/controller
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
+2 -4
View File
@@ -1,5 +1,6 @@
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -61,10 +62,7 @@ export const getEventTypesFromDB = async (id: number) => {
workflows: {
select: {
workflow: {
select: {
id: true,
steps: true,
},
select: workflowSelect,
},
},
},
@@ -14,6 +14,7 @@ import { z } from "zod";
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
import { getEventLocationType } from "@calcom/app-store/locations";
import { validateCustomEventName } from "@calcom/core/event";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { validateIntervalLimitOrder } from "@calcom/lib";
@@ -144,7 +145,7 @@ const querySchema = z.object({
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
const EventTypePage = (props: EventTypeSetupProps) => {
const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workflow[] }) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const telemetry = useTelemetry();
@@ -464,11 +465,10 @@ const EventTypePage = (props: EventTypeSetupProps) => {
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
recurring: <EventRecurringTab eventType={eventType} />,
apps: <EventAppsTab eventType={{ ...eventType, URL: permalink }} />,
workflows: (
<EventWorkflowsTab
eventType={eventType}
workflows={eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow)}
/>
workflows: props.allActiveWorkflows ? (
<EventWorkflowsTab eventType={eventType} workflows={props.allActiveWorkflows} />
) : (
<></>
),
webhooks: <EventWebhooksTab eventType={eventType} />,
ai: <EventAITab eventType={eventType} isTeamEvent={!!team} />,
@@ -705,7 +705,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
<EventTypeSingleLayout
enabledAppsNumber={numberOfActiveApps}
installedAppsNumber={eventTypeApps?.items.length || 0}
enabledWorkflowsNumber={eventType.workflows.length}
enabledWorkflowsNumber={props.allActiveWorkflows ? props.allActiveWorkflows.length : 0}
eventType={eventType}
activeWebhooksNumber={eventType.webhooks.filter((webhook) => webhook.active).length}
team={team}
@@ -853,7 +853,25 @@ const EventTypePageWrapper: React.FC<PageProps> & {
const { data } = trpc.viewer.eventTypes.get.useQuery({ id: props.type });
if (!data) return null;
return <EventTypePage {...(data as EventTypeSetupProps)} />;
const eventType = data.eventType;
const { data: workflows } = trpc.viewer.workflows.getAllActiveWorkflows.useQuery({
eventType: {
workflows: eventType.workflows,
teamId: eventType.teamId,
userId: eventType.userId,
parent: eventType.parent,
metadata: eventType.metadata,
},
});
const propsData = {
...(data as EventTypeSetupProps),
allActiveWorkflows: workflows,
};
return <EventTypePage {...propsData} />;
};
export default EventTypePageWrapper;
+1 -1
View File
@@ -119,7 +119,7 @@ test.describe("Availablity", () => {
await expect(page.getByTestId("availablity-title")).toHaveValue("More working hours");
});
await test.step("Can delete a schedule", async () => {
await page.getByRole("button", { name: /Go Back/i }).click();
await page.getByTestId("go-back-button").click();
await page.locator('[data-testid="schedules"] > li').nth(1).getByTestId("schedule-more").click();
await page.locator('[data-testid="delete-schedule"]').click();
const toast = await page.waitForSelector('[data-testid="toast-success"]');
+2 -1
View File
@@ -30,6 +30,7 @@ export function createWorkflowPageFixture(page: Page) {
await selectEventType("30 min");
}
await saveWorkflow();
await page.getByTestId("go-back-button").click();
};
const saveWorkflow = async () => {
@@ -71,7 +72,7 @@ export function createWorkflowPageFixture(page: Page) {
};
const selectEventType = async (name: string) => {
await page.getByText("Select...").click();
await page.getByTestId("multi-select-check-boxes").click();
await page.getByText(name, { exact: true }).click();
};
+2 -1
View File
@@ -19,12 +19,13 @@ test.describe("Workflow Tab - Event Type", () => {
await assertListCount(3);
});
test("Editing an existing workflow", async ({ workflowPage }) => {
test("Editing an existing workflow", async ({ workflowPage, page }) => {
const { saveWorkflow, fillNameInput, editSelectedWorkflow, hasWorkflowInList } = workflowPage;
await editSelectedWorkflow("Test Workflow");
await fillNameInput("Edited Workflow");
await saveWorkflow();
await page.getByTestId("go-back-button").click();
await hasWorkflowInList("Edited Workflow");
});
@@ -1239,6 +1239,8 @@
"may_require_confirmation": "May require confirmation",
"nr_event_type_one": "{{count}} event type",
"nr_event_type_other": "{{count}} event types",
"count_team_one": "{{count}} team",
"count_team_other": "{{count}} teams",
"add_action": "Add action",
"set_whereby_link": "Set Whereby link",
"invalid_whereby_link": "Please enter a valid Whereby Link",
@@ -1276,7 +1278,12 @@
"problem_updating_calendar": "There was a problem updating your calendar",
"active_on_event_types_one": "Active on {{count}} event type",
"active_on_event_types_other": "Active on {{count}} event types",
"active_on_teams_one": "Active on {{count}} team",
"active_on_teams_other": "Active on {{count}} teams",
"active_on_all_event_types": "Active on all event types",
"active_on_all_teams": "Active on all teams",
"no_active_event_types": "No active event types",
"no_active_teams": "No active teams",
"new_seat_subject": "New Attendee {{name}} on {{eventType}} at {{date}}",
"new_seat_title": "Someone has added themselves to an event",
"variable": "Variable",
@@ -1342,6 +1349,9 @@
"2fa_required": "Two factor authentication required",
"incorrect_2fa": "Incorrect two factor authentication code",
"which_event_type_apply": "Which event type will this apply to?",
"apply_to_all_event_types": "Apply to all, including future event types",
"apply_to_all_teams": "Apply to all team and user event types",
"which_team_apply": "Which team will this apply to?",
"no_workflows_description": "Workflows enable simple automation to send notifications & reminders enabling you to build processes around your events.",
"timeformat_profile_hint": "This is an internal setting and will not affect how times are displayed on public booking pages for you or anyone booking you.",
"create_workflow": "Create a workflow",
@@ -2480,6 +2490,7 @@
"mark_as_no_show_title": "Mark as no show",
"x_marked_as_no_show": "{{x}} marked as no-show",
"x_unmarked_as_no_show": "{{x}} unmarked as no-show",
"team_select_info": "triggers for all team event types and all team members' personal event types",
"no_show_updated": "No-show status updated for attendees",
"email_copied": "Email copied",
"USER_PENDING_MEMBER_OF_THE_ORG": "User is a pending member of the organization",
+15 -18
View File
@@ -1419,24 +1419,21 @@ describe("getSchedule", () => {
});
const scenario = await createBookingScenario(
getScenarioData(
{
eventTypes: [
{
id: 1,
slotInterval: 45,
length: 45,
users: [
{
id: 101,
},
],
},
],
organizer,
},
{ id: org.id }
)
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 45,
length: 45,
users: [
{
id: 101,
},
],
},
],
organizer,
})
);
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
@@ -18,7 +18,7 @@ import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import type { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/client";
import type { SchedulingType, SMSLockState } from "@calcom/prisma/enums";
import type { SchedulingType, SMSLockState, TimeUnit } from "@calcom/prisma/enums";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { userMetadataType } from "@calcom/prisma/zod-utils";
@@ -46,10 +46,14 @@ type InputWorkflow = {
userId?: number | null;
teamId?: number | null;
name?: string;
activeEventTypeId?: number;
activeOn?: number[];
activeOnTeams?: number[];
trigger: WorkflowTriggerEvents;
action: WorkflowActions;
template: WorkflowTemplates;
time?: number | null;
timeUnit?: TimeUnit | null;
sendTo?: string;
};
type InputHost = {
@@ -95,6 +99,7 @@ type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
id: number;
name: string;
slug: string;
parentId?: number;
};
}[];
schedules: {
@@ -130,6 +135,10 @@ export type InputEventType = {
beforeEventBuffer?: number;
afterEventBuffer?: number;
teamId?: number | null;
team?: {
id?: number | null;
parentId?: number | null;
};
requiresConfirmation?: boolean;
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
schedule?: InputUser["schedules"][number];
@@ -444,32 +453,75 @@ async function addWebhooks(webhooks: InputWebhook[]) {
}
async function addWorkflowsToDb(workflows: InputWorkflow[]) {
await prismock.$transaction(
workflows.map((workflow) => {
return prismock.workflow.create({
await Promise.all(
workflows.map(async (workflow) => {
const team = await prismock.team.findFirst({
where: {
id: workflow.teamId ?? 0,
},
});
if (workflow.teamId && !team) {
throw new Error(`Team with ID ${workflow.teamId} not found`);
}
const isOrg = team?.isOrganization;
// Create the workflow first
const createdWorkflow = await prismock.workflow.create({
data: {
userId: workflow.userId,
teamId: workflow.teamId,
trigger: workflow.trigger,
name: workflow.name ? workflow.name : "Test Workflow",
steps: {
create: {
stepNumber: 1,
action: workflow.action,
template: workflow.template,
numberVerificationPending: false,
includeCalendarEvent: false,
},
},
activeOn: {
create: workflow.activeEventTypeId ? { eventTypeId: workflow.activeEventTypeId } : undefined,
},
time: workflow.time,
timeUnit: workflow.timeUnit,
},
include: {
activeOn: true,
steps: true,
},
});
await prismock.workflowStep.create({
data: {
stepNumber: 1,
action: workflow.action,
template: workflow.template,
numberVerificationPending: false,
includeCalendarEvent: false,
sendTo: workflow.sendTo,
workflow: {
connect: {
id: createdWorkflow.id,
},
},
},
});
//activate event types and teams on workflows
if (isOrg && workflow.activeOnTeams) {
await Promise.all(
workflow.activeOnTeams.map((id) =>
prismock.workflowsOnTeams.create({
data: {
workflowId: createdWorkflow.id,
teamId: id,
},
})
)
);
} else if (workflow.activeOn) {
await Promise.all(
workflow.activeOn.map((id) =>
prismock.workflowsOnEventTypes.create({
data: {
workflowId: createdWorkflow.id,
eventTypeId: id,
},
})
)
);
}
})
);
}
@@ -510,9 +562,25 @@ export async function addUsersToDb(
export async function addTeamsToDb(teams: NonNullable<InputUser["teams"]>[number]["team"][]) {
log.silly("TestData: Creating Teams", JSON.stringify(teams));
await prismock.team.createMany({
data: teams,
});
for (const team of teams) {
const teamsWithParentId = {
...team,
parentId: team.parentId,
};
await prismock.team.upsert({
where: {
id: teamsWithParentId.id,
},
update: {
...teamsWithParentId,
},
create: {
...teamsWithParentId,
},
});
}
const addedTeams = await prismock.team.findMany({
where: {
id: {
@@ -643,17 +711,34 @@ export async function createOrganization(orgData: {
name: string;
slug: string;
metadata?: z.infer<typeof teamMetadataSchema>;
withTeam?: boolean;
}) {
const org = await prismock.team.create({
data: {
name: orgData.name,
slug: orgData.slug,
isOrganization: true,
metadata: {
...(orgData.metadata || {}),
isOrganization: true,
},
},
});
if (orgData.withTeam) {
await prismock.team.create({
data: {
name: "Org Team",
slug: "org-team",
isOrganization: false,
parent: {
connect: {
id: org.id,
},
},
},
});
}
return org;
}
@@ -1125,6 +1210,10 @@ export function getScenarioData(
return {
...eventType,
teamId: eventType.teamId || null,
team: {
id: eventType.teamId,
parentId: org ? org.id : null,
},
title: `Test Event Type - ${index + 1}`,
description: `It's a test event type - ${index + 1}`,
};
+35 -14
View File
@@ -350,33 +350,54 @@ export function expectWorkflowToBeNotTriggered({
export function expectSMSWorkflowToBeTriggered({
sms,
toNumber,
includedString,
}: {
sms: Fixtures["sms"];
toNumber: string;
includedString?: string;
}) {
expect(sms.get()).toEqual(
expect.arrayContaining([
expect.objectContaining({
to: toNumber,
}),
])
);
const allSMS = sms.get();
if (includedString) {
const messageWithIncludedString = allSMS.find((sms) => sms.message.includes(includedString));
expect(messageWithIncludedString?.to).toBe(toNumber);
} else {
expect(allSMS).toEqual(
expect.arrayContaining([
expect.objectContaining({
to: toNumber,
}),
])
);
}
}
export function expectSMSWorkflowToBeNotTriggered({
sms,
toNumber,
includedString,
}: {
sms: Fixtures["sms"];
toNumber: string;
includedString?: string;
}) {
expect(sms.get()).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
to: toNumber,
}),
])
);
const allSMS = sms.get();
if (includedString) {
const messageWithIncludedString = allSMS.find((sms) => sms.message.includes(includedString));
if (messageWithIncludedString) {
expect(messageWithIncludedString?.to).not.toBe(toNumber);
}
} else {
expect(allSMS).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
to: toNumber,
}),
])
);
}
}
export async function expectBookingToBeInDatabase(
@@ -1,7 +1,8 @@
import type { EventTypeCustomInput, EventType, Prisma, Workflow } from "@prisma/client";
import type { EventTypeCustomInput, EventType } from "@prisma/client";
import type { z } from "zod";
import { SMS_REMINDER_NUMBER_FIELD } from "@calcom/features/bookings/lib/SystemField";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import { fieldsThatSupportLabelAsSafeHtml } from "@calcom/features/form-builder/fieldsThatSupportLabelAsSafeHtml";
import { getFieldIdentifier } from "@calcom/features/form-builder/utils/getFieldIdentifier";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
@@ -67,20 +68,9 @@ export const getBookingFieldsWithSystemFields = ({
disableBookingTitle?: boolean;
customInputs: EventTypeCustomInput[] | z.infer<typeof customInputSchema>[];
metadata: EventType["metadata"] | z.infer<typeof EventTypeMetaDataSchema>;
workflows: Prisma.EventTypeGetPayload<{
select: {
workflows: {
select: {
workflow: {
select: {
id: true;
steps: true;
};
};
};
};
};
}>["workflows"];
workflows: {
workflow: Workflow;
}[];
}) => {
const parsedMetaData = EventTypeMetaDataSchema.parse(metadata || {});
const parsedBookingFields = eventTypeBookingFields.parse(bookingFields || []);
@@ -109,20 +99,9 @@ export const ensureBookingInputsHaveSystemFields = ({
disableBookingTitle?: boolean;
additionalNotesRequired: boolean;
customInputs: z.infer<typeof customInputSchema>[];
workflows: Prisma.EventTypeGetPayload<{
select: {
workflows: {
select: {
workflow: {
select: {
id: true;
steps: true;
};
};
};
};
};
}>["workflows"];
workflows: {
workflow: Workflow;
}[];
}) => {
// If bookingFields is set already, the migration is done.
const hideBookingTitle = disableBookingTitle ?? true;
@@ -7,10 +7,8 @@ import EventManager from "@calcom/core/EventManager";
import dayjs from "@calcom/dayjs";
import { sendCancelledEmails } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows";
import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import { deleteScheduledWhatsappReminder } from "@calcom/features/ee/workflows/lib/reminders/whatsappReminderManager";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/scheduleTrigger";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
@@ -25,9 +23,13 @@ import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { EventTypeMetaDataSchema, schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
import {
deleteAllWorkflowReminders,
getAllWorkflowsFromEventType,
} from "@calcom/trpc/server/routers/viewer/workflows/util";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { getAllCredentials } from "./getAllCredentialsForUsersOnEvent/getAllCredentials";
@@ -86,6 +88,11 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
},
},
parentId: true,
parent: {
select: {
teamId: true,
},
},
userId: true,
recurringEvent: true,
title: true,
@@ -105,11 +112,9 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
},
},
workflows: {
include: {
select: {
workflow: {
include: {
steps: true,
},
select: workflowSelect,
},
},
},
@@ -324,18 +329,17 @@ async function handler(req: CustomRequest) {
);
await Promise.all(promises);
//Workflows - schedule reminders
if (bookingToDelete.eventType?.workflows) {
await sendCancelledReminders({
workflows: bookingToDelete.eventType?.workflows,
smsReminderNumber: bookingToDelete.smsReminderNumber,
evt: {
...evt,
...{ eventType: { slug: bookingToDelete.eventType.slug } },
},
hideBranding: !!bookingToDelete.eventType.owner?.hideBranding,
});
}
const workflows = await getAllWorkflowsFromEventType(bookingToDelete.eventType, bookingToDelete.userId);
await sendCancelledReminders({
workflows,
smsReminderNumber: bookingToDelete.smsReminderNumber,
evt: {
...evt,
...{ eventType: { slug: bookingToDelete.eventType?.slug } },
},
hideBranding: !!bookingToDelete.eventType?.owner?.hideBranding,
});
let updatedBookings: {
id: number;
@@ -471,15 +475,7 @@ async function handler(req: CustomRequest) {
webhookTriggerPromises.push(deleteWebhookScheduledTriggers({ booking }));
//Workflows - cancel all reminders for cancelled bookings
for (const reminder of booking.workflowReminders) {
if (reminder.method === WorkflowMethods.EMAIL) {
workflowReminderPromises.push(deleteScheduledEmailReminder(reminder.id, reminder.referenceId));
} else if (reminder.method === WorkflowMethods.SMS) {
workflowReminderPromises.push(deleteScheduledSMSReminder(reminder.id, reminder.referenceId));
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
workflowReminderPromises.push(deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId));
}
}
workflowReminderPromises.push(deleteAllWorkflowReminders(booking.workflowReminders));
}
await Promise.all([...webhookTriggerPromises, ...workflowReminderPromises]).catch((error) => {
@@ -1,10 +1,15 @@
import type { Prisma, Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import type { EventManagerUser } from "@calcom/core/EventManager";
import EventManager from "@calcom/core/EventManager";
import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder";
import { sendScheduledEmails } from "@calcom/emails";
import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { scheduleTrigger } from "@calcom/features/webhooks/lib/scheduleTrigger";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
@@ -17,13 +22,9 @@ import { safeStringify } from "@calcom/lib/safeStringify";
import type { PrismaClient } from "@calcom/prisma";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "../../ee/workflows/lib/allowDisablingStandardEmails";
const log = logger.getSubLogger({ prefix: ["[handleConfirmation] book:user"] });
export async function handleConfirmation(args: {
@@ -47,10 +48,11 @@ export async function handleConfirmation(args: {
} | null;
teamId?: number | null;
parentId?: number | null;
parent?: {
teamId: number | null;
} | null;
workflows?: {
workflow: Workflow & {
steps: WorkflowStep[];
};
workflow: Workflow;
}[];
} | null;
metadata?: Prisma.JsonValue;
@@ -66,6 +68,9 @@ export async function handleConfirmation(args: {
const results = scheduleResult.results;
const metadata: AdditionalInformation = {};
const eventType = booking.eventType;
const workflows = await getAllWorkflowsFromEventType(eventType, booking.userId);
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
@@ -86,8 +91,6 @@ export async function handleConfirmation(args: {
let isHostConfirmationEmailsDisabled = false;
let isAttendeeConfirmationEmailDisabled = false;
const workflows = eventType?.workflows?.map((workflow) => workflow.workflow);
if (workflows) {
isHostConfirmationEmailsDisabled =
eventTypeMetadata?.disableStandardEmails?.confirmation?.host || false;
@@ -133,11 +136,6 @@ export async function handleConfirmation(args: {
owner: {
hideBranding?: boolean | null;
} | null;
workflows: (WorkflowsOnEventTypes & {
workflow: Workflow & {
steps: WorkflowStep[];
};
})[];
} | null;
}[] = [];
@@ -180,15 +178,6 @@ export async function handleConfirmation(args: {
hideBranding: true,
},
},
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
},
},
description: true,
@@ -234,15 +223,6 @@ export async function handleConfirmation(args: {
hideBranding: true,
},
},
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
},
},
uid: true,
@@ -273,15 +253,17 @@ export async function handleConfirmation(args: {
evtOfBooking.endTime = updatedBookings[index].endTime.toISOString();
evtOfBooking.uid = updatedBookings[index].uid;
const isFirstBooking = index === 0;
await scheduleMandatoryReminder(
evtOfBooking,
updatedBookings[index]?.eventType?.workflows || [],
workflows,
false,
!!updatedBookings[index].eventType?.owner?.hideBranding,
evt.attendeeSeatId
);
await scheduleWorkflowReminders({
workflows: updatedBookings[index]?.eventType?.workflows || [],
workflows,
smsReminderNumber: updatedBookings[index].smsReminderNumber,
calendarEvent: evtOfBooking,
isFirstRecurringEvent: isFirstBooking,
@@ -296,12 +278,12 @@ export async function handleConfirmation(args: {
try {
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: booking.eventType?.teamId ?? null },
parentId: booking?.eventType?.parentId ?? null,
team: { id: eventType?.teamId ?? null },
parentId: eventType?.parentId ?? null,
},
});
const triggerForUser = !teamId || (teamId && booking.eventType?.parentId);
const triggerForUser = !teamId || (teamId && eventType?.parentId);
const userId = triggerForUser ? booking.userId : null;
@@ -318,14 +300,14 @@ export async function handleConfirmation(args: {
userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
teamId: booking.eventType?.teamId,
teamId: eventType?.teamId,
orgId,
});
const subscribersMeetingEnded = await getWebhooks({
userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
teamId: booking.eventType?.teamId,
teamId: eventType?.teamId,
orgId,
});
@@ -359,12 +341,12 @@ export async function handleConfirmation(args: {
await Promise.all(scheduleTriggerPromises);
const eventTypeInfo: EventTypeInfo = {
eventTitle: booking.eventType?.title,
eventDescription: booking.eventType?.description,
requiresConfirmation: booking.eventType?.requiresConfirmation || null,
price: booking.eventType?.price,
currency: booking.eventType?.currency,
length: booking.eventType?.length,
eventTitle: eventType?.title,
eventDescription: eventType?.description,
requiresConfirmation: eventType?.requiresConfirmation || null,
price: eventType?.price,
currency: eventType?.currency,
length: eventType?.length,
};
const promises = subscribersBookingCreated.map((sub) =>
@@ -372,7 +354,7 @@ export async function handleConfirmation(args: {
...evt,
...eventTypeInfo,
bookingId,
eventTypeId: booking.eventType?.id,
eventTypeId: eventType?.id,
status: "ACCEPTED",
smsReminderNumber: booking.smsReminderNumber || undefined,
metadata: meetingUrl ? { videoCallUrl: meetingUrl } : undefined,
@@ -392,7 +374,7 @@ export async function handleConfirmation(args: {
userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_PAID,
teamId: booking.eventType?.teamId,
teamId: eventType?.teamId,
orgId,
});
const bookingWithPayment = await prisma.booking.findFirst({
@@ -417,9 +399,9 @@ export async function handleConfirmation(args: {
const paymentMetadata = {
identifier: "cal.com",
bookingId,
eventTypeId: booking.eventType?.id,
eventTypeId: eventType?.id,
bookerEmail: evt.attendees[0].email,
eventTitle: booking.eventType?.title,
eventTitle: eventType?.title,
externalId: paymentExternalId,
};
const bookingPaidSubscribers = subscriberMeetingPaid.map((sub) =>
@@ -427,7 +409,7 @@ export async function handleConfirmation(args: {
...evt,
...eventTypeInfo,
bookingId,
eventTypeId: booking.eventType?.id,
eventTypeId: eventType?.id,
status: "ACCEPTED",
smsReminderNumber: booking.smsReminderNumber || undefined,
paymentId: bookingWithPayment?.payment?.[0].id,
@@ -42,10 +42,7 @@ import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import {
cancelWorkflowReminders,
scheduleWorkflowReminders,
} from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { getFullName } from "@calcom/features/form-builder/utils";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
@@ -83,6 +80,10 @@ import {
bookingCreateSchemaLegacyPropsForApi,
userMetadata as userMetadataSchema,
} from "@calcom/prisma/zod-utils";
import {
deleteAllWorkflowReminders,
getAllWorkflowsFromEventType,
} from "@calcom/trpc/server/routers/viewer/workflows/util";
import type {
AdditionalInformation,
AppsStatus,
@@ -1460,6 +1461,8 @@ async function handler(
orgId,
};
const workflows = await getAllWorkflowsFromEventType(eventType, organizerUser.id);
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
if (eventType.seatsPerTimeSlot) {
const newBooking = await handleSeats({
@@ -1492,6 +1495,7 @@ async function handler(
subscriberOptions,
eventTrigger,
responses,
workflows,
});
if (newBooking) {
@@ -1647,15 +1651,8 @@ async function handler(
//this is the actual rescheduling logic
if (!eventType.seatsPerTimeSlot && originalRescheduledBooking?.uid) {
log.silly("Rescheduling booking", originalRescheduledBooking.uid);
try {
// cancel workflow reminders from previous rescheduled booking
await cancelWorkflowReminders(originalRescheduledBooking.workflowReminders);
} catch (error) {
loggerWithEventDetails.error(
"Error while canceling scheduled workflow reminders",
JSON.stringify({ error })
);
}
// cancel workflow reminders from previous rescheduled booking
await deleteAllWorkflowReminders(originalRescheduledBooking.workflowReminders);
evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt);
@@ -1974,21 +1971,17 @@ async function handler(
let isHostConfirmationEmailsDisabled = false;
let isAttendeeConfirmationEmailDisabled = false;
const workflows = eventType.workflows.map((workflow) => workflow.workflow);
isHostConfirmationEmailsDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
isAttendeeConfirmationEmailDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false;
if (eventType.workflows) {
isHostConfirmationEmailsDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
isAttendeeConfirmationEmailDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false;
if (isHostConfirmationEmailsDisabled) {
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
}
if (isHostConfirmationEmailsDisabled) {
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
}
if (isAttendeeConfirmationEmailDisabled) {
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
}
if (isAttendeeConfirmationEmailDisabled) {
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
}
loggerWithEventDetails.debug(
@@ -2249,7 +2242,7 @@ async function handler(
await scheduleMandatoryReminder(
evtWithMetadata,
eventType.workflows || [],
workflows,
!isConfirmedByDefault,
!!eventType.owner?.hideBranding,
evt.attendeeSeatId
@@ -2257,7 +2250,7 @@ async function handler(
try {
await scheduleWorkflowReminders({
workflows: eventType.workflows,
workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: evtWithMetadata,
isNotConfirmed: rescheduleUid ? false : !isConfirmedByDefault,
@@ -1,4 +1,5 @@
import type { LocationObject } from "@calcom/app-store/locations";
import { workflowSelect } from "@calcom/ee/workflows/lib/getAllWorkflows";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import prisma, { userSelect } from "@calcom/prisma";
@@ -59,6 +60,11 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
durationLimits: true,
assignAllTeamMembers: true,
parentId: true,
parent: {
select: {
teamId: true,
},
},
useEventTypeDestinationCalendarEmail: true,
owner: {
select: {
@@ -66,11 +72,9 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
},
},
workflows: {
include: {
select: {
workflow: {
include: {
steps: true,
},
select: workflowSelect,
},
},
},
@@ -654,15 +654,6 @@ describe("handleNewBooking", () => {
appId: null,
},
],
workflows: [
{
userId: organizer.id,
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
},
],
eventTypes: [
{
id: 1,
@@ -119,7 +119,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -291,7 +291,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -454,7 +454,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -609,7 +609,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -730,7 +730,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -895,7 +895,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -1457,7 +1457,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -1583,7 +1583,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -1707,7 +1707,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -1839,7 +1839,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -2046,7 +2046,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -2152,7 +2152,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -2311,7 +2311,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -96,7 +96,7 @@ describe("handleNewBooking", () => {
trigger: "RESCHEDULE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -333,7 +333,7 @@ describe("handleNewBooking", () => {
trigger: "RESCHEDULE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -545,7 +545,7 @@ describe("handleNewBooking", () => {
trigger: "RESCHEDULE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -728,7 +728,7 @@ describe("handleNewBooking", () => {
trigger: "RESCHEDULE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -935,7 +935,7 @@ describe("handleNewBooking", () => {
trigger: "RESCHEDULE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -1184,7 +1184,7 @@ describe("handleNewBooking", () => {
trigger: "RESCHEDULE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -1430,7 +1430,7 @@ describe("handleNewBooking", () => {
trigger: "RESCHEDULE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -1642,7 +1642,7 @@ describe("handleNewBooking", () => {
trigger: "RESCHEDULE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -10,6 +10,7 @@ import {
BookingLocations,
getDate,
Timezones,
createOrganization,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest";
import {
@@ -71,14 +72,14 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
{
userId: organizer.id,
trigger: "NEW_EVENT",
action: "SMS_ATTENDEE",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -186,7 +187,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "SMS_ATTENDEE",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -310,14 +311,14 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
{
teamId: 1,
trigger: "NEW_EVENT",
action: "SMS_ATTENDEE",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -463,7 +464,7 @@ describe("handleNewBooking", () => {
trigger: "NEW_EVENT",
action: "SMS_ATTENDEE",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
@@ -539,4 +540,238 @@ describe("handleNewBooking", () => {
timeout
);
});
describe("Org Workflows", () => {
test("should trigger workflow when a new team event is booked and this team is active on org workflow", async ({
emails,
}) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const org = await createOrganization({
name: "Test Org",
slug: "testorg",
});
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
organizationId: org.id,
teams: [
{
membership: {
accepted: true,
},
team: {
id: 2,
name: "Team 1",
slug: "team-1",
parentId: org.id,
},
},
{
membership: {
accepted: true,
},
team: {
id: 1,
name: "Test Org",
slug: "testorg",
},
},
],
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData(
{
workflows: [
{
teamId: 1,
trigger: "NEW_EVENT",
action: "EMAIL_ATTENDEE",
template: "REMINDER",
activeOnTeams: [2],
},
],
eventTypes: [
{
id: 1,
slotInterval: 15,
schedulingType: SchedulingType.COLLECTIVE,
length: 15,
users: [
{
id: 101,
},
],
teamId: 2,
},
],
organizer: {
...organizer,
username: "organizer",
},
apps: [TestData.apps["daily-video"]],
},
{ id: org.id }
)
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
// Try booking the first available free timeslot in both the users' schedules
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
await handleNewBooking(req);
expectWorkflowToBeTriggered({
emailsToReceive: ["booker@example.com"],
emails,
});
});
test("should trigger workflow when a new user event is booked and the user is part of an org team that is active on a org workflow", async ({
sms,
}) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const org = await createOrganization({
name: "Test Org",
slug: "testorg",
});
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
organizationId: org.id,
teams: [
{
membership: {
accepted: true,
},
team: {
id: 2,
name: "Team 1",
slug: "team-1",
parentId: org.id,
},
},
{
membership: {
accepted: true,
},
team: {
id: 1,
name: "Test Org",
slug: "testorg",
},
},
],
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData(
{
workflows: [
{
teamId: 1,
trigger: "NEW_EVENT",
action: "SMS_ATTENDEE",
template: "REMINDER",
activeOnTeams: [2],
},
],
eventTypes: [
{
id: 1,
slotInterval: 15,
length: 15,
users: [
{
id: 101,
},
],
},
],
organizer: {
...organizer,
username: "organizer",
},
apps: [TestData.apps["daily-video"]],
},
{ id: org.id }
)
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
// Try booking the first available free timeslot in both the users' schedules
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
smsReminderNumber: "000",
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
await handleNewBooking(req);
expectSMSWorkflowToBeTriggered({
sms,
toNumber: "000",
});
});
});
});
@@ -1,9 +1,6 @@
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { updateMeeting } from "@calcom/core/videoClient";
import { sendCancelledSeatEmails } from "@calcom/emails";
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import { deleteScheduledWhatsappReminder } from "@calcom/features/ee/workflows/lib/reminders/whatsappReminderManager";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { HttpError } from "@calcom/lib/http-error";
@@ -11,9 +8,10 @@ import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import { WebhookTriggerEvents, WorkflowMethods } from "@calcom/prisma/enums";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
import { deleteAllWorkflowReminders } from "@calcom/trpc/server/routers/viewer/workflows/util";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CustomRequest } from "../../handleCancelBooking";
@@ -140,23 +138,11 @@ async function cancelAttendeeSeat(
);
await Promise.all(promises);
const workflowRemindersForAttendee = bookingToDelete?.workflowReminders.filter(
(reminder) => reminder.seatReferenceId === seatReferenceUid
);
const workflowRemindersForAttendee =
bookingToDelete?.workflowReminders.filter((reminder) => reminder.seatReferenceId === seatReferenceUid) ??
null;
if (workflowRemindersForAttendee && workflowRemindersForAttendee.length !== 0) {
const deletionPromises = workflowRemindersForAttendee.map((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
return deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
return deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
return deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
}
});
await Promise.allSettled(deletionPromises);
}
await deleteAllWorkflowReminders(workflowRemindersForAttendee);
return { success: true };
}
@@ -36,6 +36,7 @@ const createNewSeat = async (
fullName,
bookerEmail,
responses,
workflows,
} = rescheduleSeatedBookingObject;
let { evt } = rescheduleSeatedBookingObject;
let resultBooking: HandleSeatsResultBooking;
@@ -117,21 +118,16 @@ const createNewSeat = async (
let isHostConfirmationEmailsDisabled = false;
let isAttendeeConfirmationEmailDisabled = false;
const workflows = eventType.workflows.map((workflow) => workflow.workflow);
isHostConfirmationEmailsDisabled = eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
isAttendeeConfirmationEmailDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false;
if (eventType.workflows) {
isHostConfirmationEmailsDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
isAttendeeConfirmationEmailDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false;
if (isHostConfirmationEmailsDisabled) {
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
}
if (isHostConfirmationEmailsDisabled) {
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
}
if (isAttendeeConfirmationEmailDisabled) {
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
}
if (isAttendeeConfirmationEmailDisabled) {
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
}
await sendScheduledSeatsEmails(
copyEvent,
@@ -29,6 +29,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
subscriberOptions,
eventTrigger,
evt,
workflows,
} = newSeatedBookingObject;
const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug);
@@ -101,7 +102,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
};
try {
await scheduleWorkflowReminders({
workflows: eventType.workflows,
workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
isNotConfirmed: evt.requiresConfirmation || false,
+2
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@prisma/client";
import type z from "zod";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import type { AppsStatus } from "@calcom/types/Calendar";
import type { Booking, NewBookingEventType, OriginalRescheduledBooking } from "../handleNewBooking";
@@ -37,6 +38,7 @@ export type NewSeatedBookingObject = {
subscriberOptions: GetSubscriberOptions;
eventTrigger: WebhookTriggerEvents;
responses: z.infer<ReturnType<typeof getBookingDataSchema>>["responses"] | null;
workflows: Workflow[];
};
export type RescheduleSeatedBookingObject = NewSeatedBookingObject & { rescheduleUid: string };
@@ -0,0 +1,95 @@
import prisma from "@calcom/prisma";
import { deleteAllWorkflowReminders } from "@calcom/trpc/server/routers/viewer/workflows/util";
// cancel/delete all workflowReminders of the removed member that come from that team (org teams only)
export async function deleteWorkfowRemindersOfRemovedMember(
team: {
id: number;
parentId?: number | null;
},
memberId: number,
isOrg: boolean
) {
if (isOrg) {
// if member was removed from org, delete all workflowReminders of the removed team member that come from org workflows
const workflowRemindersToDelete = await prisma.workflowReminder.findMany({
where: {
workflowStep: {
workflow: {
teamId: team.id,
},
},
booking: {
eventType: {
userId: memberId,
},
},
},
select: {
id: true,
referenceId: true,
method: true,
},
});
deleteAllWorkflowReminders(workflowRemindersToDelete);
} else {
if (!team.parentId) return;
// member was removed from an org subteam
const removedWorkflows = await prisma.workflow.findMany({
where: {
OR: [
{
AND: [
{
activeOnTeams: {
some: {
teamId: team.id,
},
},
},
{
activeOnTeams: {
// Don't delete reminder if user is still part of another team that is active on this workflow
none: {
team: {
members: {
some: {
userId: memberId,
},
},
},
},
},
},
// only if workflow is not active on all team and user event types
{ isActiveOnAll: false },
],
},
],
},
});
const workflowRemindersToDelete = await prisma.workflowReminder.findMany({
where: {
workflowStep: {
workflowId: {
in: removedWorkflows?.map((workflow) => workflow.id) ?? [],
},
},
booking: {
eventType: {
userId: memberId,
},
},
},
select: {
id: true,
referenceId: true,
method: true,
},
});
deleteAllWorkflowReminders(workflowRemindersToDelete);
}
}
+41 -27
View File
@@ -4,6 +4,8 @@ import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import { deleteWorkfowRemindersOfRemovedMember } from "./deleteWorkflowRemindersOfRemovedMember";
const log = logger.getSubLogger({ prefix: ["removeMember"] });
const removeMember = async ({
@@ -36,34 +38,44 @@ const removeMember = async ({
}),
]);
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
isOrganization: true,
organizationSettings: true,
id: true,
metadata: true,
activeOrgWorkflows: true,
parentId: true,
},
});
const foundUser = await prisma.user.findUnique({
where: { id: memberId },
select: {
id: true,
movedToProfileId: true,
email: true,
username: true,
completedOnboarding: true,
teams: {
select: {
team: {
select: {
id: true,
parentId: true,
},
},
},
},
},
});
if (!team || !foundUser) throw new TRPCError({ code: "NOT_FOUND" });
if (isOrg) {
log.debug("Removing a member from the organization");
// Deleting membership from all child teams
const foundUser = await prisma.user.findUnique({
where: { id: memberId },
select: {
id: true,
movedToProfileId: true,
email: true,
username: true,
completedOnboarding: true,
},
});
const orgInfo = await prisma.team.findUnique({
where: { id: teamId },
select: {
isOrganization: true,
organizationSettings: true,
id: true,
metadata: true,
},
});
const orgMetadata = orgInfo?.organizationSettings;
if (!foundUser || !orgInfo) throw new TRPCError({ code: "NOT_FOUND" });
// Delete all sub-team memberships where this team is the organization
await prisma.membership.deleteMany({
where: {
@@ -78,7 +90,7 @@ const removeMember = async ({
const profileToDelete = await ProfileRepository.findByUserIdAndOrgId({
userId: userToDeleteMembershipOf.id,
organizationId: orgInfo.id,
organizationId: team.id,
});
if (
@@ -101,7 +113,7 @@ const removeMember = async ({
}),
ProfileRepository.delete({
userId: membership.userId,
organizationId: orgInfo.id,
organizationId: team.id,
}),
]);
}
@@ -111,6 +123,8 @@ const removeMember = async ({
where: { parent: { teamId: teamId }, userId: membership.userId },
});
await deleteWorkfowRemindersOfRemovedMember(team, memberId, isOrg);
return { membership };
};
@@ -82,6 +82,7 @@ export default function EmptyScreen(props: { isFilteredView: boolean }) {
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
buttonText={t("create_workflow")}
isPending={createMutation.isPending}
includeOrg={true}
/>
</div>
</div>
@@ -19,46 +19,38 @@ import { getActionIcon } from "../lib/getActionIcon";
import SkeletonLoader from "./SkeletonLoaderEventWorkflowsTab";
import type { WorkflowType } from "./WorkflowListPage";
type PartialWorkflowType = Pick<WorkflowType, "name" | "activeOn" | "isOrg" | "steps" | "id" | "readOnly">;
type ItemProps = {
workflow: WorkflowType;
workflow: PartialWorkflowType;
eventType: {
id: number;
title: string;
requiresConfirmation: boolean;
};
isChildrenManagedEventType: boolean;
isActive: boolean;
};
const WorkflowListItem = (props: ItemProps) => {
const { workflow, eventType } = props;
const { workflow, eventType, isActive } = props;
const { t } = useLocale();
const [activeEventTypeIds, setActiveEventTypeIds] = useState(
workflow.activeOn.map((active) => {
workflow.activeOn?.map((active) => {
if (active.eventType) {
return active.eventType.id;
}
})
}) ?? []
);
const isActive = activeEventTypeIds.includes(eventType.id);
const utils = trpc.useUtils();
const activateEventTypeMutation = trpc.viewer.workflows.activateEventType.useMutation({
onSuccess: async () => {
let offOn = "";
if (activeEventTypeIds.includes(eventType.id)) {
const newActiveEventTypeIds = activeEventTypeIds.filter((id) => {
return id !== eventType.id;
});
setActiveEventTypeIds(newActiveEventTypeIds);
offOn = "off";
} else {
const newActiveEventTypeIds = activeEventTypeIds;
newActiveEventTypeIds.push(eventType.id);
setActiveEventTypeIds(newActiveEventTypeIds);
offOn = "on";
}
const offOn = isActive ? "off" : "on";
await utils.viewer.workflows.getAllActiveWorkflows.invalidate();
await utils.viewer.eventTypes.get.invalidate({ id: eventType.id });
showToast(
t("workflow_turned_on_successfully", {
@@ -180,7 +172,7 @@ type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
type Props = {
eventType: EventTypeSetup;
workflows: WorkflowType[];
workflows: PartialWorkflowType[];
};
function EventWorkflowsTab(props: Props) {
@@ -194,9 +186,7 @@ function EventWorkflowsTab(props: Props) {
});
const workflowsDisableProps = shouldLockDisableProps("workflows", { simple: true });
const lockedText = workflowsDisableProps.isLocked ? "locked" : "unlocked";
const { data, isPending } = trpc.viewer.workflows.list.useQuery({
teamId: eventType.team?.id,
userId: !isChildrenManagedEventType ? eventType.userId || undefined : undefined,
@@ -206,13 +196,14 @@ function EventWorkflowsTab(props: Props) {
useEffect(() => {
if (data?.workflows) {
const activeWorkflows = workflows.map((workflowOnEventType) => {
const allActiveWorkflows = workflows.map((workflowOnEventType) => {
const dataWf = data.workflows.find((wf) => wf.id === workflowOnEventType.id);
return {
...workflowOnEventType,
readOnly: isChildrenManagedEventType && dataWf?.teamId ? true : dataWf?.readOnly ?? false,
} as WorkflowType;
});
const disabledWorkflows = data.workflows.filter(
(workflow) =>
(!workflow.teamId || eventType.teamId === workflow.teamId) &&
@@ -224,8 +215,8 @@ function EventWorkflowsTab(props: Props) {
);
const allSortedWorkflows =
workflowsDisableProps.isLocked && !isManagedEventType
? activeWorkflows
: activeWorkflows.concat(disabledWorkflows);
? allActiveWorkflows
: allActiveWorkflows.concat(disabledWorkflows);
setSortedWorkflows(allSortedWorkflows);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -286,6 +277,7 @@ function EventWorkflowsTab(props: Props) {
workflow={workflow}
eventType={props.eventType}
isChildrenManagedEventType
isActive={!!workflows.find((activeWorkflow) => activeWorkflow.id === workflow.id)}
/>
);
})}
@@ -1,6 +1,6 @@
import { useRouter, useSearchParams } from "next/navigation";
import type { Dispatch, SetStateAction } from "react";
import { useMemo, useState, useEffect } from "react";
import { useState, useEffect } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller } from "react-hook-form";
@@ -9,9 +9,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WorkflowActions } from "@calcom/prisma/enums";
import { WorkflowTemplates } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui";
import { Button, Icon, Label, MultiSelectCheckboxes, TextField } from "@calcom/ui";
import { Button, Icon, Label, MultiSelectCheckboxes, TextField, CheckboxField, InfoBadge } from "@calcom/ui";
import { isSMSAction, isWhatsappAction } from "../lib/actionHelperFunctions";
import type { FormValues } from "../pages/workflow";
@@ -24,16 +23,17 @@ type User = RouterOutputs["viewer"]["me"];
interface Props {
form: UseFormReturn<FormValues>;
workflowId: number;
selectedEventTypes: Option[];
setSelectedEventTypes: Dispatch<SetStateAction<Option[]>>;
selectedOptions: Option[];
setSelectedOptions: Dispatch<SetStateAction<Option[]>>;
teamId?: number;
user: User;
isMixedEventType: boolean;
readOnly: boolean;
isOrg: boolean;
allOptions: Option[];
}
export default function WorkflowDetailsPage(props: Props) {
const { form, workflowId, selectedEventTypes, setSelectedEventTypes, teamId, isMixedEventType } = props;
const { form, workflowId, selectedOptions, setSelectedOptions, teamId, isOrg, allOptions } = props;
const { t } = useLocale();
const router = useRouter();
@@ -42,59 +42,18 @@ export default function WorkflowDetailsPage(props: Props) {
const [reload, setReload] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { data, isPending } = trpc.viewer.eventTypes.getByViewer.useQuery();
const searchParams = useSearchParams();
const eventTypeId = searchParams?.get("eventTypeId");
const eventTypeOptions = useMemo(
() =>
data?.eventTypeGroups.reduce((options, group) => {
/** don't show team event types for user workflow */
if (!teamId && group.teamId) return options;
/** only show correct team event types for team workflows */
if (teamId && teamId !== group.teamId) return options;
return [
...options,
...group.eventTypes
.filter(
(evType) =>
!evType.metadata?.managedEventConfig ||
!!evType.metadata?.managedEventConfig.unlockedFields?.workflows ||
!!teamId
)
.map((eventType) => ({
value: String(eventType.id),
label: `${eventType.title} ${
eventType.children && eventType.children.length ? `(+${eventType.children.length})` : ``
}`,
})),
];
}, [] as Option[]) || [],
[data]
);
let allEventTypeOptions = eventTypeOptions;
const distinctEventTypes = new Set();
if (!teamId && isMixedEventType) {
allEventTypeOptions = [...eventTypeOptions, ...selectedEventTypes];
allEventTypeOptions = allEventTypeOptions.filter((option) => {
const duplicate = distinctEventTypes.has(option.value);
distinctEventTypes.add(option.value);
return !duplicate;
});
}
useEffect(() => {
const matchingOption = allEventTypeOptions.find((option) => option.value === eventTypeId);
if (matchingOption && !selectedEventTypes.find((option) => option.value === eventTypeId)) {
const newOptions = [...selectedEventTypes, matchingOption];
setSelectedEventTypes(newOptions);
const matchingOption = allOptions.find((option) => option.value === eventTypeId);
if (matchingOption && !selectedOptions.find((option) => option.value === eventTypeId)) {
const newOptions = [...selectedOptions, matchingOption];
setSelectedOptions(newOptions);
form.setValue("activeOn", newOptions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventTypeId, allEventTypeOptions]);
}, [eventTypeId]);
const addAction = (
action: WorkflowActions,
@@ -148,26 +107,54 @@ export default function WorkflowDetailsPage(props: Props) {
{...form.register("name")}
/>
</div>
<Label>{t("which_event_type_apply")}</Label>
{isOrg ? (
<div className="flex">
<Label>{t("which_team_apply")}</Label>
<div className="-mt-0.5">
<InfoBadge content={t("team_select_info")} />
</div>
</div>
) : (
<Label>{t("which_event_type_apply")}</Label>
)}
<Controller
name="activeOn"
control={form.control}
render={() => {
return (
<MultiSelectCheckboxes
options={allEventTypeOptions}
isDisabled={props.readOnly}
isLoading={isPending}
options={allOptions}
isDisabled={props.readOnly || form.getValues("selectAll")}
className="w-full md:w-64"
setSelected={setSelectedEventTypes}
selected={selectedEventTypes}
setSelected={setSelectedOptions}
selected={form.getValues("selectAll") ? allOptions : selectedOptions}
setValue={(s: Option[]) => {
form.setValue("activeOn", s);
}}
countText={isOrg ? "count_team" : "nr_event_type"}
/>
);
}}
/>
<div className="mt-3">
<Controller
name="selectAll"
render={({ field: { value, onChange } }) => (
<CheckboxField
description={isOrg ? t("apply_to_all_teams") : t("apply_to_all_event_types")}
disabled={props.readOnly}
onChange={(e) => {
onChange(e);
if (e.target.value) {
setSelectedOptions(allOptions);
form.setValue("activeOn", allOptions);
}
}}
checked={value}
/>
)}
/>
</div>
<div className="md:border-subtle my-7 border-transparent md:border-t" />
{!props.readOnly && (
<Button
@@ -1,5 +1,5 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { Membership, Workflow, WorkflowStep } from "@prisma/client";
import type { Membership, Workflow } from "@prisma/client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -24,6 +24,7 @@ import {
} from "@calcom/ui";
import { getActionIcon } from "../lib/getActionIcon";
import type { WorkflowStep } from "../lib/types";
import { DeleteDialog } from "./DeleteDialog";
export type WorkflowType = Workflow & {
@@ -35,7 +36,13 @@ export type WorkflowType = Workflow & {
logo?: string | null;
} | null;
steps: WorkflowStep[];
activeOn: {
activeOnTeams?: {
team: {
id: number;
name?: string | null;
};
}[];
activeOn?: {
eventType: {
id: number;
title: string;
@@ -46,6 +53,7 @@ export type WorkflowType = Workflow & {
};
}[];
readOnly?: boolean;
isOrg?: boolean;
};
interface Props {
workflows: WorkflowType[] | undefined;
@@ -154,7 +162,14 @@ export default function WorkflowListPage({ workflows }: Props) {
</li>
<li>
<Badge variant="gray">
{workflow.activeOn && workflow.activeOn.length > 0 ? (
{/*active on all badge */}
{workflow.isActiveOnAll ? (
<div>
<Icon name="link" className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{workflow.isOrg ? t("active_on_all_teams") : t("active_on_all_event_types")}
</div>
) : workflow.activeOn && workflow.activeOn.length > 0 ? (
//active on event types badge
<Tooltip
content={workflow.activeOn
.filter((wf) => (workflow.teamId ? wf.eventType.parentId === null : true))
@@ -175,10 +190,24 @@ export default function WorkflowListPage({ workflows }: Props) {
})}
</div>
</Tooltip>
) : workflow.activeOnTeams && workflow.activeOnTeams.length > 0 ? (
//active on teams badge
<Tooltip
content={workflow.activeOnTeams.map((activeOn, key) => (
<p key={key}>{activeOn.team.name}</p>
))}>
<div>
<Icon name="link" className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("active_on_teams", {
count: workflow.activeOnTeams?.length,
})}
</div>
</Tooltip>
) : (
// active on no teams or event types
<div>
<Icon name="link" className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("no_active_event_types")}
{workflow.isOrg ? t("no_active_teams") : t("no_active_event_types")}
</div>
)}
</Badge>
@@ -1,12 +1,9 @@
import type { Workflow, WorkflowStep } from "@calcom/prisma/client";
import { WorkflowTriggerEvents } from "@calcom/prisma/client";
import { WorkflowActions } from "@calcom/prisma/enums";
export function allowDisablingHostConfirmationEmails(
workflows: (Workflow & {
steps: WorkflowStep[];
})[]
) {
import type { Workflow } from "./types";
export function allowDisablingHostConfirmationEmails(workflows: Workflow[]) {
return !!workflows.find(
(workflow) =>
workflow.trigger === WorkflowTriggerEvents.NEW_EVENT &&
@@ -14,11 +11,7 @@ export function allowDisablingHostConfirmationEmails(
);
}
export function allowDisablingAttendeeConfirmationEmails(
workflows: (Workflow & {
steps: WorkflowStep[];
})[]
) {
export function allowDisablingAttendeeConfirmationEmails(workflows: Workflow[]) {
return !!workflows.find(
(workflow) =>
workflow.trigger === WorkflowTriggerEvents.NEW_EVENT &&
@@ -1,9 +1,9 @@
import type { WorkflowStep } from "@prisma/client";
import { isSMSOrWhatsappAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { classNames } from "@calcom/lib";
import { Icon } from "@calcom/ui";
import type { WorkflowStep } from "../lib/types";
export function getActionIcon(steps: WorkflowStep[], className?: string): JSX.Element {
if (steps.length === 0) {
return (
@@ -0,0 +1,120 @@
import prisma from "@calcom/prisma";
import type { Workflow } from "./types";
export const workflowSelect = {
id: true,
trigger: true,
time: true,
timeUnit: true,
userId: true,
teamId: true,
name: true,
steps: {
select: {
id: true,
action: true,
sendTo: true,
reminderBody: true,
emailSubject: true,
template: true,
numberVerificationPending: true,
sender: true,
includeCalendarEvent: true,
numberRequired: true,
},
},
};
export const getAllWorkflows = async (
eventTypeWorkflows: Workflow[],
userId?: number | null,
teamId?: number | null,
orgId?: number | null,
workflowsLockedForUser = true
) => {
const allWorkflows = eventTypeWorkflows;
if (orgId) {
if (teamId) {
const orgTeamWorkflowsRel = await prisma.workflowsOnTeams.findMany({
where: {
teamId: teamId,
},
select: {
workflow: {
select: workflowSelect,
},
},
});
const orgTeamWorkflows = orgTeamWorkflowsRel?.map((workflowRel) => workflowRel.workflow) ?? [];
allWorkflows.push(...orgTeamWorkflows);
} else if (userId) {
const orgUserWorkflowsRel = await prisma.workflowsOnTeams.findMany({
where: {
team: {
members: {
some: {
userId: userId,
accepted: true,
},
},
},
},
select: {
workflow: {
select: workflowSelect,
},
team: true,
},
});
const orgUserWorkflows = orgUserWorkflowsRel.map((workflowRel) => workflowRel.workflow) ?? [];
allWorkflows.push(...orgUserWorkflows);
}
// get workflows that are active on all
const activeOnAllOrgWorkflows = await prisma.workflow.findMany({
where: {
teamId: orgId,
isActiveOnAll: true,
},
select: workflowSelect,
});
allWorkflows.push(...activeOnAllOrgWorkflows);
}
if (teamId) {
const activeOnAllTeamWorkflows = await prisma.workflow.findMany({
where: {
teamId,
isActiveOnAll: true,
},
select: workflowSelect,
});
allWorkflows.push(...activeOnAllTeamWorkflows);
}
if ((!teamId || !workflowsLockedForUser) && userId) {
const activeOnAllUserWorkflows = await prisma.workflow.findMany({
where: {
userId,
teamId: null,
isActiveOnAll: true,
},
select: workflowSelect,
});
allWorkflows.push(...activeOnAllUserWorkflows);
}
// remove all the duplicate workflows from allWorkflows
const seen = new Set();
const workflows = allWorkflows.filter((workflow) => {
const duplicate = seen.has(workflow.id);
seen.add(workflow.id);
return !duplicate;
});
return workflows;
};
@@ -87,7 +87,7 @@ export interface ScheduleReminderArgs {
time: number | null;
timeUnit: TimeUnit | null;
};
template: WorkflowTemplates;
template?: WorkflowTemplates;
sender?: string | null;
workflowStepId?: number;
seatReferenceUid?: string;
@@ -2,6 +2,7 @@ import client from "@sendgrid/client";
import type { MailData } from "@sendgrid/helpers/classes/mail";
import sgMail from "@sendgrid/mail";
import { JSDOM } from "jsdom";
import { v4 as uuidv4 } from "uuid";
import { SENDER_NAME } from "@calcom/lib/constants";
import { setTestEmail } from "@calcom/lib/testEmails";
@@ -9,6 +10,8 @@ import { setTestEmail } from "@calcom/lib/testEmails";
let sendgridAPIKey: string;
let senderEmail: string;
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE;
function assertSendgrid() {
if (process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL) {
sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
@@ -21,6 +24,9 @@ function assertSendgrid() {
}
export async function getBatchId() {
if (testMode) {
return uuidv4();
}
assertSendgrid();
if (!process.env.SENDGRID_API_KEY) {
console.info("No sendgrid API key provided, returning DUMMY_BATCH_ID");
@@ -39,7 +45,6 @@ export function sendSendgridMail(
) {
assertSendgrid();
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE;
if (testMode) {
if (!mailData.sendAt) {
setTestEmail({
@@ -1,4 +1,5 @@
import TwilioClient from "twilio";
import { v4 as uuidv4 } from "uuid";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import logger from "@calcom/lib/logger";
@@ -8,6 +9,8 @@ import { SMSLockState } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["[twilioProvider]"] });
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE;
function createTwilioClient() {
if (process.env.TWILIO_SID && process.env.TWILIO_TOKEN && process.env.TWILIO_MESSAGING_SID) {
return TwilioClient(process.env.TWILIO_SID, process.env.TWILIO_TOKEN);
@@ -42,8 +45,6 @@ export const sendSMS = async (
return;
}
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE;
if (testMode) {
setTestSMS({
to: getSMSNumber(phoneNumber, whatsapp),
@@ -85,8 +86,6 @@ export const scheduleSMS = async (
teamId?: number | null,
whatsapp = false
) => {
const twilio = createTwilioClient();
const isSMSSendingLocked = await isLockedForSMSSending(userId, teamId);
if (isSMSSendingLocked) {
@@ -94,6 +93,20 @@ export const scheduleSMS = async (
return;
}
if (testMode) {
setTestSMS({
to: getSMSNumber(phoneNumber, whatsapp),
from: whatsapp ? getDefaultSender(whatsapp) : sender ? sender : getDefaultSender(),
message: body,
});
console.log(
"Skipped sending SMS because process.env.NEXT_PUBLIC_IS_E2E or process.env.INTEGRATION_TEST_MODE is set. SMS are available in globalThis.testSMS"
);
return { sid: uuidv4() };
}
const twilio = createTwilioClient();
if (!teamId && userId) {
await checkSMSRateLimit({
identifier: `sms:user:${userId}`,
@@ -1,21 +1,20 @@
import type { Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client";
import {
isSMSAction,
isSMSOrWhatsappAction,
isWhatsappAction,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { SENDER_NAME } from "@calcom/lib/constants";
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { deleteScheduledEmailReminder, scheduleEmailReminder } from "./emailReminderManager";
import { scheduleEmailReminder } from "./emailReminderManager";
import type { ScheduleTextReminderAction } from "./smsReminderManager";
import { deleteScheduledSMSReminder, scheduleSMSReminder } from "./smsReminderManager";
import { deleteScheduledWhatsappReminder, scheduleWhatsappReminder } from "./whatsappReminderManager";
import { scheduleSMSReminder } from "./smsReminderManager";
import { scheduleWhatsappReminder } from "./whatsappReminderManager";
type ExtendedCalendarEvent = CalendarEvent & {
export type ExtendedCalendarEvent = CalendarEvent & {
metadata?: { videoCallUrl: string | undefined };
eventType: { slug?: string };
};
@@ -29,11 +28,7 @@ type ProcessWorkflowStepParams = {
};
export interface ScheduleWorkflowRemindersArgs extends ProcessWorkflowStepParams {
workflows: (WorkflowsOnEventTypes & {
workflow: Workflow & {
steps: WorkflowStep[];
};
})[];
workflows: Workflow[];
isNotConfirmed?: boolean;
isRescheduleEvent?: boolean;
isFirstRecurringEvent?: boolean;
@@ -155,10 +150,9 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA
} = args;
if (isNotConfirmed || !workflows.length) return;
for (const workflowReference of workflows) {
if (workflowReference.workflow.steps.length === 0) continue;
for (const workflow of workflows) {
if (workflow.steps.length === 0) continue;
const workflow = workflowReference.workflow;
const isNotBeforeOrAfterEvent =
workflow.trigger !== WorkflowTriggerEvents.BEFORE_EVENT &&
workflow.trigger !== WorkflowTriggerEvents.AFTER_EVENT;
@@ -188,40 +182,19 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA
}
};
const reminderMethods: { [x: string]: (id: number, referenceId: string | null) => void } = {
[WorkflowMethods.EMAIL]: deleteScheduledEmailReminder,
[WorkflowMethods.SMS]: deleteScheduledSMSReminder,
[WorkflowMethods.WHATSAPP]: deleteScheduledWhatsappReminder,
};
export const cancelWorkflowReminders = async (
workflowReminders: { method: WorkflowMethods; id: number; referenceId: string | null }[]
) => {
await Promise.all(
workflowReminders.map((reminder) => {
return reminderMethods[reminder.method](reminder.id, reminder.referenceId);
})
);
};
export interface SendCancelledRemindersArgs {
workflows: (WorkflowsOnEventTypes & {
workflow: Workflow & {
steps: WorkflowStep[];
};
})[];
workflows: Workflow[];
smsReminderNumber: string | null;
evt: ExtendedCalendarEvent;
hideBranding?: boolean;
}
export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) => {
const { workflows, smsReminderNumber, evt, hideBranding } = args;
const { smsReminderNumber, evt, workflows, hideBranding } = args;
if (!workflows.length) return;
for (const workflowRef of workflows) {
const { workflow } = workflowRef;
for (const workflow of workflows) {
if (workflow.trigger !== WorkflowTriggerEvents.EVENT_CANCELLED) continue;
for (const step of workflow.steps) {
@@ -1,23 +1,19 @@
import type { Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client";
import type { getEventTypeResponse } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import { scheduleEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import type { BookingInfo } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import type { getDefaultEvent } from "@calcom/lib/defaultEvents";
import logger from "@calcom/lib/logger";
import { WorkflowTriggerEvents, TimeUnit, WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import type { ExtendedCalendarEvent } from "./reminderScheduler";
const log = logger.getSubLogger({ prefix: ["[scheduleMandatoryReminder]"] });
export type NewBookingEventType = Awaited<ReturnType<typeof getDefaultEvent>> | getEventTypeResponse;
export async function scheduleMandatoryReminder(
evt: BookingInfo,
workflows: (WorkflowsOnEventTypes & {
workflow: Workflow & {
steps: WorkflowStep[];
};
})[],
evt: ExtendedCalendarEvent,
workflows: Workflow[],
requiresConfirmation: boolean,
hideBranding: boolean,
seatReferenceUid: string | undefined
@@ -25,14 +21,10 @@ export async function scheduleMandatoryReminder(
try {
const hasExistingWorkflow = workflows.some((workflow) => {
return (
workflow.workflow?.trigger === WorkflowTriggerEvents.BEFORE_EVENT &&
((workflow.workflow.time !== null &&
workflow.workflow.time <= 12 &&
workflow.workflow?.timeUnit === TimeUnit.HOUR) ||
(workflow.workflow.time !== null &&
workflow.workflow.time <= 720 &&
workflow.workflow?.timeUnit === TimeUnit.MINUTE)) &&
workflow.workflow?.steps.some((step) => step?.action === WorkflowActions.EMAIL_ATTENDEE)
workflow.trigger === WorkflowTriggerEvents.BEFORE_EVENT &&
((workflow.time !== null && workflow.time <= 12 && workflow.timeUnit === TimeUnit.HOUR) ||
(workflow.time !== null && workflow.time <= 720 && workflow.timeUnit === TimeUnit.MINUTE)) &&
workflow.steps.some((step) => step?.action === WorkflowActions.EMAIL_ATTENDEE)
);
});
@@ -2,6 +2,7 @@ import dayjs from "@calcom/dayjs";
import { SENDER_ID } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { WorkflowTemplates, WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums";
@@ -68,6 +69,7 @@ export interface ScheduleTextReminderArgs extends ScheduleReminderArgs {
userId?: number | null;
teamId?: number | null;
isVerificationPending?: boolean;
prisma?: PrismaClient;
}
export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
@@ -0,0 +1,857 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import {
getOrganizer,
getScenarioData,
TestData,
createBookingScenario,
createOrganization,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import {
expectSMSWorkflowToBeTriggered,
expectSMSWorkflowToBeNotTriggered,
} from "@calcom/web/test/utils/bookingScenario/expects";
import { describe, expect, beforeAll, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import { BookingStatus, WorkflowMethods, TimeUnit } from "@calcom/prisma/enums";
import {
deleteRemindersOfActiveOnIds,
scheduleBookingReminders,
bookingSelect,
} from "@calcom/trpc/server/routers/viewer/workflows/util";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { deleteWorkfowRemindersOfRemovedMember } from "../../../teams/lib/deleteWorkflowRemindersOfRemovedMember";
const workflowSelect = {
id: true,
userId: true,
isActiveOnAll: true,
trigger: true,
time: true,
timeUnit: true,
team: {
select: {
isOrganization: true,
},
},
teamId: true,
user: {
select: {
teams: true,
},
},
steps: true,
activeOn: true,
activeOnTeams: true,
};
beforeAll(() => {
vi.setSystemTime(new Date("2024-05-20T11:59:59Z"));
});
const mockEventTypes = [
{
id: 1,
slotInterval: 30,
length: 30,
useEventTypeDestinationCalendarEmail: true,
owner: 101,
users: [
{
id: 101,
},
],
},
{
id: 2,
slotInterval: 30,
length: 30,
useEventTypeDestinationCalendarEmail: true,
owner: 101,
users: [
{
id: 101,
},
],
},
];
const mockBookings = [
{
uid: "jK7Rf8iYsOpmQUw9hB1vZxP",
eventTypeId: 1,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `2024-05-22T04:00:00.000Z`,
endTime: `2024-05-22T04:30:00.000Z`,
attendees: [{ email: "attendee@example.com" }],
},
{
uid: "mL4Dx9jTkQbnWEu3pR7yNcF",
eventTypeId: 1,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `2024-05-23T04:00:00.000Z`,
endTime: `2024-05-23T04:30:00.000Z`,
attendees: [{ email: "attendee@example.com" }],
},
{
uid: "Fd9Rf8iYsOpmQUw9hB1vKd8",
eventTypeId: 2,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `2024-06-01T04:30:00.000Z`,
endTime: `2024-06-01T05:00:00.000Z`,
attendees: [{ email: "attendee@example.com" }],
},
{
uid: "Kd8Dx9jTkQbnWEu3pR7yKdl",
eventTypeId: 2,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `2024-06-02T04:30:00.000Z`,
endTime: `2024-06-02T05:00:00.000Z`,
attendees: [{ email: "attendee@example.com" }],
},
];
async function createWorkflowRemindersForWorkflow(workflowName: string) {
const workflow = await prismock.workflow.findFirst({
where: {
name: workflowName,
},
select: {
steps: {
select: {
id: true,
stepNumber: true,
action: true,
workflowId: true,
sendTo: true,
reminderBody: true,
emailSubject: true,
template: true,
numberRequired: true,
sender: true,
numberVerificationPending: true,
includeCalendarEvent: true,
},
},
},
});
const workflowRemindersData = [
{
booking: {
connect: {
bookingUid: "jK7Rf8iYsOpmQUw9hB1vZxP",
},
},
bookingUid: "jK7Rf8iYsOpmQUw9hB1vZxP",
workflowStepId: workflow?.steps[0]?.id,
method: WorkflowMethods.EMAIL,
scheduledDate: `2024-05-22T06:00:00.000Z`,
scheduled: false,
retryCount: 0,
},
{
booking: {
connect: {
bookingUid: "mL4Dx9jTkQbnWEu3pR7yNcF",
},
},
bookingUid: "mL4Dx9jTkQbnWEu3pR7yNcF",
workflowStepId: workflow?.steps[0]?.id,
method: WorkflowMethods.EMAIL,
scheduledDate: `2024-05-22T06:30:00.000Z`,
scheduled: false,
retryCount: 0,
},
{
booking: {
connect: {
bookingUid: "Fd9Rf8iYsOpmQUw9hB1vKd8",
},
},
bookingUid: "Fd9Rf8iYsOpmQUw9hB1vKd8",
workflowStepId: workflow?.steps[0]?.id,
method: WorkflowMethods.EMAIL,
scheduledDate: `2024-05-22T06:30:00.000Z`,
scheduled: false,
retryCount: 0,
},
{
booking: {
connect: {
bookingUid: "Kd8Dx9jTkQbnWEu3pR7yKdl",
},
},
bookingUid: "Kd8Dx9jTkQbnWEu3pR7yKdl",
workflowStepId: workflow?.steps[0]?.id,
method: WorkflowMethods.EMAIL,
scheduledDate: `2024-05-22T06:30:00.000Z`,
scheduled: false,
retryCount: 0,
},
];
for (const data of workflowRemindersData) {
await prismock.workflowReminder.create({
data,
});
}
return workflow;
}
describe("deleteRemindersOfActiveOnIds", () => {
test("should delete all reminders from removed event types", async ({}) => {
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "User Workflow",
userId: organizer.id,
trigger: "BEFORE_EVENT",
time: 1,
timeUnit: TimeUnit.HOUR,
action: "EMAIL_HOST",
template: "REMINDER",
activeOn: [1],
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
const workflow = await createWorkflowRemindersForWorkflow("User Workflow");
const removedActiveOnIds = [1];
const activeOnIds = [2];
await deleteRemindersOfActiveOnIds({
removedActiveOnIds,
workflowSteps: workflow?.steps || [],
isOrg: false,
activeOnIds,
});
const workflowReminders = await prismock.workflowReminder.findMany({
select: {
booking: {
select: {
eventTypeId: true,
},
},
},
});
expect(workflowReminders.filter((reminder) => reminder.booking?.eventTypeId === 1).length).toBe(0);
expect(workflowReminders.filter((reminder) => reminder.booking?.eventTypeId === 2).length).toBe(2);
});
test("should delete all reminders from removed event types (org workflow)", async ({}) => {
const org = await createOrganization({
name: "Test Org",
slug: "testorg",
withTeam: true,
});
// organizer is part of org and two teams
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
organizationId: org.id,
teams: [
{
membership: {
accepted: true,
},
team: {
id: 3,
name: "Team 1",
slug: "team-1",
parentId: org.id,
},
},
{
membership: {
accepted: true,
},
team: {
id: 4,
name: "Team 2",
slug: "team-2",
parentId: org.id,
},
},
],
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "Org Workflow",
teamId: 1,
trigger: "BEFORE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeOnTeams: [2, 3, 4],
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
const workflow = await createWorkflowRemindersForWorkflow("Org Workflow");
let removedActiveOnIds = [1];
const activeOnIds = [2];
//workflow removed from team 2, but still acitve on team 3 --> so reminder should not be removed
await deleteRemindersOfActiveOnIds({
removedActiveOnIds,
workflowSteps: workflow?.steps || [],
isOrg: true,
activeOnIds,
});
// get all reminders from organizer's bookings
const workflowRemindersWithOneTeamActive = await prismock.workflowReminder.findMany({
where: {
booking: {
userId: organizer.id,
},
},
});
removedActiveOnIds = [3];
// should still be active on all 4 bookings
expect(workflowRemindersWithOneTeamActive.length).toBe(4);
await deleteRemindersOfActiveOnIds({
removedActiveOnIds,
workflowSteps: workflow?.steps || [],
isOrg: true,
activeOnIds,
});
const workflowRemindersWithNoTeamActive = await prismock.workflowReminder.findMany({
where: {
booking: {
userId: organizer.id,
},
},
});
expect(workflowRemindersWithNoTeamActive.length).toBe(0);
});
});
describe("scheduleBookingReminders", () => {
test("schedules workflow notifications with before event trigger and email to host action", async ({}) => {
// organizer is part of org and two teams
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "Workflow",
userId: 101,
trigger: "BEFORE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeOn: [],
time: 1,
timeUnit: TimeUnit.HOUR,
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
const workflow = await prismock.workflow.findFirst({
select: workflowSelect,
});
const bookings = await prismock.booking.findMany({
where: {
userId: organizer.id,
},
select: bookingSelect,
});
expect(workflow).not.toBeNull();
if (!workflow) return;
await scheduleBookingReminders(
bookings,
workflow.steps,
workflow.time,
workflow.timeUnit,
workflow.trigger,
organizer.id,
null //teamId
);
const scheduledWorkflowReminders = await prismock.workflowReminder.findMany({
where: {
workflowStep: {
workflowId: workflow.id,
},
},
});
scheduledWorkflowReminders.sort((a, b) =>
dayjs(a.scheduledDate).isBefore(dayjs(b.scheduledDate)) ? -1 : 1
);
const expectedScheduledDates = [
new Date("2024-05-22T03:00:00.000"),
new Date("2024-05-23T03:00:00.000Z"),
new Date("2024-06-01T03:30:00.000Z"),
new Date("2024-06-02T03:30:00.000Z"),
];
scheduledWorkflowReminders.forEach((reminder, index) => {
expect(expectedScheduledDates[index]).toStrictEqual(reminder.scheduledDate);
expect(reminder.method).toBe(WorkflowMethods.EMAIL);
if (index < 2) {
expect(reminder.scheduled).toBe(true);
} else {
expect(reminder.scheduled).toBe(false);
}
});
});
test("schedules workflow notifications with after event trigger and email to host action", async ({}) => {
// organizer is part of org and two teams
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "Workflow",
userId: 101,
trigger: "AFTER_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeOn: [],
time: 1,
timeUnit: TimeUnit.HOUR,
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
const workflow = await prismock.workflow.findFirst({
select: workflowSelect,
});
const bookings = await prismock.booking.findMany({
where: {
userId: organizer.id,
},
select: bookingSelect,
});
expect(workflow).not.toBeNull();
if (!workflow) return;
await scheduleBookingReminders(
bookings,
workflow.steps,
workflow.time,
workflow.timeUnit,
workflow.trigger,
organizer.id,
null //teamId
);
const scheduledWorkflowReminders = await prismock.workflowReminder.findMany({
where: {
workflowStep: {
workflowId: workflow.id,
},
},
});
scheduledWorkflowReminders.sort((a, b) =>
dayjs(a.scheduledDate).isBefore(dayjs(b.scheduledDate)) ? -1 : 1
);
const expectedScheduledDates = [
new Date("2024-05-22T05:30:00.000"),
new Date("2024-05-23T05:30:00.000Z"),
new Date("2024-06-01T06:00:00.000Z"),
new Date("2024-06-02T06:00:00.000Z"),
];
scheduledWorkflowReminders.forEach((reminder, index) => {
expect(expectedScheduledDates[index]).toStrictEqual(reminder.scheduledDate);
expect(reminder.method).toBe(WorkflowMethods.EMAIL);
if (index < 2) {
expect(reminder.scheduled).toBe(true);
} else {
expect(reminder.scheduled).toBe(false);
}
});
});
test("send sms to specific number for bookings", async ({ sms }) => {
// organizer is part of org and two teams
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "Workflow",
userId: 101,
trigger: "BEFORE_EVENT",
action: "SMS_NUMBER",
template: "REMINDER",
activeOn: [],
time: 3,
timeUnit: TimeUnit.HOUR,
sendTo: "000",
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
const workflow = await prismock.workflow.findFirst({
select: workflowSelect,
});
const bookings = await prismock.booking.findMany({
where: {
userId: organizer.id,
},
select: bookingSelect,
});
expect(workflow).not.toBeNull();
if (!workflow) return;
await scheduleBookingReminders(
bookings,
workflow.steps,
workflow.time,
workflow.timeUnit,
workflow.trigger,
organizer.id,
null //teamId
);
// number is not verified, so sms should not send
expectSMSWorkflowToBeNotTriggered({
sms,
toNumber: "000",
});
await prismock.verifiedNumber.create({
data: {
userId: organizer.id,
phoneNumber: "000",
},
});
const allVerified = await prismock.verifiedNumber.findMany();
await scheduleBookingReminders(
bookings,
workflow.steps,
workflow.time,
workflow.timeUnit,
workflow.trigger,
organizer.id,
null //teamId
);
// two sms schould be scheduled
expectSMSWorkflowToBeTriggered({
sms,
toNumber: "000",
includedString: "2024 May 22 at 9:30am Asia/Kolkata",
});
expectSMSWorkflowToBeTriggered({
sms,
toNumber: "000",
includedString: "2024 May 23 at 9:30am Asia/Kolkata",
});
// sms are too far in future
expectSMSWorkflowToBeNotTriggered({
sms,
toNumber: "000",
includedString: "2024 June 1 at 10:00am Asia/Kolkata",
});
expectSMSWorkflowToBeNotTriggered({
sms,
toNumber: "000",
includedString: "2024 June 2 at 10:00am Asia/Kolkata",
});
const scheduledWorkflowReminders = await prismock.workflowReminder.findMany({
where: {
workflowStep: {
workflowId: workflow.id,
},
},
});
scheduledWorkflowReminders.sort((a, b) =>
dayjs(a.scheduledDate).isBefore(dayjs(b.scheduledDate)) ? -1 : 1
);
const expectedScheduledDates = [
new Date("2024-05-22T01:00:00.000"),
new Date("2024-05-23T01:00:00.000Z"),
new Date("2024-06-01T01:30:00.000Z"),
new Date("2024-06-02T01:30:00.000Z"),
];
scheduledWorkflowReminders.forEach((reminder, index) => {
expect(expectedScheduledDates[index]).toStrictEqual(reminder.scheduledDate);
expect(reminder.method).toBe(WorkflowMethods.SMS);
if (index < 2) {
expect(reminder.scheduled).toBe(true);
} else {
expect(reminder.scheduled).toBe(false);
}
});
});
});
describe("deleteWorkfowRemindersOfRemovedMember", () => {
test("deletes all workflow reminders when member is removed from org", async ({}) => {
const org = await createOrganization({
name: "Test Org",
slug: "testorg",
withTeam: true,
});
// organizer is part of org and two teams
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
organizationId: org.id,
teams: [
{
membership: {
accepted: true,
},
team: {
id: 3,
name: "Team 1",
slug: "team-1",
parentId: org.id,
},
},
{
membership: {
accepted: true,
},
team: {
id: 4,
name: "Team 2",
slug: "team-2",
parentId: org.id,
},
},
],
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "Org Workflow",
teamId: 1,
trigger: "BEFORE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeOnTeams: [2, 3, 4],
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
await createWorkflowRemindersForWorkflow("Org Workflow");
await deleteWorkfowRemindersOfRemovedMember(org, 101, true);
const workflowReminders = await prismock.workflowReminder.findMany();
expect(workflowReminders.length).toBe(0);
});
test("deletes reminders if member is removed from an org team ", async ({}) => {
const org = await createOrganization({
name: "Test Org",
slug: "testorg",
withTeam: true,
});
// organizer is part of org and two teams
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
organizationId: org.id,
teams: [
{
membership: {
accepted: true,
},
team: {
id: 2,
name: "Team 1",
slug: "team-1",
parentId: org.id,
},
},
{
membership: {
accepted: true,
},
team: {
id: 3,
name: "Team 2",
slug: "team-2",
parentId: org.id,
},
},
{
membership: {
accepted: true,
},
team: {
id: 4,
name: "Team 3",
slug: "team-3",
parentId: org.id,
},
},
],
schedules: [TestData.schedules.IstMorningShift],
});
await createBookingScenario(
getScenarioData({
workflows: [
{
name: "Org Workflow 1",
teamId: 1,
trigger: "BEFORE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeOnTeams: [2, 3, 4],
},
{
name: "Org Workflow 2",
teamId: 1,
trigger: "BEFORE_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeOnTeams: [2],
},
],
eventTypes: mockEventTypes,
bookings: mockBookings,
organizer,
})
);
await createWorkflowRemindersForWorkflow("Org Workflow 1");
await createWorkflowRemindersForWorkflow("Org Workflow 2");
const tes = await prismock.membership.findMany();
await prismock.membership.delete({
where: {
userId: 101,
teamId: 2,
},
});
await deleteWorkfowRemindersOfRemovedMember({ id: 2, parentId: org.id }, 101, false);
const workflowReminders = await prismock.workflowReminder.findMany({
select: {
workflowStep: {
select: {
workflow: {
select: {
name: true,
},
},
},
},
},
});
const workflow1Reminders = workflowReminders.filter(
(reminder) => reminder.workflowStep?.workflow.name === "Org Workflow 1"
);
const workflow2Reminders = workflowReminders.filter(
(reminder) => reminder.workflowStep?.workflow.name === "Org Workflow 2"
);
expect(workflow1Reminders.length).toBe(4);
expect(workflow2Reminders.length).toBe(0);
});
});
@@ -0,0 +1,30 @@
import type {
TimeUnit,
WorkflowActions,
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
export type Workflow = {
id: number;
name: string;
trigger: WorkflowTriggerEvents;
time: number | null;
timeUnit: TimeUnit | null;
userId: number | null;
teamId: number | null;
steps: WorkflowStep[];
};
export type WorkflowStep = {
action: WorkflowActions;
sendTo: string | null;
template: WorkflowTemplates;
reminderBody: string | null;
emailSubject: string | null;
id: number;
sender: string | null;
includeCalendarEvent: boolean;
numberVerificationPending: boolean;
numberRequired: boolean | null;
};
@@ -69,6 +69,7 @@ function WorkflowsPage() {
isPending={createMutation.isPending}
disableMobileButton={true}
onlyShowWithNoTeams={true}
includeOrg={true}
/>
) : null
}>
@@ -76,13 +77,14 @@ function WorkflowsPage() {
{queryRes.data?.totalCount ? (
<div className="flex">
<TeamsFilter />
<div className="ml-auto">
<div className="mb-4 ml-auto">
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
isPending={createMutation.isPending}
disableMobileButton={true}
onlyShowWithTeams={true}
includeOrg={true}
/>
</div>
</div>
+136 -38
View File
@@ -2,8 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import type { WorkflowStep } from "@prisma/client";
import { isValidPhoneNumber } from "libphonenumber-js";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -21,6 +20,7 @@ import {
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui";
@@ -39,6 +39,7 @@ export type FormValues = {
trigger: WorkflowTriggerEvents;
time?: number;
timeUnit?: TimeUnit;
selectAll: boolean;
};
export function onlyLettersNumbersSpaces(str: string) {
@@ -78,6 +79,7 @@ const formSchema = z.object({
senderName: z.string().optional().nullable(),
})
.array(),
selectAll: z.boolean(),
});
const querySchema = z.object({
@@ -87,10 +89,9 @@ const querySchema = z.object({
function WorkflowPage() {
const { t, i18n } = useLocale();
const session = useSession();
const router = useRouter();
const params = useParamsWithFallback();
const [selectedEventTypes, setSelectedEventTypes] = useState<Option[]>([]);
const [selectedOptions, setSelectedOptions] = useState<Option[]>([]);
const [isAllDataLoaded, setIsAllDataLoaded] = useState(false);
const [isMixedEventType, setIsMixedEventType] = useState(false); //for old event types before team workflows existed
@@ -109,7 +110,7 @@ function WorkflowPage() {
data: workflow,
isError,
error,
isPending,
isPending: isPendingWorkflow,
} = trpc.viewer.workflows.get.useQuery(
{ id: +workflowId },
{
@@ -128,33 +129,126 @@ function WorkflowPage() {
teamId: workflow?.team?.id,
});
const { data: eventTypeGroups, isPending: isPendingEventTypes } =
trpc.viewer.eventTypes.getByViewer.useQuery();
const { data: otherTeams, isPending: isPendingTeams } = trpc.viewer.organizations.listOtherTeams.useQuery();
const isOrg = workflow?.team?.isOrganization ?? false;
const teamId = workflow?.teamId ?? undefined;
const profileTeamsOptions =
isOrg && eventTypeGroups
? eventTypeGroups?.profiles
.filter((profile) => !!profile.teamId)
.map((profile) => {
return {
value: String(profile.teamId) || "",
label: profile.name || profile.slug || "",
};
})
: [];
const otherTeamsOptions = otherTeams
? otherTeams.map((team) => {
return {
value: String(team.id) || "",
label: team.name || team.slug || "",
};
})
: [];
const teamOptions = profileTeamsOptions.concat(otherTeamsOptions);
const eventTypeOptions = useMemo(
() =>
eventTypeGroups?.eventTypeGroups.reduce((options, group) => {
/** don't show team event types for user workflow */
if (!teamId && group.teamId) return options;
/** only show correct team event types for team workflows */
if (teamId && teamId !== group.teamId) return options;
return [
...options,
...group.eventTypes
.filter(
(evType) =>
!evType.metadata?.managedEventConfig ||
!!evType.metadata?.managedEventConfig.unlockedFields?.workflows ||
!!teamId
)
.map((eventType) => ({
value: String(eventType.id),
label: `${eventType.title} ${
eventType.children && eventType.children.length ? `(+${eventType.children.length})` : ``
}`,
})),
];
}, [] as Option[]) || [],
[eventTypeGroups]
);
let allEventTypeOptions = eventTypeOptions;
const distinctEventTypes = new Set();
if (!teamId && isMixedEventType) {
allEventTypeOptions = [...eventTypeOptions, ...selectedOptions];
allEventTypeOptions = allEventTypeOptions.filter((option) => {
const duplicate = distinctEventTypes.has(option.value);
distinctEventTypes.add(option.value);
return !duplicate;
});
}
const readOnly =
workflow?.team?.members?.find((member) => member.userId === session.data?.user.id)?.role ===
MembershipRole.MEMBER;
const isPending = isPendingWorkflow || isPendingEventTypes || isPendingTeams;
useEffect(() => {
if (workflow && !isPending) {
if (workflow.userId && workflow.activeOn.find((active) => !!active.eventType.teamId)) {
if (!isPending) {
setFormData(workflow);
}
}, [isPending]);
function setFormData(workflowData: RouterOutputs["viewer"]["workflows"]["get"] | undefined) {
if (workflowData) {
if (workflowData.userId && workflowData.activeOn.find((active) => !!active.eventType.teamId)) {
setIsMixedEventType(true);
}
setSelectedEventTypes(
workflow.activeOn.flatMap((active) => {
if (workflow.teamId && active.eventType.parentId) return [];
return {
value: String(active.eventType.id),
label: active.eventType.title,
};
}) || []
);
const activeOn = workflow.activeOn
? workflow.activeOn.map((active) => ({
value: active.eventType.id.toString(),
label: active.eventType.slug,
}))
: undefined;
let activeOn;
if (workflowData.isActiveOnAll) {
activeOn = isOrg ? teamOptions : allEventTypeOptions;
} else {
if (isOrg) {
activeOn = workflowData.activeOnTeams.flatMap((active) => {
return {
value: String(active.team.id) || "",
label: active.team.slug || "",
};
});
setSelectedOptions(activeOn || []);
} else {
setSelectedOptions(
workflowData.activeOn.flatMap((active) => {
if (workflowData.teamId && active.eventType.parentId) return [];
return {
value: String(active.eventType.id),
label: active.eventType.title,
};
}) || []
);
activeOn = workflowData.activeOn
? workflowData.activeOn.map((active) => ({
value: active.eventType.id.toString(),
label: active.eventType.slug,
}))
: undefined;
}
}
//translate dynamic variables into local language
const steps = workflow.steps.map((step) => {
const steps = workflowData.steps.map((step) => {
const updatedStep = {
...step,
senderName: step.sender,
@@ -175,21 +269,22 @@ function WorkflowPage() {
return updatedStep;
});
form.setValue("name", workflow.name);
form.setValue("name", workflowData.name);
form.setValue("steps", steps);
form.setValue("trigger", workflow.trigger);
form.setValue("time", workflow.time || undefined);
form.setValue("timeUnit", workflow.timeUnit || undefined);
form.setValue("trigger", workflowData.trigger);
form.setValue("time", workflowData.time || undefined);
form.setValue("timeUnit", workflowData.timeUnit || undefined);
form.setValue("activeOn", activeOn || []);
form.setValue("selectAll", workflowData.isActiveOnAll ?? false);
setIsAllDataLoaded(true);
}
}, [isPending]);
}
const updateMutation = trpc.viewer.workflows.update.useMutation({
onSuccess: async ({ workflow }) => {
if (workflow) {
utils.viewer.workflows.get.setData({ id: +workflow.id }, workflow);
setFormData(workflow);
showToast(
t("workflow_updated_successfully", {
workflowName: workflow.name,
@@ -197,7 +292,6 @@ function WorkflowPage() {
"success"
);
}
router.push("/workflows");
},
onError: (err) => {
if (err instanceof HttpError) {
@@ -213,7 +307,7 @@ function WorkflowPage() {
<Form
form={form}
handleSubmit={async (values) => {
let activeOnEventTypeIds: number[] = [];
let activeOnIds: number[] = [];
let isEmpty = false;
let isVerified = true;
@@ -272,18 +366,21 @@ function WorkflowPage() {
if (!isEmpty && isVerified) {
if (values.activeOn) {
activeOnEventTypeIds = values.activeOn.map((option) => {
return parseInt(option.value, 10);
});
activeOnIds = values.activeOn
.filter((option) => option.value !== "all")
.map((option) => {
return parseInt(option.value, 10);
});
}
updateMutation.mutate({
id: workflowId,
name: values.name,
activeOn: activeOnEventTypeIds,
activeOn: activeOnIds,
steps: values.steps,
trigger: values.trigger,
time: values.time || null,
timeUnit: values.timeUnit || null,
isActiveOnAll: values.selectAll || false,
});
utils.viewer.workflows.getVerifiedNumbers.invalidate();
}
@@ -328,11 +425,12 @@ function WorkflowPage() {
form={form}
workflowId={+workflowId}
user={user}
selectedEventTypes={selectedEventTypes}
setSelectedEventTypes={setSelectedEventTypes}
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
teamId={workflow ? workflow.teamId || undefined : undefined}
isMixedEventType={isMixedEventType}
readOnly={readOnly}
isOrg={isOrg}
allOptions={isOrg ? teamOptions : allEventTypeOptions}
/>
</>
) : (
@@ -1,6 +1,7 @@
import type { z } from "zod";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows";
import { prisma } from "@calcom/prisma";
import type { EventType } from "@calcom/prisma/client";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
@@ -17,10 +18,7 @@ async function getEventType(eventTypeId: EventType["id"]) {
workflows: {
select: {
workflow: {
select: {
id: true,
steps: true,
},
select: workflowSelect,
},
},
},
+1
View File
@@ -1092,6 +1092,7 @@ export function ShellMain(props: LayoutProps) {
StartIcon="arrow-left"
aria-label="Go Back"
className="rounded-md ltr:mr-2 rtl:ml-2"
data-testid="go-back-button"
/>
)}
{props.heading && (
@@ -80,6 +80,7 @@ const WebhooksView = () => {
isAdmin={isAdmin}
createFunction={createFunction}
data-testid="new_webhook"
includeOrg={true}
/>
) : (
<></>
@@ -167,6 +168,7 @@ const WebhooksList = ({
isAdmin={isAdmin}
createFunction={createFunction}
data-testid="new_webhook"
includeOrg={true}
/>
}
/>
+1
View File
@@ -96,6 +96,7 @@ const commons = {
hidden: false,
userId: 0,
parentId: null,
parent: null,
owner: null,
workflows: [],
users: [user],
+9 -1
View File
@@ -125,6 +125,7 @@ export const getEventTypeById = async ({
},
parent: {
select: {
id: true,
teamId: true,
},
},
@@ -215,7 +216,14 @@ export const getEventTypeById = async ({
workflows: {
include: {
workflow: {
include: {
select: {
name: true,
id: true,
trigger: true,
time: true,
timeUnit: true,
userId: true,
teamId: true,
team: {
select: {
id: true,
+7 -11
View File
@@ -1,3 +1,4 @@
import { workflowSelect } from "@calcom/ee/workflows/lib/getAllWorkflows";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
@@ -43,21 +44,16 @@ export async function getBooking(bookingId: number) {
title: true,
teamId: true,
parentId: true,
parent: {
select: {
teamId: true,
},
},
slug: true,
workflows: {
select: {
workflow: {
select: {
id: true,
userId: true,
name: true,
steps: true,
position: true,
teamId: true,
time: true,
timeUnit: true,
trigger: true,
},
select: workflowSelect,
},
},
},
@@ -0,0 +1,32 @@
-- DropForeignKey
ALTER TABLE "WorkflowReminder" DROP CONSTRAINT "WorkflowReminder_workflowStepId_fkey";
-- AlterTable
ALTER TABLE "Workflow" ADD COLUMN "isActiveOnAll" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "WorkflowsOnTeams" (
"id" SERIAL NOT NULL,
"workflowId" INTEGER NOT NULL,
"teamId" INTEGER NOT NULL,
CONSTRAINT "WorkflowsOnTeams_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WorkflowsOnTeams_workflowId_idx" ON "WorkflowsOnTeams"("workflowId");
-- CreateIndex
CREATE INDEX "WorkflowsOnTeams_teamId_idx" ON "WorkflowsOnTeams"("teamId");
-- CreateIndex
CREATE UNIQUE INDEX "WorkflowsOnTeams_workflowId_teamId_key" ON "WorkflowsOnTeams"("workflowId", "teamId");
-- AddForeignKey
ALTER TABLE "WorkflowsOnTeams" ADD CONSTRAINT "WorkflowsOnTeams_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkflowsOnTeams" ADD CONSTRAINT "WorkflowsOnTeams_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkflowReminder" ADD CONSTRAINT "WorkflowReminder_workflowStepId_fkey" FOREIGN KEY ("workflowStepId") REFERENCES "WorkflowStep"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+28 -13
View File
@@ -404,6 +404,7 @@ model Team {
platformOAuthClient PlatformOAuthClient[]
smsLockState SMSLockState @default(UNLOCKED)
platformBilling PlatformBilling?
activeOrgWorkflows WorkflowsOnTeams[]
@@unique([slug, parentId])
@@index([parentId])
@@ -926,18 +927,20 @@ model WorkflowStep {
}
model Workflow {
id Int @id @default(autoincrement())
position Int @default(0)
name String
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int?
activeOn WorkflowsOnEventTypes[]
trigger WorkflowTriggerEvents
time Int?
timeUnit TimeUnit?
steps WorkflowStep[]
id Int @id @default(autoincrement())
position Int @default(0)
name String
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int?
activeOn WorkflowsOnEventTypes[]
activeOnTeams WorkflowsOnTeams[]
isActiveOnAll Boolean @default(false)
trigger WorkflowTriggerEvents
time Int?
timeUnit TimeUnit?
steps WorkflowStep[]
@@index([userId])
@@index([teamId])
@@ -973,6 +976,18 @@ model WorkflowsOnEventTypes {
@@index([eventTypeId])
}
model WorkflowsOnTeams {
id Int @id @default(autoincrement())
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
workflowId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int
@@unique([workflowId, teamId])
@@index([workflowId])
@@index([teamId])
}
model Deployment {
/// This is a single row table, so we use a fixed id
id Int @id @default(1)
@@ -998,7 +1013,7 @@ model WorkflowReminder {
referenceId String? @unique
scheduled Boolean
workflowStepId Int?
workflowStep WorkflowStep? @relation(fields: [workflowStepId], references: [id])
workflowStep WorkflowStep? @relation(fields: [workflowStepId], references: [id], onDelete: Cascade)
cancelled Boolean?
seatReferenceId String?
isMandatoryReminder Boolean? @default(false)
@@ -3,6 +3,7 @@ import { Prisma } from "@prisma/client";
import appStore from "@calcom/app-store";
import { getLocationValueForDB } from "@calcom/app-store/locations";
import type { LocationObject } from "@calcom/app-store/locations";
import { workflowSelect } from "@calcom/ee/workflows/lib/getAllWorkflows";
import { sendDeclinedEmails } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
@@ -77,16 +78,19 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
},
},
workflows: {
include: {
select: {
workflow: {
include: {
steps: true,
},
select: workflowSelect,
},
},
},
customInputs: true,
parentId: true,
parent: {
select: {
teamId: true,
},
},
},
},
location: true,
@@ -6,9 +6,6 @@ import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builde
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
import { deleteMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { deleteScheduledEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager";
import { deleteScheduledSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager";
import { deleteScheduledWhatsappReminder } from "@calcom/ee/workflows/lib/reminders/whatsappReminderManager";
import { sendRequestRescheduleEmail } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
@@ -24,12 +21,13 @@ import { getTranslation } from "@calcom/lib/server";
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
import { prisma } from "@calcom/prisma";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import { deleteAllWorkflowReminders } from "../workflows/util";
import type { TRequestRescheduleInputSchema } from "./requestReschedule.schema";
import type { PersonAttendeeCommonFields } from "./types";
@@ -149,23 +147,15 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
});
// delete scheduled jobs of previous booking
const webhookWorkflowPromises = [];
webhookWorkflowPromises.push(deleteWebhookScheduledTriggers({ booking: bookingToReschedule }));
const webhookPromises = [];
webhookPromises.push(deleteWebhookScheduledTriggers({ booking: bookingToReschedule }));
await Promise.all(webhookPromises).catch((error) => {
log.error("Error while deleting scheduled webhook triggers", JSON.stringify({ error }));
});
//cancel workflow reminders of previous booking
for (const reminder of bookingToReschedule.workflowReminders) {
if (reminder.method === WorkflowMethods.EMAIL) {
webhookWorkflowPromises.push(deleteScheduledEmailReminder(reminder.id, reminder.referenceId));
} else if (reminder.method === WorkflowMethods.SMS) {
webhookWorkflowPromises.push(deleteScheduledSMSReminder(reminder.id, reminder.referenceId));
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
webhookWorkflowPromises.push(deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId));
}
}
await Promise.all(webhookWorkflowPromises).catch((error) => {
log.error("Error while scheduling or canceling webhook triggers", JSON.stringify({ error }));
});
await deleteAllWorkflowReminders(bookingToReschedule.workflowReminders);
const [mainAttendee] = bookingToReschedule.attendees;
// @NOTE: Should we assume attendees language?
@@ -8,6 +8,7 @@ import { prisma } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import { deleteRemindersOfActiveOnIds } from "../workflows/util";
import type { TDeleteInputSchema } from "./delete.schema";
type DeleteOptions = {
@@ -22,6 +23,12 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
if (IS_TEAM_BILLING_ENABLED) await cancelTeamSubscriptionFromStripe(input.teamId);
try {
await deleteWorkflowRemindersOfRemovedTeam(input.teamId);
} catch (e) {
console.error(e);
}
const deletedTeam = await prisma.$transaction(async (tx) => {
// delete all memberships
await tx.membership.deleteMany({
@@ -44,4 +51,65 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
closeComDeleteTeam(deletedTeam);
};
// cancel/delete all workflowReminders of the removed team if the realted booking doesn't belong to another active team (org teams only)
async function deleteWorkflowRemindersOfRemovedTeam(teamId: number) {
const team = await prisma.team.findFirst({
where: {
id: teamId,
},
});
if (team?.parentId) {
const activeWorkflowsOnTeam = await prisma.workflow.findMany({
where: {
teamId: team.parentId,
OR: [
{
activeOnTeams: {
some: {
teamId: team.id,
},
},
},
{
isActiveOnAll: true,
},
],
},
select: {
steps: true,
activeOnTeams: true,
isActiveOnAll: true,
},
});
for (const workflow of activeWorkflowsOnTeam) {
const workflowSteps = workflow.steps;
let remainingActiveOnIds = [];
if (workflow.isActiveOnAll) {
const allRemainingOrgTeams = await prisma.team.findMany({
where: {
parentId: team.parentId,
id: {
not: team.id,
},
},
});
remainingActiveOnIds = allRemainingOrgTeams.map((team) => team.id);
} else {
remainingActiveOnIds = workflow.activeOnTeams
.filter((activeOn) => activeOn.teamId !== team.id)
.map((activeOn) => activeOn.teamId);
}
deleteRemindersOfActiveOnIds({
removedActiveOnIds: [team.id],
workflowSteps,
isOrg: true,
activeOnIds: remainingActiveOnIds,
});
}
}
}
export default deleteHandler;
@@ -5,6 +5,7 @@ import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZFilteredListInputSchema } from "./filteredList.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetAllActiveWorkflowsInputSchema } from "./getAllActiveWorkflows.schema";
import { ZGetVerifiedEmailsInputSchema } from "./getVerifiedEmails.schema";
import { ZGetVerifiedNumbersInputSchema } from "./getVerifiedNumbers.schema";
import { ZListInputSchema } from "./list.schema";
@@ -27,6 +28,7 @@ type WorkflowsRouterHandlerCache = {
filteredList?: typeof import("./filteredList.handler").filteredListHandler;
getVerifiedEmails?: typeof import("./getVerifiedEmails.handler").getVerifiedEmailsHandler;
verifyEmailCode?: typeof import("./verifyEmailCode.handler").verifyEmailCodeHandler;
getAllActiveWorkflows?: typeof import("./getAllActiveWorkflows.handler").getAllActiveWorkflowsHandler;
};
const UNSTABLE_HANDLER_CACHE: WorkflowsRouterHandlerCache = {};
@@ -255,4 +257,23 @@ export const workflowsRouter = router({
input,
});
}),
getAllActiveWorkflows: authedProcedure
.input(ZGetAllActiveWorkflowsInputSchema)
.query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getAllActiveWorkflows) {
UNSTABLE_HANDLER_CACHE.getAllActiveWorkflows = await import("./getAllActiveWorkflows.handler").then(
(mod) => mod.getAllActiveWorkflowsHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getAllActiveWorkflows) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getAllActiveWorkflows({
ctx,
input,
});
}),
});
@@ -1,25 +1,21 @@
import {
deleteScheduledEmailReminder,
scheduleEmailReminder,
} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import {
deleteScheduledSMSReminder,
scheduleSMSReminder,
} from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import {
deleteScheduledWhatsappReminder,
scheduleWhatsappReminder,
} from "@calcom/features/ee/workflows/lib/reminders/whatsappReminderManager";
import { scheduleEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { scheduleSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import { scheduleWhatsappReminder } from "@calcom/features/ee/workflows/lib/reminders/whatsappReminderManager";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/client";
import { MembershipRole, WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums";
import { MembershipRole, WorkflowActions } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TActivateEventTypeInputSchema } from "./activateEventType.schema";
import { removeSmsReminderFieldForBooking, upsertSmsReminderFieldForBooking } from "./util";
import {
deleteAllWorkflowReminders,
removeSmsReminderFieldForEventTypes,
upsertSmsReminderFieldForEventTypes,
} from "./util";
type ActivateEventTypeOptions = {
ctx: {
@@ -75,6 +71,11 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
},
include: {
steps: true,
team: {
select: {
isOrganization: true,
},
},
},
});
@@ -85,12 +86,18 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
});
//check if event type is already active
const isActive = await prisma.workflowsOnEventTypes.findFirst({
where: {
workflowId,
eventTypeId,
},
});
const isActive =
!!(await prisma.workflowsOnEventTypes.findFirst({
where: {
workflowId,
eventTypeId,
},
})) || eventTypeWorkflow.isActiveOnAll;
const isOrg = eventTypeWorkflow.team?.isOrganization ?? false;
const activeOn = [eventTypeId].concat(userEventType.children.map((ch) => ch.id));
if (isActive) {
// disable workflow for this event type & delete all reminders
const remindersToDelete = await prisma.workflowReminder.findMany({
@@ -113,15 +120,7 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
},
});
remindersToDelete.forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
}
});
await deleteAllWorkflowReminders(remindersToDelete);
await prisma.workflowsOnEventTypes.deleteMany({
where: {
@@ -130,12 +129,64 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
},
});
[eventTypeId].concat(userEventType.children.map((ch) => ch.id)).map(async (chId) => {
await removeSmsReminderFieldForBooking({
workflowId,
eventTypeId: chId,
if (eventTypeWorkflow.isActiveOnAll) {
await prisma.workflow.update({
where: {
id: workflowId,
},
data: {
isActiveOnAll: false,
},
});
});
let allEventTypes = [];
//get all event types of of team or user
if (eventTypeWorkflow.teamId) {
allEventTypes = await prisma.eventType.findMany({
where: {
id: {
not: eventTypeId,
},
teamId: eventTypeWorkflow.teamId,
},
});
} else {
const allEventTypesWithLocked = await prisma.eventType.findMany({
where: {
id: {
not: eventTypeId,
},
userId: eventTypeWorkflow.userId,
},
});
//if workflows are locked on managed event type then don't set user workflow active
allEventTypes = allEventTypesWithLocked.filter(
(eventType) =>
!eventType.parentId ||
EventTypeMetaDataSchema.parse(eventType.metadata)?.managedEventConfig?.unlockedFields?.workflows
);
}
// activate all event types on the workflow
for (const eventType of allEventTypes) {
await prisma.workflowsOnEventTypes.upsert({
create: {
workflowId,
eventTypeId: eventType.id,
},
update: {},
where: {
workflowId_eventTypeId: {
eventTypeId: eventType.id,
workflowId,
},
},
});
}
}
await removeSmsReminderFieldForEventTypes({ activeOnToRemove: activeOn, workflowId, isOrg });
} else {
// activate workflow and schedule reminders for existing bookings
@@ -270,13 +321,8 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
const isSmsReminderNumberRequired = eventTypeWorkflow.steps.some((step) => {
return requiresAttendeeNumber(step.action) && step.numberRequired;
});
[eventTypeId].concat(userEventType.children.map((ch) => ch.id)).map(async (evTyId) => {
await upsertSmsReminderFieldForBooking({
workflowId,
isSmsReminderNumberRequired,
eventTypeId: evTyId,
});
});
await upsertSmsReminderFieldForEventTypes({ activeOn, workflowId, isSmsReminderNumberRequired, isOrg });
}
}
};
@@ -1,13 +1,10 @@
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import { prisma } from "@calcom/prisma";
import { WorkflowMethods } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TDeleteInputSchema } from "./delete.schema";
import { isAuthorized, removeSmsReminderFieldForBooking } from "./util";
import { deleteAllWorkflowReminders, isAuthorized, removeSmsReminderFieldForEventTypes } from "./util";
type DeleteOptions = {
ctx: {
@@ -25,10 +22,16 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
},
include: {
activeOn: true,
activeOnTeams: true,
team: {
select: {
isOrganization: true,
},
},
},
});
const isUserAuthorized = await isAuthorized(workflowToDelete, prisma, ctx.user.id, true);
const isUserAuthorized = await isAuthorized(workflowToDelete, ctx.user.id, true);
if (!isUserAuthorized || !workflowToDelete) {
throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -47,18 +50,17 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
});
//cancel workflow reminders of deleted workflow
scheduledReminders.forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
}
});
await deleteAllWorkflowReminders(scheduledReminders);
for (const activeOn of workflowToDelete.activeOn) {
await removeSmsReminderFieldForBooking({ workflowId: id, eventTypeId: activeOn.eventTypeId });
}
const isOrg = workflowToDelete.team?.isOrganization ?? false;
const activeOnToRemove = isOrg
? workflowToDelete.activeOnTeams.map((activeOn) => activeOn.teamId)
: workflowToDelete.activeOn.map((activeOn) => activeOn.eventTypeId);
await removeSmsReminderFieldForEventTypes({ activeOnToRemove, workflowId: workflowToDelete.id, isOrg });
// automatically deletes all steps and reminders connected to this workflow
await prisma.workflow.deleteMany({
where: {
id,
@@ -33,6 +33,16 @@ const { include: includedFields } = Prisma.validator<Prisma.WorkflowDefaultArgs>
},
},
},
activeOnTeams: {
select: {
team: {
select: {
id: true,
name: true,
},
},
},
},
steps: true,
team: {
select: {
@@ -41,6 +51,7 @@ const { include: includedFields } = Prisma.validator<Prisma.WorkflowDefaultArgs>
name: true,
members: true,
logoUrl: true,
isOrganization: true,
},
},
},
@@ -88,7 +99,7 @@ export const filteredListHandler = async ({ ctx, input }: FilteredListOptions) =
(member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER
);
return { readOnly, ...workflow };
return { readOnly, isOrg: workflow.team?.isOrganization ?? false, ...workflow };
});
return {
@@ -140,7 +151,7 @@ export const filteredListHandler = async ({ ctx, input }: FilteredListOptions) =
(member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER
);
return { readOnly, ...workflow };
return { readOnly, isOrg: workflow.team?.isOrganization ?? false, ...workflow };
});
return {
@@ -23,12 +23,14 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
name: true,
userId: true,
teamId: true,
isActiveOnAll: true,
team: {
select: {
id: true,
slug: true,
members: true,
name: true,
isOrganization: true,
},
},
time: true,
@@ -38,6 +40,11 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
eventType: true,
},
},
activeOnTeams: {
select: {
team: true,
},
},
trigger: true,
steps: {
orderBy: {
@@ -47,7 +54,7 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
},
});
const isUserAuthorized = await isAuthorized(workflow, prisma, ctx.user.id);
const isUserAuthorized = await isAuthorized(workflow, ctx.user.id);
if (!isUserAuthorized) {
throw new TRPCError({
@@ -0,0 +1,38 @@
import { isTeamMember } from "@calcom/lib/server/queries";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TGetAllActiveWorkflowsInputSchema } from "./getAllActiveWorkflows.schema";
import { getAllWorkflowsFromEventType } from "./util";
type GetAllActiveWorkflowsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetAllActiveWorkflowsInputSchema;
};
export const getAllActiveWorkflowsHandler = async ({ input, ctx }: GetAllActiveWorkflowsOptions) => {
const { eventType } = input;
if (eventType.userId && eventType.userId !== ctx.user.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
if (eventType.teamId) {
const team = await isTeamMember(ctx.user?.id, eventType.teamId);
if (!team) throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (eventType.parent?.teamId) {
const team = await isTeamMember(ctx.user?.id, eventType.parent?.teamId);
if (!team) throw new TRPCError({ code: "UNAUTHORIZED" });
}
const allActiveWorkflows = await getAllWorkflowsFromEventType(eventType, eventType.userId);
return allActiveWorkflows;
};
@@ -0,0 +1,56 @@
import { z } from "zod";
import {
TIME_UNIT,
WORKFLOW_ACTIONS,
WORKFLOW_TEMPLATES,
WORKFLOW_TRIGGER_EVENTS,
} from "@calcom/ee/workflows/lib/constants";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
const ZWorkflow = z.object({
id: z.number(),
name: z.string(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
time: z.number().nullable(),
timeUnit: z.enum(TIME_UNIT).nullable(),
userId: z.number().nullable(),
teamId: z.number().nullable(),
steps: z
.object({
id: z.number(),
action: z.enum(WORKFLOW_ACTIONS),
sendTo: z.string().nullable(),
template: z.enum(WORKFLOW_TEMPLATES),
reminderBody: z.string().nullable(),
emailSubject: z.string().nullable(),
numberRequired: z.boolean().nullable(),
sender: z.string().nullable(),
includeCalendarEvent: z.boolean(),
numberVerificationPending: z.boolean(),
})
.array(),
});
export const ZGetAllActiveWorkflowsInputSchema = z.object({
eventType: z.object({
workflows: z
.object({
workflow: ZWorkflow,
})
.array()
.optional(),
teamId: z.number().optional().nullable(),
parent: z
.object({
id: z.number().nullable(),
teamId: z.number().nullable(),
})
.optional()
.nullable(),
metadata: EventTypeMetaDataSchema,
userId: z.number().optional().nullable(),
}),
});
export type TGetAllActiveWorkflowsInputSchema = z.infer<typeof ZGetAllActiveWorkflowsInputSchema>;
@@ -15,8 +15,96 @@ type ListOptions = {
};
export const listHandler = async ({ ctx, input }: ListOptions) => {
const workflows: WorkflowType[] = [];
const org = await prisma.team.findFirst({
where: {
isOrganization: true,
children: {
some: {
id: input?.teamId,
},
},
members: {
some: {
userId: input?.userId || ctx.user.id,
accepted: true,
},
},
},
select: {
id: true,
},
});
if (org) {
const activeOrgWorkflows = await prisma.workflow.findMany({
where: {
team: {
id: org.id,
members: {
some: {
userId: ctx.user.id,
accepted: true,
},
},
},
OR: [
{
isActiveOnAll: true,
},
{
activeOnTeams: {
some: {
team: {
OR: [
{ id: input?.teamId },
{
members: {
some: {
userId: ctx.user.id,
accepted: true,
},
},
},
],
},
},
},
},
],
},
include: {
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
activeOnTeams: {
select: {
team: {
select: {
id: true,
name: true,
},
},
},
},
steps: true,
},
});
workflows.push(
...activeOrgWorkflows.map((workflow) => {
return { ...workflow, isOrg: true, readOnly: true };
})
);
}
if (input && input.teamId) {
const workflows: WorkflowType[] = await prisma.workflow.findMany({
const teamWorkflows: WorkflowType[] = await prisma.workflow.findMany({
where: {
team: {
id: input.teamId,
@@ -59,18 +147,20 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
id: "asc",
},
});
const workflowsWithReadOnly = workflows.map((workflow) => {
const workflowsWithReadOnly = teamWorkflows.map((workflow) => {
const readOnly = !!workflow.team?.members?.find(
(member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER
);
return { ...workflow, readOnly };
});
return { workflows: workflowsWithReadOnly };
workflows.push(...workflowsWithReadOnly);
return { workflows };
}
if (input && input.userId) {
const workflows: WorkflowType[] = await prisma.workflow.findMany({
const userWorkflows: WorkflowType[] = await prisma.workflow.findMany({
where: {
userId: ctx.user.id,
},
@@ -106,10 +196,12 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
},
});
workflows.push(...userWorkflows);
return { workflows };
}
const workflows = await prisma.workflow.findMany({
const allWorkflows = await prisma.workflow.findMany({
where: {
OR: [
{ userId: ctx.user.id },
@@ -157,7 +249,7 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
},
});
const workflowsWithReadOnly: WorkflowType[] = workflows.map((workflow) => {
const workflowsWithReadOnly: WorkflowType[] = allWorkflows.map((workflow) => {
const readOnly = !!workflow.team?.members?.find(
(member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER
);
@@ -165,5 +257,7 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
return { readOnly, ...workflow };
});
return { workflows: workflowsWithReadOnly };
workflows.push(...workflowsWithReadOnly);
return { workflows };
};
File diff suppressed because it is too large. Load diff
@@ -17,19 +17,20 @@ export const ZUpdateInputSchema = z.object({
stepNumber: z.number(),
action: z.enum(WORKFLOW_ACTIONS),
workflowId: z.number(),
sendTo: z.string().optional().nullable(),
reminderBody: z.string().optional().nullable(),
emailSubject: z.string().optional().nullable(),
sendTo: z.string().nullable(),
reminderBody: z.string().nullable(),
emailSubject: z.string().nullable(),
template: z.enum(WORKFLOW_TEMPLATES),
numberRequired: z.boolean().nullable(),
sender: z.string().optional().nullable(),
senderName: z.string().optional().nullable(),
sender: z.string().nullable(),
senderName: z.string().nullable(),
includeCalendarEvent: z.boolean(),
})
.array(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
time: z.number().nullable(),
timeUnit: z.enum(TIME_UNIT).nullable(),
isActiveOnAll: z.boolean().optional(),
});
export type TUpdateInputSchema = z.infer<typeof ZUpdateInputSchema>;
@@ -1,6 +1,20 @@
import type { Workflow } from "@prisma/client";
import { isSMSOrWhatsappAction } from "@calcom/ee/workflows/lib/actionHelperFunctions";
import { getAllWorkflows } from "@calcom/ee/workflows/lib/getAllWorkflows";
import {
deleteScheduledEmailReminder,
scheduleEmailReminder,
} from "@calcom/ee/workflows/lib/reminders/emailReminderManager";
import {
deleteScheduledSMSReminder,
scheduleSMSReminder,
} from "@calcom/ee/workflows/lib/reminders/smsReminderManager";
import {
deleteScheduledWhatsappReminder,
scheduleWhatsappReminder,
} from "@calcom/ee/workflows/lib/reminders/whatsappReminderManager";
import type { Workflow as WorkflowType } from "@calcom/ee/workflows/lib/types";
import { SMS_REMINDER_NUMBER_FIELD } from "@calcom/features/bookings/lib/SystemField";
import {
getSmsReminderNumberField,
@@ -8,9 +22,90 @@ import {
} from "@calcom/features/bookings/lib/getBookingFields";
import { removeBookingField, upsertBookingField } from "@calcom/features/eventtypes/lib/bookingFieldsManager";
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import type { PrismaClient } from "@calcom/prisma";
import type { WorkflowStep } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { Prisma, WorkflowStep } from "@calcom/prisma/client";
import type { TimeUnit } from "@calcom/prisma/enums";
import {
BookingStatus,
MembershipRole,
WorkflowActions,
WorkflowMethods,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
const log = logger.getSubLogger({ prefix: ["workflow"] });
export const bookingSelect = {
userPrimaryEmail: true,
startTime: true,
endTime: true,
title: true,
uid: true,
attendees: {
select: {
name: true,
email: true,
timeZone: true,
locale: true,
},
},
eventType: {
select: {
slug: true,
id: true,
},
},
user: {
select: {
name: true,
timeZone: true,
timeFormat: true,
locale: true,
email: true,
},
},
};
export const verifyEmailSender = async (email: string, userId: number, teamId: number | null) => {
const verifiedEmail = await prisma.verifiedEmail.findFirst({
where: {
email,
OR: [{ userId }, { teamId }],
},
});
if (!verifiedEmail) {
throw new TRPCError({ code: "NOT_FOUND", message: "Email not verified" });
}
if (teamId) {
if (!verifiedEmail.teamId) {
await prisma.verifiedEmail.update({
where: {
id: verifiedEmail.id,
},
data: {
teamId,
},
});
} else if (verifiedEmail.teamId !== teamId) {
await prisma.verifiedEmail.create({
data: {
email,
userId,
teamId,
},
});
}
}
};
export function getSender(
step: Pick<WorkflowStep, "action" | "sender"> & { senderName: string | null | undefined }
@@ -20,21 +115,20 @@ export function getSender(
export async function isAuthorized(
workflow: Pick<Workflow, "id" | "teamId" | "userId"> | null,
prisma: PrismaClient,
currentUserId: number,
readOnly?: boolean
isWriteOperation?: boolean
) {
if (!workflow) {
return false;
}
if (!readOnly) {
if (!isWriteOperation) {
const userWorkflow = await prisma.workflow.findFirst({
where: {
id: workflow.id,
OR: [
{ userId: currentUserId },
{
// for read operation every team member has access
team: {
members: {
some: {
@@ -61,6 +155,7 @@ export async function isAuthorized(
some: {
userId: currentUserId,
accepted: true,
//only admins can update team/org workflows
NOT: {
role: MembershipRole.MEMBER,
},
@@ -77,26 +172,60 @@ export async function isAuthorized(
return false;
}
export async function upsertSmsReminderFieldForBooking({
export async function upsertSmsReminderFieldForEventTypes({
activeOn,
workflowId,
eventTypeId,
isSmsReminderNumberRequired,
isOrg,
}: {
activeOn: number[];
workflowId: number;
isSmsReminderNumberRequired: boolean;
eventTypeId: number;
isOrg: boolean;
}) {
await upsertBookingField(
getSmsReminderNumberField(),
getSmsReminderNumberSource({
workflowId,
isSmsReminderNumberRequired,
}),
eventTypeId
);
let allEventTypeIds = activeOn;
if (isOrg) {
allEventTypeIds = await getAllUserAndTeamEventTypes(activeOn);
}
for (const eventTypeId of allEventTypeIds) {
await upsertBookingField(
getSmsReminderNumberField(),
getSmsReminderNumberSource({
workflowId,
isSmsReminderNumberRequired,
}),
eventTypeId
);
}
}
export async function removeSmsReminderFieldForBooking({
export async function removeSmsReminderFieldForEventTypes({
activeOnToRemove,
workflowId,
isOrg,
activeOn,
}: {
activeOnToRemove: number[];
workflowId: number;
isOrg: boolean;
activeOn?: number[];
}) {
let allEventTypeIds = activeOnToRemove;
if (isOrg) {
allEventTypeIds = await getAllUserAndTeamEventTypes(activeOnToRemove, activeOn);
}
for (const eventTypeId of allEventTypeIds) {
await removeSmsReminderFieldForEventType({
workflowId,
eventTypeId,
});
}
}
export async function removeSmsReminderFieldForEventType({
workflowId,
eventTypeId,
}: {
@@ -114,3 +243,564 @@ export async function removeSmsReminderFieldForBooking({
eventTypeId
);
}
async function getAllUserAndTeamEventTypes(teamIds: number[], notMemberOfTeamId: number[] = []) {
const teamMembersWithEventTypes = await prisma.membership.findMany({
where: {
teamId: {
in: teamIds,
},
user: {
teams: {
none: {
team: {
id: {
in: notMemberOfTeamId ?? [],
},
},
},
},
},
},
select: {
user: {
select: {
eventTypes: {
select: {
id: true,
},
},
},
},
},
});
const teamEventTypes = await prisma.eventType.findMany({
where: {
teamId: {
in: teamIds,
},
},
});
const userEventTypes = teamMembersWithEventTypes?.flatMap((membership) =>
membership.user.eventTypes.map((et) => et.id)
);
return teamEventTypes.map((et) => et.id).concat(userEventTypes);
}
export async function isAuthorizedToAddActiveOnIds(
newActiveIds: number[],
isOrg: boolean,
teamId?: number | null,
userId?: number | null
) {
for (const id of newActiveIds) {
if (isOrg) {
const newTeam = await prisma.team.findFirst({
where: {
id,
},
select: {
parent: true,
},
});
if (newTeam?.parent?.id !== teamId) {
return false;
}
} else {
const newEventType = await prisma.eventType.findFirst({
where: {
id,
},
include: {
users: {
select: {
id: true,
},
},
children: true,
},
});
if (newEventType) {
if (teamId && teamId !== newEventType.teamId) {
return false;
}
if (
!teamId &&
userId &&
newEventType.userId !== userId &&
!newEventType?.users.find((eventTypeUser) => eventTypeUser.id === userId)
) {
return false;
}
}
}
}
return true;
}
const reminderMethods: {
[x: string]: (id: number, referenceId: string | null) => void;
} = {
[WorkflowMethods.EMAIL]: (id, referenceId) => deleteScheduledEmailReminder(id, referenceId),
[WorkflowMethods.SMS]: (id, referenceId) => deleteScheduledSMSReminder(id, referenceId),
[WorkflowMethods.WHATSAPP]: (id, referenceId) => deleteScheduledWhatsappReminder(id, referenceId),
};
export async function deleteAllWorkflowReminders(
remindersToDelete:
| {
id: number;
referenceId: string | null;
method: string;
}[]
| null
) {
if (!remindersToDelete) return Promise.resolve();
const results = await Promise.allSettled(
remindersToDelete.map((reminder) => {
return reminderMethods[reminder.method](reminder.id, reminder.referenceId);
})
);
results.forEach((result, index) => {
if (result.status !== "fulfilled") {
log.error(
`An error occurred when deleting reminder ${remindersToDelete[index].id}, method: ${remindersToDelete[index].method}`,
result.reason
);
}
});
}
export async function deleteRemindersOfActiveOnIds({
removedActiveOnIds,
workflowSteps,
isOrg,
activeOnIds,
}: {
removedActiveOnIds: number[];
workflowSteps: WorkflowStep[];
isOrg: boolean;
activeOnIds?: number[];
}) {
const remindersToDelete = !isOrg
? await getRemindersFromRemovedEventTypes(removedActiveOnIds, workflowSteps)
: await getRemindersFromRemovedTeams(removedActiveOnIds, workflowSteps, activeOnIds);
await deleteAllWorkflowReminders(remindersToDelete);
}
async function getRemindersFromRemovedTeams(
removedTeams: number[],
workflowSteps: WorkflowStep[],
activeOn?: number[]
) {
const remindersToDeletePromise: Prisma.PrismaPromise<
{
id: number;
referenceId: string | null;
method: string;
}[]
>[] = [];
removedTeams.forEach((teamId) => {
const reminderToDelete = prisma.workflowReminder.findMany({
where: {
OR: [
{
//team event types + children managed event types
booking: {
eventType: {
OR: [{ teamId }, { teamId: null, parent: { teamId } }],
},
},
},
{
// user bookings
booking: {
user: {
AND: [
// user is part of team that got removed
{
teams: {
some: {
teamId: teamId,
},
},
},
// and user is not part of any team were the workflow is still active on
{
teams: {
none: {
teamId: {
in: activeOn,
},
},
},
},
],
},
eventType: {
teamId: null,
parentId: null, // children managed event types are handled above with team event types
},
},
},
],
workflowStepId: {
in: workflowSteps.map((step) => {
return step.id;
}),
},
},
select: {
id: true,
referenceId: true,
method: true,
},
});
remindersToDeletePromise.push(reminderToDelete);
});
const remindersToDelete = (await Promise.all(remindersToDeletePromise)).flat();
return remindersToDelete;
}
async function getRemindersFromRemovedEventTypes(removedEventTypes: number[], workflowSteps: WorkflowStep[]) {
const remindersToDeletePromise: Prisma.PrismaPromise<
{
id: number;
referenceId: string | null;
method: string;
}[]
>[] = [];
removedEventTypes.forEach((eventTypeId) => {
const remindersToDelete = prisma.workflowReminder.findMany({
where: {
booking: {
eventTypeId,
},
workflowStepId: {
in: workflowSteps.map((step) => {
return step.id;
}),
},
},
select: {
id: true,
referenceId: true,
method: true,
},
});
remindersToDeletePromise.push(remindersToDelete);
});
const remindersToDelete = (await Promise.all(remindersToDeletePromise)).flat();
return remindersToDelete;
}
export async function scheduleWorkflowNotifications(
activeOn: number[],
isOrg: boolean,
workflowSteps: Partial<WorkflowStep>[],
time: number | null,
timeUnit: TimeUnit | null,
trigger: WorkflowTriggerEvents,
userId: number,
teamId: number | null,
alreadyScheduledActiveOnIds?: number[]
) {
const bookingsToScheduleNotifications = await getBookings(activeOn, isOrg, alreadyScheduledActiveOnIds);
await scheduleBookingReminders(
bookingsToScheduleNotifications,
workflowSteps,
time,
timeUnit,
trigger,
userId,
teamId
);
}
async function getBookings(activeOn: number[], isOrg: boolean, alreadyScheduledActiveOnIds: number[] = []) {
if (activeOn.length === 0) return [];
if (isOrg) {
const bookingsForReminders = await prisma.booking.findMany({
where: {
OR: [
{
// bookings from team event types + children managed event types
eventType: {
OR: [
{
teamId: {
in: activeOn,
},
},
{
teamId: null,
parent: {
teamId: {
in: activeOn,
},
},
},
],
},
},
{
// user bookings
user: {
teams: {
some: {
teamId: {
in: activeOn,
},
accepted: true,
},
},
},
eventType: {
teamId: null,
parentId: null, // children managed event types are handled above with team event types
},
// if user is already part of an already scheduled activeOn connecting reminders are already scheduled
NOT: {
user: {
teams: {
some: {
teamId: {
in: alreadyScheduledActiveOnIds,
},
},
},
},
},
},
],
status: BookingStatus.ACCEPTED,
startTime: {
gte: new Date(),
},
},
select: bookingSelect,
});
return bookingsForReminders;
} else {
const bookingsForReminders = await prisma.booking.findMany({
where: {
OR: [
{ eventTypeId: { in: activeOn } },
{
eventType: {
parentId: {
in: activeOn, // child event type can not disable workflows, so this should work
},
},
},
],
status: BookingStatus.ACCEPTED,
startTime: {
gte: new Date(),
},
},
select: bookingSelect,
});
return bookingsForReminders;
}
}
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Bookings = UnwrapPromise<ReturnType<typeof getBookings>>;
// some parts of scheduleWorkflowReminders (reminderSchedule.ts) is quite similar to this code
// we should consider refactoring this to reuse similar code snippets
export async function scheduleBookingReminders(
bookings: Bookings,
workflowSteps: Partial<WorkflowStep>[],
time: number | null,
timeUnit: TimeUnit | null,
trigger: WorkflowTriggerEvents,
userId: number,
teamId: number | null
) {
if (!bookings.length) return;
if (trigger !== WorkflowTriggerEvents.BEFORE_EVENT && trigger !== WorkflowTriggerEvents.AFTER_EVENT) return;
//create reminders for all bookings for each workflow step
const promiseSteps = workflowSteps.map(async (step) => {
// we do not have attendees phone number (user is notified about that when setting this action)
if (step.action == WorkflowActions.SMS_ATTENDEE || step.action == WorkflowActions.WHATSAPP_ATTENDEE)
return;
const promiseScheduleReminders = bookings.map(async (booking) => {
const defaultLocale = "en";
const bookingInfo = {
uid: booking.uid,
attendees: booking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { locale: attendee.locale || defaultLocale },
};
}),
organizer: booking.user
? {
language: { locale: booking.user.locale || defaultLocale },
name: booking.user.name || "",
email: booking?.userPrimaryEmail ?? booking.user.email,
timeZone: booking.user.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(booking.user.timeFormat),
}
: { name: "", email: "", timeZone: "", language: { locale: "" } },
startTime: booking.startTime?.toISOString(),
endTime: booking.endTime?.toISOString(),
title: booking.title,
language: { locale: booking?.user?.locale || defaultLocale },
eventType: {
slug: booking.eventType?.slug,
},
};
if (
step.action === WorkflowActions.EMAIL_HOST ||
step.action === WorkflowActions.EMAIL_ATTENDEE ||
step.action === WorkflowActions.EMAIL_ADDRESS
) {
let sendTo: string[] = [];
switch (step.action) {
case WorkflowActions.EMAIL_HOST:
sendTo = [bookingInfo.organizer?.email];
break;
case WorkflowActions.EMAIL_ATTENDEE:
sendTo = bookingInfo.attendees.map((attendee) => attendee.email);
break;
case WorkflowActions.EMAIL_ADDRESS:
await verifyEmailSender(step.sendTo || "", userId, teamId);
sendTo = [step.sendTo || ""];
}
await scheduleEmailReminder({
evt: bookingInfo,
triggerEvent: trigger,
action: step.action,
timeSpan: {
time,
timeUnit,
},
sendTo,
emailSubject: step.emailSubject || "",
emailBody: step.reminderBody || "",
template: step.template,
sender: step.sender,
workflowStepId: step.id,
});
} else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) {
await scheduleSMSReminder({
evt: bookingInfo,
reminderPhone: step.sendTo,
triggerEvent: trigger,
action: step.action,
timeSpan: {
time,
timeUnit,
},
message: step.reminderBody || "",
workflowStepId: step.id,
template: step.template,
sender: step.sender,
userId: userId,
teamId: teamId,
});
} else if (step.action === WorkflowActions.WHATSAPP_NUMBER && step.sendTo) {
await scheduleWhatsappReminder({
evt: bookingInfo,
reminderPhone: step.sendTo,
triggerEvent: trigger,
action: step.action,
timeSpan: {
time,
timeUnit,
},
message: step.reminderBody || "",
workflowStepId: step.id || 0,
template: step.template,
userId: userId,
teamId: teamId,
});
}
});
await Promise.all(promiseScheduleReminders);
});
return Promise.all(promiseSteps);
}
export function isStepEdited(oldStep: WorkflowStep, newStep: WorkflowStep) {
const oldStepKeys = Object.keys(oldStep);
const newStepKeys = Object.keys(newStep);
if (oldStepKeys.length !== newStepKeys.length) {
return true;
}
for (const key of oldStepKeys) {
if (oldStep[key as keyof WorkflowStep] !== newStep[key as keyof WorkflowStep]) {
return true;
}
}
return false;
}
export async function getAllWorkflowsFromEventType(
eventType: {
workflows?: {
workflow: WorkflowType;
}[];
teamId?: number | null;
parentId?: number | null;
parent?: {
id?: number | null;
teamId: number | null;
} | null;
metadata?: Prisma.JsonValue;
} | null,
userId?: number | null
) {
if (!eventType) return [];
const eventTypeWorkflows = eventType?.workflows?.map((workflowRel) => workflowRel.workflow) ?? [];
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: eventType?.teamId ?? null },
parentId: eventType?.parentId || eventType?.parent?.id || null,
},
});
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId });
const isManagedEventType = !!eventType?.parent;
const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType?.metadata || {});
const workflowsLockedForUser = isManagedEventType
? !eventTypeMetadata?.managedEventConfig?.unlockedFields?.workflows
: false;
const allWorkflows = await getAllWorkflows(
eventTypeWorkflows,
userId,
teamId,
orgId,
workflowsLockedForUser
);
return allWorkflows;
}
@@ -8,11 +8,10 @@ export function CreateButtonWithTeamsList(
onlyShowWithTeams?: boolean;
onlyShowWithNoTeams?: boolean;
isAdmin?: boolean;
includeOrg?: boolean;
}
) {
const query = trpc.viewer.teamsAndUserProfilesQuery.useQuery({
includeOrg: true,
});
const query = trpc.viewer.teamsAndUserProfilesQuery.useQuery({ includeOrg: props.includeOrg });
if (!query.data) return null;
const teamsAndUserProfiles: Option[] = query.data
@@ -1,6 +1,12 @@
import type { Dispatch, SetStateAction } from "react";
import React from "react";
import type { GroupBase, OptionProps } from "react-select";
import type {
GroupBase,
OptionProps,
MultiValueProps,
MultiValue as MultiValueType,
SingleValue,
} from "react-select";
import { components } from "react-select";
import type { Props } from "react-select";
@@ -14,7 +20,7 @@ export type Option = {
label: string;
};
const InputOption: React.FC<OptionProps<unknown, boolean, GroupBase<unknown>>> = ({
const InputOption: React.FC<OptionProps<Option, boolean, GroupBase<Option>>> = ({
isDisabled,
isFocused,
isSelected,
@@ -49,12 +55,21 @@ type MultiSelectionCheckboxesProps = {
setSelected: Dispatch<SetStateAction<Option[]>>;
selected: Option[];
setValue: (s: Option[]) => unknown;
countText?: string;
};
const MultiValue = ({ index, getValue }: { index: number; getValue: () => { length: number } }) => {
const MultiValue = ({
index,
getValue,
countText,
}: {
index: number;
getValue: () => readonly Option[];
countText: string;
}) => {
const { t } = useLocale();
return <>{!index && <div>{t("nr_event_type", { count: getValue().length })}</div>}</>;
const count = getValue().filter((option) => option.value !== "all").length;
return <>{!index && count !== 0 && <div>{t(countText, { count })}</div>}</>;
};
export default function MultiSelectCheckboxes({
@@ -65,26 +80,60 @@ export default function MultiSelectCheckboxes({
setValue,
className,
isDisabled,
countText,
}: Omit<Props, "options"> & MultiSelectionCheckboxesProps) {
const additonalComponents = { MultiValue };
const additonalComponents = {
MultiValue: (props: MultiValueProps<Option, boolean, GroupBase<Option>>) => (
<MultiValue {...props} countText={countText || "selected"} />
),
};
const allOptions = [{ label: "Select all", value: "all" }, ...options];
const allSelected = selected.length === options.length ? allOptions : selected;
return (
<Select
value={selected}
value={allSelected}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange={(s: any) => {
setSelected(s);
setValue(s);
onChange={(s: MultiValueType<Option> | SingleValue<Option>, event: any) => {
const allSelected = [];
if (s !== null && Array.isArray(s) && s.length > 0) {
if (s.find((option) => option.value === "all")) {
if (event.action === "select-option") {
allSelected.push(...[{ label: "Select all", value: "all" }, ...options]);
} else {
allSelected.push(...s.filter((option) => option.value !== "all"));
}
} else {
if (s.length === options.length) {
if (s.find((option) => option.value === "all")) {
allSelected.push(...s.filter((option) => option.value !== "all"));
} else {
if (event.action === "select-option") {
allSelected.push(...[...s, { label: "Select all", value: "all" }]);
}
}
} else {
allSelected.push(...s);
}
}
}
setSelected(allSelected);
setValue(allSelected);
}}
variant="checkbox"
options={options}
options={allOptions.length > 1 ? allOptions : []}
isMulti
isDisabled={isDisabled}
className={classNames(className ? className : "w-64 text-sm")}
isSearchable={false}
isSearchable={true}
closeMenuOnSelect={false}
hideSelectedOptions={false}
isLoading={isLoading}
data-testid="multi-select-check-boxes"
components={{
...additonalComponents,
Option: InputOption,