From c4104af8a211f9cba3cd461422614b799a90829e Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Mon, 8 Jul 2024 08:14:11 -0400 Subject: [PATCH] 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 Co-authored-by: Hariom Balhara Co-authored-by: Keith Williams Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- .../organizations/organizations.module.ts | 2 +- apps/web/lib/booking.ts | 6 +- .../views/event-types-single-view.tsx | 34 +- apps/web/playwright/availability.e2e.ts | 2 +- apps/web/playwright/fixtures/workflows.ts | 3 +- apps/web/playwright/workflow.e2e.ts | 3 +- apps/web/public/static/locales/en/common.json | 11 + apps/web/test/lib/getSchedule.test.ts | 33 +- .../utils/bookingScenario/bookingScenario.ts | 131 ++- .../web/test/utils/bookingScenario/expects.ts | 49 +- .../features/bookings/lib/getBookingFields.ts | 37 +- .../bookings/lib/handleCancelBooking.ts | 54 +- .../bookings/lib/handleConfirmation.ts | 86 +- .../features/bookings/lib/handleNewBooking.ts | 49 +- .../handleNewBooking/getEventTypesFromDB.ts | 12 +- .../test/booking-limits.test.ts | 9 - .../test/fresh-booking.test.ts | 26 +- .../handleNewBooking/test/reschedule.test.ts | 16 +- .../test/workflow-notifications.test.ts | 247 +++- .../handleSeats/cancel/cancelAttendeeSeat.ts | 26 +- .../lib/handleSeats/create/createNewSeat.ts | 22 +- .../bookings/lib/handleSeats/handleSeats.ts | 3 +- .../bookings/lib/handleSeats/types.d.ts | 2 + .../deleteWorkflowRemindersOfRemovedMember.ts | 95 ++ .../features/ee/teams/lib/removeMember.ts | 68 +- .../ee/workflows/components/EmptyScreen.tsx | 1 + .../components/EventWorkflowsTab.tsx | 40 +- .../components/WorkflowDetailsPage.tsx | 105 +- .../workflows/components/WorkflowListPage.tsx | 37 +- .../lib/allowDisablingStandardEmails.ts | 15 +- .../ee/workflows/lib/getActionIcon.tsx | 4 +- .../ee/workflows/lib/getAllWorkflows.ts | 120 ++ .../lib/reminders/emailReminderManager.ts | 2 +- .../reminders/providers/sendgridProvider.ts | 7 +- .../lib/reminders/providers/twilioProvider.ts | 21 +- .../lib/reminders/reminderScheduler.ts | 53 +- .../reminders/scheduleMandatoryReminder.ts | 26 +- .../lib/reminders/smsReminderManager.ts | 2 + .../ee/workflows/lib/test/workflows.test.ts | 857 ++++++++++++++ packages/features/ee/workflows/lib/types.ts | 30 + .../features/ee/workflows/pages/index.tsx | 4 +- .../features/ee/workflows/pages/workflow.tsx | 174 ++- .../eventtypes/lib/bookingFieldsManager.ts | 6 +- packages/features/shell/Shell.tsx | 1 + .../features/webhooks/pages/webhooks-view.tsx | 2 + packages/lib/defaultEvents.ts | 1 + packages/lib/event-types/getEventTypeById.ts | 10 +- packages/lib/payment/getBooking.ts | 18 +- .../migration.sql | 32 + packages/prisma/schema.prisma | 41 +- .../viewer/bookings/confirm.handler.ts | 12 +- .../bookings/requestReschedule.handler.ts | 28 +- .../routers/viewer/teams/delete.handler.ts | 68 ++ .../routers/viewer/workflows/_router.tsx | 21 + .../workflows/activateEventType.handler.ts | 128 ++- .../viewer/workflows/delete.handler.ts | 32 +- .../viewer/workflows/filteredList.handler.tsx | 15 +- .../routers/viewer/workflows/get.handler.ts | 9 +- .../getAllActiveWorkflows.handler.ts | 38 + .../workflows/getAllActiveWorkflows.schema.ts | 56 + .../routers/viewer/workflows/list.handler.ts | 108 +- .../viewer/workflows/update.handler.ts | 1007 ++++++----------- .../routers/viewer/workflows/update.schema.ts | 11 +- .../server/routers/viewer/workflows/util.ts | 728 +++++++++++- .../CreateButtonWithTeamsList.tsx | 5 +- .../form/checkbox/MultiSelectCheckboxes.tsx | 73 +- 66 files changed, 3633 insertions(+), 1341 deletions(-) create mode 100644 packages/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember.ts create mode 100644 packages/features/ee/workflows/lib/getAllWorkflows.ts create mode 100644 packages/features/ee/workflows/lib/test/workflows.test.ts create mode 100644 packages/features/ee/workflows/lib/types.ts create mode 100644 packages/prisma/migrations/20240517144241_add_org_wide_workflows/migration.sql create mode 100644 packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.schema.ts diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index cc6f958966..4368cb1a49 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -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"; diff --git a/apps/web/lib/booking.ts b/apps/web/lib/booking.ts index 6447a260eb..f01cee0fc3 100644 --- a/apps/web/lib/booking.ts +++ b/apps/web/lib/booking.ts @@ -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, }, }, }, diff --git a/apps/web/modules/event-types/views/event-types-single-view.tsx b/apps/web/modules/event-types/views/event-types-single-view.tsx index 3f51828a6d..1f13468fe0 100644 --- a/apps/web/modules/event-types/views/event-types-single-view.tsx +++ b/apps/web/modules/event-types/views/event-types-single-view.tsx @@ -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: , recurring: , apps: , - workflows: ( - workflowOnEventType.workflow)} - /> + workflows: props.allActiveWorkflows ? ( + + ) : ( + <> ), webhooks: , ai: , @@ -705,7 +705,7 @@ const EventTypePage = (props: EventTypeSetupProps) => { webhook.active).length} team={team} @@ -853,7 +853,25 @@ const EventTypePageWrapper: React.FC & { const { data } = trpc.viewer.eventTypes.get.useQuery({ id: props.type }); if (!data) return null; - return ; + + 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 ; }; export default EventTypePageWrapper; diff --git a/apps/web/playwright/availability.e2e.ts b/apps/web/playwright/availability.e2e.ts index e07b553896..89ab2bc6b5 100644 --- a/apps/web/playwright/availability.e2e.ts +++ b/apps/web/playwright/availability.e2e.ts @@ -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"]'); diff --git a/apps/web/playwright/fixtures/workflows.ts b/apps/web/playwright/fixtures/workflows.ts index d013134a2f..99a4d4ba11 100644 --- a/apps/web/playwright/fixtures/workflows.ts +++ b/apps/web/playwright/fixtures/workflows.ts @@ -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(); }; diff --git a/apps/web/playwright/workflow.e2e.ts b/apps/web/playwright/workflow.e2e.ts index 9b9affa46a..5b5aafe152 100644 --- a/apps/web/playwright/workflow.e2e.ts +++ b/apps/web/playwright/workflow.e2e.ts @@ -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"); }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 470d271405..1921edaf93 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index afa55bd817..48cbb5450b 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -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 }); diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index d6b33e7ce6..702e9799fb 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -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 & { 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[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; + 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}`, }; diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 6eda229076..22030bd31c 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -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( diff --git a/packages/features/bookings/lib/getBookingFields.ts b/packages/features/bookings/lib/getBookingFields.ts index a67b3f04f8..21ea352055 100644 --- a/packages/features/bookings/lib/getBookingFields.ts +++ b/packages/features/bookings/lib/getBookingFields.ts @@ -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[]; metadata: EventType["metadata"] | z.infer; - 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[]; - 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; diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 8a9c5441aa..98a630941f 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -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) => { diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index 98423f8b6f..bdeb65fc9a 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -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, diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 0245e11ea7..72c115be59 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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, diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 8948bcb268..38576d80ae 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -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, }, }, }, diff --git a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts index 4cd45a3bda..a9f2322ee6 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts @@ -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, diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index b0d7027f3e..732fa4a604 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -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: [ diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts index 3f568f6064..080d42d7de 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -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: [ diff --git a/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts b/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts index 4ceceedca2..e2a523ea77 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts @@ -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", + }); + }); + }); }); diff --git a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts index 0473710af4..af732a9027 100644 --- a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts +++ b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts @@ -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 }; } diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts index 214d7752a6..7b19d2848e 100644 --- a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -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, diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index 20277ed8df..b4cf2a661a 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -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, diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index 9233a0fd16..7f79068146 100644 --- a/packages/features/bookings/lib/handleSeats/types.d.ts +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -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>["responses"] | null; + workflows: Workflow[]; }; export type RescheduleSeatedBookingObject = NewSeatedBookingObject & { rescheduleUid: string }; diff --git a/packages/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember.ts b/packages/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember.ts new file mode 100644 index 0000000000..3fc82c7665 --- /dev/null +++ b/packages/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember.ts @@ -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); + } +} diff --git a/packages/features/ee/teams/lib/removeMember.ts b/packages/features/ee/teams/lib/removeMember.ts index d1bca7ff5e..f98d73b8ed 100644 --- a/packages/features/ee/teams/lib/removeMember.ts +++ b/packages/features/ee/teams/lib/removeMember.ts @@ -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 }; }; diff --git a/packages/features/ee/workflows/components/EmptyScreen.tsx b/packages/features/ee/workflows/components/EmptyScreen.tsx index d72102d20b..b1562ef209 100644 --- a/packages/features/ee/workflows/components/EmptyScreen.tsx +++ b/packages/features/ee/workflows/components/EmptyScreen.tsx @@ -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} /> diff --git a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx b/packages/features/ee/workflows/components/EventWorkflowsTab.tsx index bf42cfb690..cc648c30a4 100644 --- a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx +++ b/packages/features/ee/workflows/components/EventWorkflowsTab.tsx @@ -19,46 +19,38 @@ import { getActionIcon } from "../lib/getActionIcon"; import SkeletonLoader from "./SkeletonLoaderEventWorkflowsTab"; import type { WorkflowType } from "./WorkflowListPage"; +type PartialWorkflowType = Pick; + 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)} /> ); })} diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx index ced69cc829..7929ca1380 100644 --- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx @@ -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; workflowId: number; - selectedEventTypes: Option[]; - setSelectedEventTypes: Dispatch>; + selectedOptions: Option[]; + setSelectedOptions: Dispatch>; 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")} /> - + {isOrg ? ( +
+ +
+ +
+
+ ) : ( + + )} { return ( { form.setValue("activeOn", s); }} + countText={isOrg ? "count_team" : "nr_event_type"} /> ); }} /> +
+ ( + { + onChange(e); + if (e.target.value) { + setSelectedOptions(allOptions); + form.setValue("activeOn", allOptions); + } + }} + checked={value} + /> + )} + /> +
{!props.readOnly && (
+ ) : workflow.activeOnTeams && workflow.activeOnTeams.length > 0 ? ( + //active on teams badge + ( +

{activeOn.team.name}

+ ))}> +
+
+
) : ( + // active on no teams or event types
)} diff --git a/packages/features/ee/workflows/lib/allowDisablingStandardEmails.ts b/packages/features/ee/workflows/lib/allowDisablingStandardEmails.ts index a1a2277955..28a7556b76 100644 --- a/packages/features/ee/workflows/lib/allowDisablingStandardEmails.ts +++ b/packages/features/ee/workflows/lib/allowDisablingStandardEmails.ts @@ -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 && diff --git a/packages/features/ee/workflows/lib/getActionIcon.tsx b/packages/features/ee/workflows/lib/getActionIcon.tsx index b1fedf62a3..930c3506d1 100644 --- a/packages/features/ee/workflows/lib/getActionIcon.tsx +++ b/packages/features/ee/workflows/lib/getActionIcon.tsx @@ -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 ( diff --git a/packages/features/ee/workflows/lib/getAllWorkflows.ts b/packages/features/ee/workflows/lib/getAllWorkflows.ts new file mode 100644 index 0000000000..5d6b20e6c9 --- /dev/null +++ b/packages/features/ee/workflows/lib/getAllWorkflows.ts @@ -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; +}; diff --git a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts index 9a886a0add..f5a26670f0 100644 --- a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts @@ -87,7 +87,7 @@ export interface ScheduleReminderArgs { time: number | null; timeUnit: TimeUnit | null; }; - template: WorkflowTemplates; + template?: WorkflowTemplates; sender?: string | null; workflowStepId?: number; seatReferenceUid?: string; diff --git a/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts index c22ae466f6..29357ec21c 100644 --- a/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts @@ -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({ diff --git a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts index 5178bc2395..99cc6c8c21 100644 --- a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts @@ -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}`, diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index a6d28dcb73..0f0aaf2ce1 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -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) { diff --git a/packages/features/ee/workflows/lib/reminders/scheduleMandatoryReminder.ts b/packages/features/ee/workflows/lib/reminders/scheduleMandatoryReminder.ts index 0e6eb031e7..d8f2325720 100644 --- a/packages/features/ee/workflows/lib/reminders/scheduleMandatoryReminder.ts +++ b/packages/features/ee/workflows/lib/reminders/scheduleMandatoryReminder.ts @@ -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> | 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) ); }); diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index f47cb12586..3bff4f7b1f 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -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) => { diff --git a/packages/features/ee/workflows/lib/test/workflows.test.ts b/packages/features/ee/workflows/lib/test/workflows.test.ts new file mode 100644 index 0000000000..f238205689 --- /dev/null +++ b/packages/features/ee/workflows/lib/test/workflows.test.ts @@ -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); + }); +}); diff --git a/packages/features/ee/workflows/lib/types.ts b/packages/features/ee/workflows/lib/types.ts new file mode 100644 index 0000000000..f363e16c90 --- /dev/null +++ b/packages/features/ee/workflows/lib/types.ts @@ -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; +}; diff --git a/packages/features/ee/workflows/pages/index.tsx b/packages/features/ee/workflows/pages/index.tsx index db54da1b11..541ce2d52c 100644 --- a/packages/features/ee/workflows/pages/index.tsx +++ b/packages/features/ee/workflows/pages/index.tsx @@ -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 ? (
-
+
createMutation.mutate({ teamId })} isPending={createMutation.isPending} disableMobileButton={true} onlyShowWithTeams={true} + includeOrg={true} />
diff --git a/packages/features/ee/workflows/pages/workflow.tsx b/packages/features/ee/workflows/pages/workflow.tsx index 57a9c178fc..91d081a20e 100644 --- a/packages/features/ee/workflows/pages/workflow.tsx +++ b/packages/features/ee/workflows/pages/workflow.tsx @@ -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([]); + const [selectedOptions, setSelectedOptions] = useState([]); 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() {
{ - 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} /> ) : ( diff --git a/packages/features/eventtypes/lib/bookingFieldsManager.ts b/packages/features/eventtypes/lib/bookingFieldsManager.ts index 645b9c6390..f9a4656f1d 100644 --- a/packages/features/eventtypes/lib/bookingFieldsManager.ts +++ b/packages/features/eventtypes/lib/bookingFieldsManager.ts @@ -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, }, }, }, diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index b3e88ffb0c..e31e440c53 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -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 && ( diff --git a/packages/features/webhooks/pages/webhooks-view.tsx b/packages/features/webhooks/pages/webhooks-view.tsx index d6b71e7e50..783893cb66 100644 --- a/packages/features/webhooks/pages/webhooks-view.tsx +++ b/packages/features/webhooks/pages/webhooks-view.tsx @@ -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} /> } /> diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 0d6dcb4497..b7d2e8053e 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -96,6 +96,7 @@ const commons = { hidden: false, userId: 0, parentId: null, + parent: null, owner: null, workflows: [], users: [user], diff --git a/packages/lib/event-types/getEventTypeById.ts b/packages/lib/event-types/getEventTypeById.ts index baf36fdfc8..abdc3c76ac 100644 --- a/packages/lib/event-types/getEventTypeById.ts +++ b/packages/lib/event-types/getEventTypeById.ts @@ -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, diff --git a/packages/lib/payment/getBooking.ts b/packages/lib/payment/getBooking.ts index bbd4af216c..e5b7a5d667 100644 --- a/packages/lib/payment/getBooking.ts +++ b/packages/lib/payment/getBooking.ts @@ -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, }, }, }, diff --git a/packages/prisma/migrations/20240517144241_add_org_wide_workflows/migration.sql b/packages/prisma/migrations/20240517144241_add_org_wide_workflows/migration.sql new file mode 100644 index 0000000000..9da77239bf --- /dev/null +++ b/packages/prisma/migrations/20240517144241_add_org_wide_workflows/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index e747fc75bd..62c9432f2c 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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) diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index 5ae8c60bb8..a079d5dc01 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index 5aabcfa2f2..54ce65d75c 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -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? diff --git a/packages/trpc/server/routers/viewer/teams/delete.handler.ts b/packages/trpc/server/routers/viewer/teams/delete.handler.ts index 2daf10edf3..270fe950e8 100644 --- a/packages/trpc/server/routers/viewer/teams/delete.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/delete.handler.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/workflows/_router.tsx b/packages/trpc/server/routers/viewer/workflows/_router.tsx index 9b9ad10be5..962fce28cc 100644 --- a/packages/trpc/server/routers/viewer/workflows/_router.tsx +++ b/packages/trpc/server/routers/viewer/workflows/_router.tsx @@ -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, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts index cdf6236d3e..9318d49591 100644 --- a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts @@ -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 }); } } }; diff --git a/packages/trpc/server/routers/viewer/workflows/delete.handler.ts b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts index 228531765f..d1405cbdc0 100644 --- a/packages/trpc/server/routers/viewer/workflows/delete.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx b/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx index 862bbf65b3..46e850ea57 100644 --- a/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx +++ b/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx @@ -33,6 +33,16 @@ const { include: includedFields } = Prisma.validator }, }, }, + activeOnTeams: { + select: { + team: { + select: { + id: true, + name: true, + }, + }, + }, + }, steps: true, team: { select: { @@ -41,6 +51,7 @@ const { include: includedFields } = Prisma.validator 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 { diff --git a/packages/trpc/server/routers/viewer/workflows/get.handler.ts b/packages/trpc/server/routers/viewer/workflows/get.handler.ts index 3f74f982b3..7a0388369c 100644 --- a/packages/trpc/server/routers/viewer/workflows/get.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/get.handler.ts @@ -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({ diff --git a/packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.handler.ts b/packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.handler.ts new file mode 100644 index 0000000000..d16f23bd17 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.handler.ts @@ -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; + }; + 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; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.schema.ts b/packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.schema.ts new file mode 100644 index 0000000000..3d86fb3d94 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/workflows/list.handler.ts b/packages/trpc/server/routers/viewer/workflows/list.handler.ts index 53b4145804..24b74e8fa6 100644 --- a/packages/trpc/server/routers/viewer/workflows/list.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/list.handler.ts @@ -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 }; }; diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts index 08c55d5fcd..d171c735cf 100755 --- a/packages/trpc/server/routers/viewer/workflows/update.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -1,23 +1,8 @@ -import type { Prisma } from "@prisma/client"; - import { isSMSOrWhatsappAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; -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 { IS_SELF_HOSTED } from "@calcom/lib/constants"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; -import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import type { PrismaClient } from "@calcom/prisma"; -import { BookingStatus, WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums"; +import { WorkflowActions } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; @@ -27,8 +12,14 @@ import type { TUpdateInputSchema } from "./update.schema"; import { getSender, isAuthorized, - removeSmsReminderFieldForBooking, - upsertSmsReminderFieldForBooking, + upsertSmsReminderFieldForEventTypes, + deleteRemindersOfActiveOnIds, + isAuthorizedToAddActiveOnIds, + deleteAllWorkflowReminders, + scheduleWorkflowNotifications, + verifyEmailSender, + removeSmsReminderFieldForEventTypes, + isStepEdited, } from "./util"; type UpdateOptions = { @@ -39,48 +30,9 @@ type UpdateOptions = { input: TUpdateInputSchema; }; -const verifyEmailSender = async ( - email: string, - userId: number, - teamId: number | null, - prisma: PrismaClient -) => { - 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 const updateHandler = async ({ ctx, input }: UpdateOptions) => { const { user } = ctx; - const { id, name, activeOn, steps, trigger, time, timeUnit } = input; + const { id, name, activeOn, steps, trigger, time, timeUnit, isActiveOnAll } = input; const userWorkflow = await ctx.prisma.workflow.findUnique({ where: { @@ -89,6 +41,15 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { select: { id: true, userId: true, + isActiveOnAll: true, + trigger: true, + time: true, + timeUnit: true, + team: { + select: { + isOrganization: true, + }, + }, teamId: true, user: { select: { @@ -97,10 +58,13 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }, steps: true, activeOn: true, + activeOnTeams: true, }, }); - const isUserAuthorized = await isAuthorized(userWorkflow, ctx.prisma, ctx.user.id, true); + const isOrg = !!userWorkflow?.team?.isOrganization; + + const isUserAuthorized = await isAuthorized(userWorkflow, ctx.user.id, true); if (!isUserAuthorized || !userWorkflow) { throw new TRPCError({ code: "UNAUTHORIZED" }); @@ -119,360 +83,270 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } const hasPaidPlan = IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan; - const where: Prisma.EventTypeWhereInput = {}; - where.id = { - in: activeOn, - }; - if (userWorkflow.teamId) { - //all children managed event types are added after - where.parentId = null; - } - const activeOnEventTypes = await ctx.prisma.eventType.findMany({ - where, - select: { - id: true, - children: { - select: { - id: true, - }, - }, - }, - }); + let newActiveOn: number[] = []; - const activeOnWithChildren = activeOnEventTypes - .map((eventType) => [eventType.id].concat(eventType.children.map((child) => child.id))) - .flat(); + let removedActiveOnIds: number[] = []; - const oldActiveOnEventTypes = await ctx.prisma.workflowsOnEventTypes.findMany({ - where: { - workflowId: id, - }, - select: { - eventTypeId: true, - eventType: { - include: { - children: true, - }, - }, - }, - }); + let activeOnWithChildren: number[] = activeOn; - const oldActiveOnEventTypeIds = oldActiveOnEventTypes - .map((eventTypeRel) => - [eventTypeRel.eventType.id].concat(eventTypeRel.eventType.children.map((child) => child.id)) - ) - .flat(); + let oldActiveOnIds: number[] = []; - const newActiveEventTypes = activeOn.filter( - (eventType) => - !oldActiveOnEventTypes || - !oldActiveOnEventTypes - .map((oldEventType) => { - return oldEventType.eventTypeId; - }) - .includes(eventType) - ); - - //check if new event types belong to user or team - for (const newEventTypeId of newActiveEventTypes) { - const newEventType = await ctx.prisma.eventType.findFirst({ + if (!isOrg) { + // activeOn are event types ids + const activeOnEventTypes = await ctx.prisma.eventType.findMany({ where: { - id: newEventTypeId, + id: { + in: activeOn, + }, + ...(userWorkflow.teamId && { parentId: null }), //all children managed event types are added after }, - include: { - users: { + select: { + id: true, + children: { select: { id: true, }, }, - team: { - include: { - members: true, - }, - }, - children: true, }, }); - if (newEventType) { - if (userWorkflow.teamId && userWorkflow.teamId !== newEventType.teamId) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } + activeOnWithChildren = activeOnEventTypes + .map((eventType) => [eventType.id].concat(eventType.children.map((child) => child.id))) + .flat(); - if ( - !userWorkflow.teamId && - userWorkflow.userId && - newEventType.userId !== userWorkflow.userId && - !newEventType?.users.find((eventTypeUser) => eventTypeUser.id === userWorkflow.userId) - ) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } + let oldActiveOnEventTypes: { id: number; children: { id: number }[] }[]; + if (userWorkflow.isActiveOnAll) { + oldActiveOnEventTypes = await ctx.prisma.eventType.findMany({ + where: { + ...(userWorkflow.teamId ? { teamId: userWorkflow.teamId } : { userId: userWorkflow.userId }), + }, + select: { + id: true, + children: { + select: { + id: true, + }, + }, + }, + }); + } else { + oldActiveOnEventTypes = ( + await ctx.prisma.workflowsOnEventTypes.findMany({ + where: { + workflowId: id, + }, + select: { + eventTypeId: true, + eventType: { + select: { + children: { + select: { + id: true, + }, + }, + }, + }, + }, + }) + ).map((eventTypeRel) => { + return { id: eventTypeRel.eventTypeId, children: eventTypeRel.eventType.children }; + }); } + + oldActiveOnIds = oldActiveOnEventTypes.flatMap((eventType) => [ + eventType.id, + ...eventType.children.map((child) => child.id), + ]); + + newActiveOn = activeOn.filter((eventTypeId) => !oldActiveOnIds.includes(eventTypeId)); + + const isAuthorizedToAddIds = await isAuthorizedToAddActiveOnIds( + newActiveOn, + isOrg, + userWorkflow?.teamId, + userWorkflow?.userId + ); + + if (!isAuthorizedToAddIds) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + //remove all scheduled Email and SMS reminders for eventTypes that are not active any more + removedActiveOnIds = oldActiveOnIds.filter((eventTypeId) => !activeOnWithChildren.includes(eventTypeId)); + + await deleteRemindersOfActiveOnIds({ removedActiveOnIds, workflowSteps: userWorkflow.steps, isOrg }); + + //update active on + await ctx.prisma.workflowsOnEventTypes.deleteMany({ + where: { + workflowId: id, + }, + }); + + //create all workflow - eventtypes relationships + await ctx.prisma.workflowsOnEventTypes.createMany({ + data: activeOnWithChildren.map((eventTypeId) => ({ + workflowId: id, + eventTypeId, + })), + }); + } else { + // activeOn are team ids + if (userWorkflow.isActiveOnAll) { + oldActiveOnIds = ( + await ctx.prisma.team.findMany({ + where: { + parent: { + id: userWorkflow.teamId ?? 0, + }, + }, + select: { + id: true, + }, + }) + ).map((team) => team.id); + } else { + oldActiveOnIds = ( + await ctx.prisma.workflowsOnTeams.findMany({ + where: { + workflowId: id, + }, + select: { + teamId: true, + }, + }) + ).map((teamRel) => teamRel.teamId); + } + + newActiveOn = activeOn.filter((teamId) => !oldActiveOnIds.includes(teamId)); + + const isAuthorizedToAddIds = await isAuthorizedToAddActiveOnIds( + newActiveOn, + isOrg, + userWorkflow?.teamId, + userWorkflow?.userId + ); + + if (!isAuthorizedToAddIds) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + removedActiveOnIds = oldActiveOnIds.filter((teamId) => !activeOn.includes(teamId)); + + await deleteRemindersOfActiveOnIds({ + removedActiveOnIds, + workflowSteps: userWorkflow.steps, + isOrg, + activeOnIds: activeOn.filter((activeOn) => !newActiveOn.includes(activeOn)), + }); + + //update active on + await ctx.prisma.workflowsOnTeams.deleteMany({ + where: { + workflowId: id, + }, + }); + + await ctx.prisma.workflowsOnTeams.createMany({ + data: activeOn.map((teamId) => ({ + workflowId: id, + teamId, + })), + }); } - //remove all scheduled Email and SMS reminders for eventTypes that are not active any more - const removedEventTypes = oldActiveOnEventTypeIds.filter( - (eventTypeId) => !activeOnWithChildren.includes(eventTypeId) - ); + if (userWorkflow.trigger !== trigger || userWorkflow.time !== time || userWorkflow.timeUnit !== timeUnit) { + //if trigger changed, delete all reminders from steps before change + await deleteRemindersOfActiveOnIds({ + removedActiveOnIds: oldActiveOnIds, + workflowSteps: userWorkflow.steps, + isOrg, + }); - const remindersToDeletePromise: Prisma.PrismaPromise< - { - id: number; - referenceId: string | null; - method: string; - scheduled: boolean; - }[] - >[] = []; + await scheduleWorkflowNotifications( + activeOn, // schedule for activeOn that stayed the same + new active on (old reminders were deleted) + isOrg, + userWorkflow.steps, // use old steps here, edited and deleted steps are handled below + time, + timeUnit, + trigger, + user.id, + userWorkflow.teamId + ); + } else { + // if trigger didn't change, only schedule reminders for all new activeOn + await scheduleWorkflowNotifications( + newActiveOn, + isOrg, + userWorkflow.steps, // use old steps here, edited and deleted steps are handled below + time, + timeUnit, + trigger, + user.id, + userWorkflow.teamId, + activeOn.filter((activeOn) => !newActiveOn.includes(activeOn)) // alreadyScheduledActiveOnIds + ); + } - removedEventTypes.forEach((eventTypeId) => { - const reminderToDelete = ctx.prisma.workflowReminder.findMany({ + // handle deleted and edited workflow steps + userWorkflow.steps.map(async (oldStep) => { + const foundStep = steps.find((s) => s.id === oldStep.id); + let newStep; + + if (foundStep) { + const { senderName, ...rest } = { + ...foundStep, + numberVerificationPending: false, + sender: getSender({ + action: foundStep.action, + sender: foundStep.sender || null, + senderName: foundStep.senderName, + }), + }; + newStep = rest; + } + + const remindersFromStep = await ctx.prisma.workflowReminder.findMany({ where: { - booking: { - eventTypeId: eventTypeId, - userId: ctx.user.id, - }, - workflowStepId: { - in: userWorkflow.steps.map((step) => { - return step.id; - }), - }, + workflowStepId: oldStep.id, }, select: { id: true, referenceId: true, method: true, - scheduled: true, - }, - }); - - remindersToDeletePromise.push(reminderToDelete); - }); - - const remindersToDelete = await Promise.all(remindersToDeletePromise); - - //cancel workflow reminders for all bookings from event types that got disabled - remindersToDelete.flat().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); - } - }); - - //update active on & reminders for new eventTypes - await ctx.prisma.workflowsOnEventTypes.deleteMany({ - where: { - workflowId: id, - }, - }); - - let newEventTypes: number[] = []; - if (activeOn.length) { - if (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) { - newEventTypes = newActiveEventTypes; - } - if (newEventTypes.length > 0) { - //create reminders for all bookings with newEventTypes - const bookingsForReminders = await ctx.prisma.booking.findMany({ - where: { - OR: [ - { eventTypeId: { in: newEventTypes } }, - { - eventType: { - parentId: { - in: newEventTypes, - }, - }, - }, - ], - status: BookingStatus.ACCEPTED, - startTime: { - gte: new Date(), + booking: { + select: { + eventTypeId: true, }, }, - include: { - attendees: true, - eventType: true, - user: true, - }, - }); - - const promiseSteps = userWorkflow.steps.map(async (step) => { - if ( - step.action !== WorkflowActions.SMS_ATTENDEE && - step.action !== WorkflowActions.WHATSAPP_ATTENDEE - ) { - //as we do not have attendees phone number (user is notified about that when setting this action) - const promiseScheduleReminders = bookingsForReminders.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 || "", user.id, userWorkflow.teamId, ctx.prisma); - sendTo = [step.sendTo || ""]; - break; - } - - 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) { - 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: user.id, - teamId: userWorkflow.teamId, - }); - } else if (step.action === WorkflowActions.WHATSAPP_NUMBER) { - 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: user.id, - teamId: userWorkflow.teamId, - }); - } - }); - await Promise.all(promiseScheduleReminders); - } - }); - await Promise.all(promiseSteps); - } - //create all workflow - eventtypes relationships - await ctx.prisma.workflowsOnEventTypes.createMany({ - data: activeOnEventTypes.map((eventType) => ({ - workflowId: id, - eventTypeId: eventType.id, - })), - }); - await Promise.all( - activeOnEventTypes.map((eventType) => - ctx.prisma.workflowsOnEventTypes.createMany({ - data: eventType.children.map((chEventType) => ({ - workflowId: id, - eventTypeId: chEventType.id, - })), - }) - ) - ); - } - - userWorkflow.steps.map(async (oldStep) => { - const newStep = steps.filter((s) => s.id === oldStep.id)[0]; - const remindersFromStep = await ctx.prisma.workflowReminder.findMany({ - where: { - workflowStepId: oldStep.id, - }, - include: { - booking: true, }, }); - //step was deleted if (!newStep) { // cancel all workflow reminders from deleted steps - if (remindersFromStep.length > 0) { - remindersFromStep.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(remindersFromStep); + await ctx.prisma.workflowStep.delete({ where: { id: oldStep.id, }, }); - - //step was edited - } else if (JSON.stringify(oldStep) !== JSON.stringify(newStep)) { + } else if (isStepEdited(oldStep, newStep)) { // check if step that require team plan already existed before if (!hasPaidPlan && !isSMSOrWhatsappAction(oldStep.action) && isSMSOrWhatsappAction(newStep.action)) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" }); } + + // update step const requiresSender = newStep.action === WorkflowActions.SMS_NUMBER || newStep.action === WorkflowActions.WHATSAPP_NUMBER || newStep.action === WorkflowActions.EMAIL_ADDRESS; if (newStep.action === WorkflowActions.EMAIL_ADDRESS) { - await verifyEmailSender(newStep.sendTo || "", user.id, userWorkflow.teamId, ctx.prisma); + await verifyEmailSender(newStep.sendTo || "", user.id, userWorkflow.teamId); } + await ctx.prisma.workflowStep.update({ where: { id: oldStep.id, @@ -486,304 +360,80 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { emailSubject: newStep.emailSubject, template: newStep.template, numberRequired: newStep.numberRequired, + sender: newStep.sender, + numberVerificationPending: false, + includeCalendarEvent: newStep.includeCalendarEvent, + }, + }); + + // cancel all notifications of edited step + await deleteAllWorkflowReminders(remindersFromStep); + + // schedule notifications for edited steps + await scheduleWorkflowNotifications( + activeOn, + isOrg, + [newStep], + time, + timeUnit, + trigger, + user.id, + userWorkflow.teamId + ); + } + }); + + // handle added workflow steps + const addedSteps = await Promise.all( + steps + .filter((step) => step.id <= 0) + .map(async (newStep) => { + if (isSMSOrWhatsappAction(newStep.action) && !hasPaidPlan) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" }); + } + + if (newStep.action === WorkflowActions.EMAIL_ADDRESS) { + await verifyEmailSender(newStep.sendTo || "", user.id, userWorkflow.teamId); + } + + const { + id: _stepId, + senderName, + ...stepToAdd + } = { + ...newStep, sender: getSender({ action: newStep.action, sender: newStep.sender || null, senderName: newStep.senderName, }), - numberVerificationPending: false, - includeCalendarEvent: newStep.includeCalendarEvent, - }, - }); - //cancel all reminders of step and create new ones (not for newEventTypes) - const remindersToUpdate = remindersFromStep.filter( - (reminder) => reminder.booking?.eventTypeId && !newEventTypes.includes(reminder.booking?.eventTypeId) - ); + }; - //cancel all workflow reminders from steps that were edited - // FIXME: async calls into ether - remindersToUpdate.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); - } - }); + return stepToAdd; + }) + ); - const eventTypesToUpdateReminders = activeOn.filter( - (eventTypeId) => !newEventTypes.includes(eventTypeId) - ); - if ( - eventTypesToUpdateReminders && - (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) - ) { - const bookingsOfEventTypes = await ctx.prisma.booking.findMany({ - where: { - eventTypeId: { - in: eventTypesToUpdateReminders, - }, - status: BookingStatus.ACCEPTED, - startTime: { - gte: new Date(), - }, - }, - include: { - attendees: true, - eventType: true, - user: true, - }, - }); - const promiseScheduleReminders = bookingsOfEventTypes.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 ( - newStep.action === WorkflowActions.EMAIL_HOST || - newStep.action === WorkflowActions.EMAIL_ATTENDEE || - newStep.action === WorkflowActions.EMAIL_ADDRESS - ) { - let sendTo: string[] = []; + if (addedSteps.length) { + //create new steps + const createdSteps = await Promise.all( + addedSteps.map((step) => + ctx.prisma.workflowStep.create({ + data: { ...step, numberVerificationPending: false }, + }) + ) + ); - switch (newStep.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: - sendTo = newStep.sendTo ? [newStep.sendTo] : []; - break; - } - - await scheduleEmailReminder({ - evt: bookingInfo, - triggerEvent: trigger, - action: newStep.action, - timeSpan: { - time, - timeUnit, - }, - sendTo, - emailSubject: newStep.emailSubject || "", - emailBody: newStep.reminderBody || "", - template: newStep.template, - sender: newStep.senderName, - workflowStepId: newStep.id, - }); - } else if (newStep.action === WorkflowActions.SMS_NUMBER) { - await scheduleSMSReminder({ - evt: bookingInfo, - reminderPhone: newStep.sendTo || "", - triggerEvent: trigger, - action: newStep.action, - timeSpan: { - time, - timeUnit, - }, - message: newStep.reminderBody || "", - workflowStepId: newStep.id || 0, - template: newStep.template, - sender: newStep.sender, - userId: user.id, - teamId: userWorkflow.teamId, - }); - } else if (newStep.action === WorkflowActions.WHATSAPP_NUMBER) { - await scheduleWhatsappReminder({ - evt: bookingInfo, - reminderPhone: newStep.sendTo || "", - triggerEvent: trigger, - action: newStep.action, - timeSpan: { - time, - timeUnit, - }, - message: newStep.reminderBody || "", - workflowStepId: newStep.id || 0, - template: newStep.template, - userId: user.id, - teamId: userWorkflow.teamId, - }); - } - }); - await Promise.all(promiseScheduleReminders); - } - } - }); - //added steps - const addedSteps = steps.map((s) => { - if (s.id <= 0) { - if (isSMSOrWhatsappAction(s.action) && !hasPaidPlan) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" }); - } - const { id: _stepId, ...stepToAdd } = s; - return stepToAdd; - } - }); - if (addedSteps) { - const promiseAddedSteps = addedSteps.map(async (step) => { - if (step) { - const { senderName, ...newStep } = step; - newStep.sender = getSender({ - action: newStep.action, - sender: newStep.sender || null, - senderName: senderName, - }); - if (newStep.action === WorkflowActions.EMAIL_ADDRESS) { - await verifyEmailSender(newStep.sendTo || "", user.id, userWorkflow.teamId, ctx.prisma); - } - const createdStep = await ctx.prisma.workflowStep.create({ - data: { ...newStep, numberVerificationPending: false }, - }); - if ( - (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) && - step.action !== WorkflowActions.SMS_ATTENDEE && - step.action !== WorkflowActions.WHATSAPP_ATTENDEE - ) { - const bookingsForReminders = await ctx.prisma.booking.findMany({ - where: { - eventTypeId: { in: activeOn }, - status: BookingStatus.ACCEPTED, - startTime: { - gte: new Date(), - }, - }, - include: { - attendees: true, - eventType: true, - user: true, - }, - }); - for (const booking of bookingsForReminders) { - 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 - ? { - name: booking.user.name || "", - email: booking?.userPrimaryEmail ?? booking.user.email, - timeZone: booking.user.timeZone, - timeFormat: getTimeFormatStringFromUserTimeFormat(booking.user.timeFormat), - language: { locale: booking.user.locale || defaultLocale }, - } - : { 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_ATTENDEE || - step.action === WorkflowActions.EMAIL_HOST || - 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: - sendTo = step.sendTo ? [step.sendTo] : []; - break; - } - - await scheduleEmailReminder({ - evt: bookingInfo, - triggerEvent: trigger, - action: step.action, - timeSpan: { - time, - timeUnit, - }, - sendTo, - emailSubject: step.emailSubject || "", - emailBody: step.reminderBody || "", - template: step.template, - sender: step.senderName, - workflowStepId: createdStep.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: createdStep.id, - template: step.template, - sender: step.sender, - userId: user.id, - teamId: userWorkflow.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: createdStep.id, - template: step.template, - userId: user.id, - teamId: userWorkflow.teamId, - }); - } - } - } - } - }); - await Promise.all(promiseAddedSteps); + // schedule notification for new step + await scheduleWorkflowNotifications( + activeOn, + isOrg, + createdSteps, + time, + timeUnit, + trigger, + user.id, + userWorkflow.teamId + ); } //update trigger, name, time, timeUnit @@ -796,6 +446,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { trigger, time, timeUnit, + isActiveOnAll, }, }); @@ -809,12 +460,18 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { eventType: true, }, }, + activeOnTeams: { + select: { + team: true, + }, + }, team: { select: { id: true, slug: true, members: true, name: true, + isOrganization: true, }, }, steps: { @@ -832,28 +489,30 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { (step) => step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.WHATSAPP_ATTENDEE ); + await removeSmsReminderFieldForEventTypes({ + activeOnToRemove: removedActiveOnIds, + workflowId: id, + isOrg, + activeOn, + }); - for (const removedEventType of removedEventTypes) { - await removeSmsReminderFieldForBooking({ + if (!smsReminderNumberNeeded) { + await removeSmsReminderFieldForEventTypes({ + activeOnToRemove: activeOnWithChildren, workflowId: id, - eventTypeId: removedEventType, + isOrg, + }); + } else { + await upsertSmsReminderFieldForEventTypes({ + activeOn: activeOnWithChildren, + workflowId: id, + isSmsReminderNumberRequired: steps.some( + (s) => + (s.action === WorkflowActions.SMS_ATTENDEE || s.action === WorkflowActions.WHATSAPP_ATTENDEE) && + s.numberRequired + ), + isOrg, }); - } - - for (const eventTypeId of activeOnWithChildren) { - if (smsReminderNumberNeeded) { - await upsertSmsReminderFieldForBooking({ - workflowId: id, - isSmsReminderNumberRequired: steps.some( - (s) => - (s.action === WorkflowActions.SMS_ATTENDEE || s.action === WorkflowActions.WHATSAPP_ATTENDEE) && - s.numberRequired - ), - eventTypeId, - }); - } else { - await removeSmsReminderFieldForBooking({ workflowId: id, eventTypeId }); - } } return { diff --git a/packages/trpc/server/routers/viewer/workflows/update.schema.ts b/packages/trpc/server/routers/viewer/workflows/update.schema.ts index 48ac2a032c..21ad05785e 100644 --- a/packages/trpc/server/routers/viewer/workflows/update.schema.ts +++ b/packages/trpc/server/routers/viewer/workflows/update.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/workflows/util.ts b/packages/trpc/server/routers/viewer/workflows/util.ts index 52e1de8aa4..8125845adf 100644 --- a/packages/trpc/server/routers/viewer/workflows/util.ts +++ b/packages/trpc/server/routers/viewer/workflows/util.ts @@ -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 & { senderName: string | null | undefined } @@ -20,21 +115,20 @@ export function getSender( export async function isAuthorized( workflow: Pick | 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[], + 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 extends Promise ? U : T; + +type Bookings = UnwrapPromise>; + +// 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[], + 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; +} diff --git a/packages/ui/components/createButton/CreateButtonWithTeamsList.tsx b/packages/ui/components/createButton/CreateButtonWithTeamsList.tsx index dde43c8b21..23af0d266a 100644 --- a/packages/ui/components/createButton/CreateButtonWithTeamsList.tsx +++ b/packages/ui/components/createButton/CreateButtonWithTeamsList.tsx @@ -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 diff --git a/packages/ui/components/form/checkbox/MultiSelectCheckboxes.tsx b/packages/ui/components/form/checkbox/MultiSelectCheckboxes.tsx index d06c4c57ee..346726ff13 100644 --- a/packages/ui/components/form/checkbox/MultiSelectCheckboxes.tsx +++ b/packages/ui/components/form/checkbox/MultiSelectCheckboxes.tsx @@ -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>> = ({ +const InputOption: React.FC>> = ({ isDisabled, isFocused, isSelected, @@ -49,12 +55,21 @@ type MultiSelectionCheckboxesProps = { setSelected: Dispatch>; 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 &&
{t("nr_event_type", { count: getValue().length })}
}; + const count = getValue().filter((option) => option.value !== "all").length; + return <>{!index && count !== 0 &&
{t(countText, { count })}
}; }; export default function MultiSelectCheckboxes({ @@ -65,26 +80,60 @@ export default function MultiSelectCheckboxes({ setValue, className, isDisabled, + countText, }: Omit & MultiSelectionCheckboxesProps) { - const additonalComponents = { MultiValue }; + const additonalComponents = { + MultiValue: (props: MultiValueProps>) => ( + + ), + }; + + const allOptions = [{ label: "Select all", value: "all" }, ...options]; + + const allSelected = selected.length === options.length ? allOptions : selected; return (