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