cal.pub0.org/packages/trpc/server/routers/viewer/workflows.tsx

1581 lines
45 KiB
TypeScript

import type { Workflow, Prisma } from "@prisma/client";
import {
WorkflowTemplates,
WorkflowActions,
WorkflowTriggerEvents,
BookingStatus,
WorkflowMethods,
TimeUnit,
MembershipRole,
} from "@prisma/client";
import { z } from "zod";
import emailReminderTemplate from "@calcom/ee/workflows/lib/reminders/templates/emailReminderTemplate";
import {
SMS_REMINDER_NUMBER_FIELD,
getSmsReminderNumberField,
getSmsReminderNumberSource,
} from "@calcom/features/bookings/lib/getBookingFields";
import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage";
import { isSMSAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import {
WORKFLOW_TEMPLATES,
WORKFLOW_TRIGGER_EVENTS,
WORKFLOW_ACTIONS,
TIME_UNIT,
} from "@calcom/features/ee/workflows/lib/constants";
import { getWorkflowActionOptions } from "@calcom/features/ee/workflows/lib/getOptions";
import {
deleteScheduledEmailReminder,
scheduleEmailReminder,
} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import {
deleteScheduledSMSReminder,
scheduleSMSReminder,
} from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import {
verifyPhoneNumber,
sendVerificationCode,
} from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber";
import { upsertBookingField, removeBookingField } from "@calcom/features/eventtypes/lib/bookingFieldsManager";
import { IS_SELF_HOSTED, SENDER_ID, CAL_URL } from "@calcom/lib/constants";
import { SENDER_NAME } from "@calcom/lib/constants";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
// import { getErrorFromUnknown } from "@calcom/lib/errors";
import { getTranslation } from "@calcom/lib/server/i18n";
import type PrismaType from "@calcom/prisma";
import type { WorkflowStep } from "@calcom/prisma/client";
import { TRPCError } from "@trpc/server";
import { router, authedProcedure } from "../../trpc";
import { viewerTeamsRouter } from "./teams";
function getSender(
step: Pick<WorkflowStep, "action" | "sender"> & { senderName: string | null | undefined }
) {
return isSMSAction(step.action) ? step.sender || SENDER_ID : step.senderName || SENDER_NAME;
}
async function isAuthorized(
workflow: Pick<Workflow, "id" | "teamId" | "userId"> | null,
prisma: typeof PrismaType,
currentUserId: number,
readOnly?: boolean
) {
if (!workflow) {
return false;
}
if (!readOnly) {
const userWorkflow = await prisma.workflow.findFirst({
where: {
id: workflow.id,
OR: [
{ userId: currentUserId },
{
team: {
members: {
some: {
userId: currentUserId,
accepted: true,
},
},
},
},
],
},
});
if (userWorkflow) return true;
}
const userWorkflow = await prisma.workflow.findFirst({
where: {
id: workflow.id,
OR: [
{ userId: currentUserId },
{
team: {
members: {
some: {
userId: currentUserId,
accepted: true,
NOT: {
role: MembershipRole.MEMBER,
},
},
},
},
},
],
},
});
if (userWorkflow) return true;
return false;
}
export const workflowsRouter = router({
list: authedProcedure
.input(
z
.object({
teamId: z.number().optional(),
userId: z.number().optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
if (input && input.teamId) {
const workflows: WorkflowType[] = await ctx.prisma.workflow.findMany({
where: {
team: {
id: input.teamId,
members: {
some: {
userId: ctx.user.id,
accepted: true,
},
},
},
},
include: {
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
},
},
},
},
steps: true,
},
orderBy: {
id: "asc",
},
});
const workflowsWithReadOnly = workflows.map((workflow) => {
const readOnly = !!workflow.team?.members?.find(
(member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER
);
return { ...workflow, readOnly };
});
return { workflows: workflowsWithReadOnly };
}
if (input && input.userId) {
const workflows: WorkflowType[] = await ctx.prisma.workflow.findMany({
where: {
userId: ctx.user.id,
},
include: {
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
},
},
},
},
steps: true,
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
},
orderBy: {
id: "asc",
},
});
return { workflows };
}
const workflows = await ctx.prisma.workflow.findMany({
where: {
OR: [
{ userId: ctx.user.id },
{
team: {
members: {
some: {
userId: ctx.user.id,
accepted: true,
},
},
},
},
],
},
include: {
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
},
},
},
},
steps: true,
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
},
orderBy: {
id: "asc",
},
});
const workflowsWithReadOnly: WorkflowType[] = workflows.map((workflow) => {
const readOnly = !!workflow.team?.members?.find(
(member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER
);
return { readOnly, ...workflow };
});
return { workflows: workflowsWithReadOnly };
}),
get: authedProcedure
.input(
z.object({
id: z.number(),
})
)
.query(async ({ ctx, input }) => {
const workflow = await ctx.prisma.workflow.findFirst({
where: {
id: input.id,
},
select: {
id: true,
name: true,
userId: true,
teamId: true,
team: {
select: {
id: true,
slug: true,
members: true,
},
},
time: true,
timeUnit: true,
activeOn: {
select: {
eventType: true,
},
},
trigger: true,
steps: {
orderBy: {
stepNumber: "asc",
},
},
},
});
const isUserAuthorized = await isAuthorized(workflow, ctx.prisma, ctx.user.id);
if (!isUserAuthorized) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
return workflow;
}),
create: authedProcedure
.input(
z.object({
teamId: z.number().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { teamId } = input;
const userId = ctx.user.id;
if (teamId) {
const team = await ctx.prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId: ctx.user.id,
accepted: true,
NOT: {
role: MembershipRole.MEMBER,
},
},
},
},
});
if (!team) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
}
try {
const workflow: Workflow = await ctx.prisma.workflow.create({
data: {
name: "",
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 24,
timeUnit: TimeUnit.HOUR,
userId,
teamId,
},
});
await ctx.prisma.workflowStep.create({
data: {
stepNumber: 1,
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.REMINDER,
reminderBody: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailBody,
emailSubject: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailSubject,
workflowId: workflow.id,
sender: SENDER_NAME,
numberVerificationPending: false,
},
});
return { workflow };
} catch (e) {
throw e;
}
}),
delete: authedProcedure
.input(
z.object({
id: z.number(),
})
)
.mutation(async ({ ctx, input }) => {
const { id } = input;
const workflowToDelete = await ctx.prisma.workflow.findFirst({
where: {
id,
},
include: {
activeOn: true,
},
});
const isUserAuthorized = await isAuthorized(workflowToDelete, ctx.prisma, ctx.user.id, true);
if (!isUserAuthorized || !workflowToDelete) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const scheduledReminders = await ctx.prisma.workflowReminder.findMany({
where: {
workflowStep: {
workflowId: id,
},
scheduled: true,
NOT: {
referenceId: null,
},
},
});
//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);
}
});
for (const activeOn of workflowToDelete.activeOn) {
await removeSmsReminderFieldForBooking({ workflowId: id, eventTypeId: activeOn.eventTypeId });
}
await ctx.prisma.workflow.deleteMany({
where: {
id,
},
});
return {
id,
};
}),
update: authedProcedure
.input(
z.object({
id: z.number(),
name: z.string(),
activeOn: z.number().array(),
steps: z
.object({
id: z.number(),
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(),
template: z.enum(WORKFLOW_TEMPLATES),
numberRequired: z.boolean().nullable(),
sender: z.string().optional().nullable(),
senderName: z.string().optional().nullable(),
})
.array(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
time: z.number().nullable(),
timeUnit: z.enum(TIME_UNIT).nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { id, name, activeOn, steps, trigger, time, timeUnit } = input;
const userWorkflow = await ctx.prisma.workflow.findUnique({
where: {
id,
},
select: {
id: true,
userId: true,
teamId: true,
user: {
select: {
teams: true,
},
},
steps: true,
activeOn: true,
},
});
const isUserAuthorized = await isAuthorized(userWorkflow, ctx.prisma, ctx.user.id, true);
if (!isUserAuthorized || !userWorkflow) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (steps.find((step) => step.workflowId != id)) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const oldActiveOnEventTypes = await ctx.prisma.workflowsOnEventTypes.findMany({
where: {
workflowId: id,
},
select: {
eventTypeId: true,
},
});
const newActiveEventTypes = activeOn.filter((eventType) => {
if (
!oldActiveOnEventTypes ||
!oldActiveOnEventTypes
.map((oldEventType) => {
return oldEventType.eventTypeId;
})
.includes(eventType)
) {
return eventType;
}
});
//check if new event types belong to user or team
for (const newEventTypeId of newActiveEventTypes) {
const newEventType = await ctx.prisma.eventType.findFirst({
where: {
id: newEventTypeId,
},
include: {
users: true,
team: {
include: {
members: true,
},
},
},
});
if (newEventType) {
if (userWorkflow.teamId && userWorkflow.teamId !== newEventType.teamId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (
!userWorkflow.teamId &&
userWorkflow.userId &&
newEventType.userId !== userWorkflow.userId &&
!newEventType?.users.find((eventTypeUser) => eventTypeUser.id === userWorkflow.userId)
) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
}
//remove all scheduled Email and SMS reminders for eventTypes that are not active any more
const removedEventTypes = oldActiveOnEventTypes
.map((eventType) => {
return eventType.eventTypeId;
})
.filter((eventType) => {
if (!activeOn.includes(eventType)) {
return eventType;
}
});
const remindersToDeletePromise: Prisma.PrismaPromise<
{
id: number;
referenceId: string | null;
method: string;
scheduled: boolean;
}[]
>[] = [];
removedEventTypes.forEach((eventTypeId) => {
const reminderToDelete = ctx.prisma.workflowReminder.findMany({
where: {
booking: {
eventTypeId: eventTypeId,
userId: ctx.user.id,
},
workflowStepId: {
in: userWorkflow.steps.map((step) => {
return step.id;
}),
},
},
select: {
id: true,
referenceId: true,
method: true,
scheduled: true,
},
});
remindersToDeletePromise.push(reminderToDelete);
});
const remindersToDelete = await Promise.all(remindersToDeletePromise);
//cancel workflow reminders for all bookings from event types that got disabled
remindersToDelete.flat().forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
}
});
//update active on & reminders for new eventTypes
await ctx.prisma.workflowsOnEventTypes.deleteMany({
where: {
workflowId: id,
},
});
let newEventTypes: number[] = [];
if (activeOn.length) {
if (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) {
newEventTypes = newActiveEventTypes;
}
if (newEventTypes.length > 0) {
//create reminders for all bookings with newEventTypes
const bookingsForReminders = await ctx.prisma.booking.findMany({
where: {
eventTypeId: { in: newEventTypes },
status: BookingStatus.ACCEPTED,
startTime: {
gte: new Date(),
},
},
include: {
attendees: true,
eventType: true,
user: true,
},
});
steps.forEach(async (step) => {
if (step.action !== WorkflowActions.SMS_ATTENDEE) {
//as we do not have attendees phone number (user is notified about that when setting this action)
bookingsForReminders.forEach(async (booking) => {
const bookingInfo = {
uid: booking.uid,
attendees: booking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { locale: attendee.locale || "" },
};
}),
organizer: booking.user
? {
language: { locale: booking.user.locale || "" },
name: booking.user.name || "",
email: booking.user.email,
timeZone: booking.user.timeZone,
}
: { name: "", email: "", timeZone: "", language: { locale: "" } },
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
title: booking.title,
language: { locale: booking?.user?.locale || "" },
eventType: {
slug: booking.eventType?.slug,
},
};
if (
step.action === WorkflowActions.EMAIL_HOST ||
step.action === WorkflowActions.EMAIL_ATTENDEE /*||
step.action === WorkflowActions.EMAIL_ADDRESS*/
) {
let sendTo = "";
switch (step.action) {
case WorkflowActions.EMAIL_HOST:
sendTo = bookingInfo.organizer?.email;
break;
case WorkflowActions.EMAIL_ATTENDEE:
sendTo = bookingInfo.attendees[0].email;
break;
/*case WorkflowActions.EMAIL_ADDRESS:
sendTo = step.sendTo || "";*/
}
await scheduleEmailReminder(
bookingInfo,
trigger,
step.action,
{
time,
timeUnit,
},
sendTo,
step.emailSubject || "",
step.reminderBody || "",
step.id,
step.template,
step.senderName || SENDER_NAME
);
} else if (step.action === WorkflowActions.SMS_NUMBER) {
await scheduleSMSReminder(
bookingInfo,
step.sendTo || "",
trigger,
step.action,
{
time,
timeUnit,
},
step.reminderBody || "",
step.id,
step.template,
step.sender || SENDER_ID,
user.id,
userWorkflow.teamId
);
}
});
}
});
}
//create all workflow - eventtypes relationships
activeOn.forEach(async (eventTypeId) => {
await ctx.prisma.workflowsOnEventTypes.createMany({
data: {
workflowId: id,
eventTypeId,
},
});
});
}
userWorkflow.steps.map(async (oldStep) => {
const newStep = steps.filter((s) => s.id === oldStep.id)[0];
const remindersFromStep = await ctx.prisma.workflowReminder.findMany({
where: {
workflowStepId: oldStep.id,
},
include: {
booking: true,
},
});
//step was deleted
if (!newStep) {
// cancel all workflow reminders from deleted steps
if (remindersFromStep.length > 0) {
remindersFromStep.forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
}
});
}
await ctx.prisma.workflowStep.delete({
where: {
id: oldStep.id,
},
});
//step was edited
} else if (JSON.stringify(oldStep) !== JSON.stringify(newStep)) {
if (
!userWorkflow.teamId &&
!userWorkflow.user?.teams.length &&
!isSMSAction(oldStep.action) &&
isSMSAction(newStep.action)
) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
await ctx.prisma.workflowStep.update({
where: {
id: oldStep.id,
},
data: {
action: newStep.action,
sendTo:
newStep.action === WorkflowActions.SMS_NUMBER /*||
newStep.action === WorkflowActions.EMAIL_ADDRESS*/
? newStep.sendTo
: null,
stepNumber: newStep.stepNumber,
workflowId: newStep.workflowId,
reminderBody: newStep.reminderBody,
emailSubject: newStep.emailSubject,
template: newStep.template,
numberRequired: newStep.numberRequired,
sender: getSender({
action: newStep.action,
sender: newStep.sender || null,
senderName: newStep.senderName,
}),
numberVerificationPending: false,
},
});
//cancel all reminders of step and create new ones (not for newEventTypes)
const remindersToUpdate = remindersFromStep.filter((reminder) => {
if (reminder.booking?.eventTypeId && !newEventTypes.includes(reminder.booking?.eventTypeId)) {
return reminder;
}
});
//cancel all workflow reminders from steps that were edited
remindersToUpdate.forEach(async (reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
}
});
const eventTypesToUpdateReminders = activeOn.filter((eventTypeId) => {
if (!newEventTypes.includes(eventTypeId)) {
return eventTypeId;
}
});
if (
eventTypesToUpdateReminders &&
(trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT)
) {
const bookingsOfEventTypes = await ctx.prisma.booking.findMany({
where: {
eventTypeId: {
in: eventTypesToUpdateReminders,
},
status: BookingStatus.ACCEPTED,
startTime: {
gte: new Date(),
},
},
include: {
attendees: true,
eventType: true,
user: true,
},
});
bookingsOfEventTypes.forEach(async (booking) => {
const bookingInfo = {
uid: booking.uid,
attendees: booking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { locale: attendee.locale || "" },
};
}),
organizer: booking.user
? {
language: { locale: booking.user.locale || "" },
name: booking.user.name || "",
email: booking.user.email,
timeZone: booking.user.timeZone,
}
: { name: "", email: "", timeZone: "", language: { locale: "" } },
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
title: booking.title,
language: { locale: booking?.user?.locale || "" },
eventType: {
slug: booking.eventType?.slug,
},
};
if (
newStep.action === WorkflowActions.EMAIL_HOST ||
newStep.action === WorkflowActions.EMAIL_ATTENDEE /*||
newStep.action === WorkflowActions.EMAIL_ADDRESS*/
) {
let sendTo = "";
switch (newStep.action) {
case WorkflowActions.EMAIL_HOST:
sendTo = bookingInfo.organizer?.email;
break;
case WorkflowActions.EMAIL_ATTENDEE:
sendTo = bookingInfo.attendees[0].email;
break;
/*case WorkflowActions.EMAIL_ADDRESS:
sendTo = newStep.sendTo || "";*/
}
await scheduleEmailReminder(
bookingInfo,
trigger,
newStep.action,
{
time,
timeUnit,
},
sendTo,
newStep.emailSubject || "",
newStep.reminderBody || "",
newStep.id,
newStep.template,
newStep.senderName || SENDER_NAME
);
} else if (newStep.action === WorkflowActions.SMS_NUMBER) {
await scheduleSMSReminder(
bookingInfo,
newStep.sendTo || "",
trigger,
newStep.action,
{
time,
timeUnit,
},
newStep.reminderBody || "",
newStep.id || 0,
newStep.template,
newStep.sender || SENDER_ID,
user.id,
userWorkflow.teamId
);
}
});
}
}
});
//added steps
const addedSteps = steps.map((s) => {
if (s.id <= 0) {
if (!userWorkflow.user?.teams.length && isSMSAction(s.action)) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const { id: _stepId, ...stepToAdd } = s;
return stepToAdd;
}
});
if (addedSteps) {
const eventTypesToCreateReminders = activeOn.map((activeEventType) => {
if (activeEventType && !newEventTypes.includes(activeEventType)) {
return activeEventType;
}
});
addedSteps.forEach(async (step) => {
if (step) {
const { senderName, ...newStep } = step;
newStep.sender = getSender({
action: newStep.action,
sender: newStep.sender || null,
senderName: senderName,
});
const createdStep = await ctx.prisma.workflowStep.create({
data: { ...newStep, numberVerificationPending: false },
});
if (
(trigger === WorkflowTriggerEvents.BEFORE_EVENT ||
trigger === WorkflowTriggerEvents.AFTER_EVENT) &&
eventTypesToCreateReminders &&
step.action !== WorkflowActions.SMS_ATTENDEE
) {
const bookingsForReminders = await ctx.prisma.booking.findMany({
where: {
eventTypeId: { in: eventTypesToCreateReminders as number[] },
status: BookingStatus.ACCEPTED,
startTime: {
gte: new Date(),
},
},
include: {
attendees: true,
eventType: true,
user: true,
},
});
bookingsForReminders.forEach(async (booking) => {
const bookingInfo = {
uid: booking.uid,
attendees: booking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { locale: attendee.locale || "" },
};
}),
organizer: booking.user
? {
name: booking.user.name || "",
email: booking.user.email,
timeZone: booking.user.timeZone,
language: { locale: booking.user.locale || "" },
}
: { name: "", email: "", timeZone: "", language: { locale: "" } },
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
title: booking.title,
language: { locale: booking?.user?.locale || "" },
eventType: {
slug: booking.eventType?.slug,
},
};
if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||
step.action === WorkflowActions.EMAIL_HOST /*||
step.action === WorkflowActions.EMAIL_ADDRESS*/
) {
let sendTo = "";
switch (step.action) {
case WorkflowActions.EMAIL_HOST:
sendTo = bookingInfo.organizer?.email;
break;
case WorkflowActions.EMAIL_ATTENDEE:
sendTo = bookingInfo.attendees[0].email;
break;
/*case WorkflowActions.EMAIL_ADDRESS:
sendTo = step.sendTo || "";*/
}
await scheduleEmailReminder(
bookingInfo,
trigger,
step.action,
{
time,
timeUnit,
},
sendTo,
step.emailSubject || "",
step.reminderBody || "",
createdStep.id,
step.template,
step.senderName || SENDER_NAME
);
} else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) {
await scheduleSMSReminder(
bookingInfo,
step.sendTo,
trigger,
step.action,
{
time,
timeUnit,
},
step.reminderBody || "",
createdStep.id,
step.template,
step.sender || SENDER_ID,
user.id,
userWorkflow.teamId
);
}
});
}
}
});
}
//update trigger, name, time, timeUnit
await ctx.prisma.workflow.update({
where: {
id,
},
data: {
name,
trigger,
time,
timeUnit,
},
});
const workflow = await ctx.prisma.workflow.findFirst({
where: {
id,
},
include: {
activeOn: {
select: {
eventType: true,
},
},
team: {
select: {
id: true,
slug: true,
members: true,
},
},
steps: {
orderBy: {
stepNumber: "asc",
},
},
},
});
// Remove or add booking field for sms reminder number
const smsReminderNumberNeeded =
activeOn.length && steps.some((step) => step.action === WorkflowActions.SMS_ATTENDEE);
for (const removedEventType of removedEventTypes) {
await removeSmsReminderFieldForBooking({
workflowId: id,
eventTypeId: removedEventType,
});
}
for (const eventTypeId of activeOn) {
if (smsReminderNumberNeeded) {
await upsertSmsReminderFieldForBooking({
workflowId: id,
isSmsReminderNumberRequired: steps.some(
(s) => s.action === WorkflowActions.SMS_ATTENDEE && s.numberRequired
),
eventTypeId,
});
} else {
await removeSmsReminderFieldForBooking({ workflowId: id, eventTypeId });
}
}
return {
workflow,
};
}),
/* testAction: authedRateLimitedProcedure({ intervalInMs: 10000, limit: 3 })
.input(
z.object({
step: z.object({
id: z.number(),
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(),
template: z.enum(WORKFLOW_TEMPLATES),
numberRequired: z.boolean().nullable(),
sender: z.string().optional().nullable(),
}),
emailSubject: z.string(),
reminderBody: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { step, emailSubject, reminderBody } = input;
const { action, template, sendTo, sender } = step;
const senderID = sender || SENDER_ID;
if (action === WorkflowActions.SMS_NUMBER) {
if (!sendTo) throw new TRPCError({ code: "BAD_REQUEST", message: "Missing sendTo" });
const verifiedNumbers = await ctx.prisma.verifiedNumber.findFirst({
where: {
userId: ctx.user.id,
phoneNumber: sendTo,
},
});
if (!verifiedNumbers)
throw new TRPCError({ code: "UNAUTHORIZED", message: "Phone number is not verified" });
}
try {
const userWorkflow = await ctx.prisma.workflow.findUnique({
where: {
id: step.workflowId,
},
select: {
userId: true,
steps: true,
},
});
if (!userWorkflow || userWorkflow.userId !== user.id) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (isSMSAction(step.action) /*|| step.action === WorkflowActions.EMAIL_ADDRESS*/ /*) {
const hasTeamPlan = (await ctx.prisma.membership.count({ where: { userId: user.id } })) > 0;
if (!hasTeamPlan) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Team plan needed" });
}
}
const booking = await ctx.prisma.booking.findFirst({
orderBy: {
createdAt: "desc",
},
where: {
userId: ctx.user.id,
},
include: {
attendees: true,
user: true,
},
});
let evt: BookingInfo;
if (booking) {
evt = {
uid: booking?.uid,
attendees:
booking?.attendees.map((attendee) => {
return { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone };
}) || [],
organizer: {
language: {
locale: booking?.user?.locale || "",
},
name: booking?.user?.name || "",
email: booking?.user?.email || "",
timeZone: booking?.user?.timeZone || "",
},
startTime: booking?.startTime.toISOString() || "",
endTime: booking?.endTime.toISOString() || "",
title: booking?.title || "",
location: booking?.location || null,
additionalNotes: booking?.description || null,
customInputs: booking?.customInputs,
};
} else {
//if no booking exists create an example booking
evt = {
attendees: [{ name: "John Doe", email: "john.doe@example.com", timeZone: "Europe/London" }],
organizer: {
language: {
locale: ctx.user.locale,
},
name: ctx.user.name || "",
email: ctx.user.email,
timeZone: ctx.user.timeZone,
},
startTime: dayjs().add(10, "hour").toISOString(),
endTime: dayjs().add(11, "hour").toISOString(),
title: "Example Booking",
location: "Office",
additionalNotes: "These are additional notes",
};
}
if (
action === WorkflowActions.EMAIL_ATTENDEE ||
action === WorkflowActions.EMAIL_HOST /*||
action === WorkflowActions.EMAIL_ADDRESS*/
/*) {
scheduleEmailReminder(
evt,
WorkflowTriggerEvents.NEW_EVENT,
action,
{ time: null, timeUnit: null },
ctx.user.email,
emailSubject,
reminderBody,
0,
template
);
return { message: "Notification sent" };
} else if (action === WorkflowActions.SMS_NUMBER && sendTo) {
scheduleSMSReminder(
evt,
sendTo,
WorkflowTriggerEvents.NEW_EVENT,
action,
{ time: null, timeUnit: null },
reminderBody,
0,
template,
senderID,
ctx.user.id
);
return { message: "Notification sent" };
}
return {
ok: false,
status: 500,
message: "Notification could not be sent",
};
} catch (_err) {
const error = getErrorFromUnknown(_err);
return {
ok: false,
status: 500,
message: error.message,
};
}
}),
*/
activateEventType: authedProcedure
.input(
z.object({
eventTypeId: z.number(),
workflowId: z.number(),
})
)
.mutation(async ({ ctx, input }) => {
const { eventTypeId, workflowId } = input;
// Check that vent type belong to the user or team
const userEventType = await ctx.prisma.eventType.findFirst({
where: {
id: eventTypeId,
OR: [
{ userId: ctx.user.id },
{
team: {
members: {
some: {
userId: ctx.user.id,
accepted: true,
NOT: {
role: MembershipRole.MEMBER,
},
},
},
},
},
],
},
});
if (!userEventType)
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authorized to edit this event type" });
// Check that the workflow belongs to the user or team
const eventTypeWorkflow = await ctx.prisma.workflow.findFirst({
where: {
id: workflowId,
OR: [
{
userId: ctx.user.id,
},
{
teamId: userEventType.teamId,
},
],
},
include: {
steps: true,
},
});
if (!eventTypeWorkflow)
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authorized to enable/disable this workflow",
});
//check if event type is already active
const isActive = await ctx.prisma.workflowsOnEventTypes.findFirst({
where: {
workflowId,
eventTypeId,
},
});
if (isActive) {
await ctx.prisma.workflowsOnEventTypes.deleteMany({
where: {
workflowId,
eventTypeId,
},
});
await removeSmsReminderFieldForBooking({
workflowId,
eventTypeId,
});
} else {
await ctx.prisma.workflowsOnEventTypes.create({
data: {
workflowId,
eventTypeId,
},
});
if (
eventTypeWorkflow.steps.some((step) => {
return step.action === WorkflowActions.SMS_ATTENDEE;
})
) {
const isSmsReminderNumberRequired = eventTypeWorkflow.steps.some((step) => {
return step.action === WorkflowActions.SMS_ATTENDEE && step.numberRequired;
});
await upsertSmsReminderFieldForBooking({
workflowId,
isSmsReminderNumberRequired,
eventTypeId,
});
}
}
}),
sendVerificationCode: authedProcedure
.input(
z.object({
phoneNumber: z.string(),
})
)
.mutation(async ({ input }) => {
const { phoneNumber } = input;
return sendVerificationCode(phoneNumber);
}),
verifyPhoneNumber: authedProcedure
.input(
z.object({
phoneNumber: z.string(),
code: z.string(),
teamId: z.number().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { phoneNumber, code, teamId } = input;
const { user } = ctx;
const verifyStatus = await verifyPhoneNumber(phoneNumber, code, user.id, teamId);
return verifyStatus;
}),
getVerifiedNumbers: authedProcedure
.input(
z.object({
teamId: z.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { user } = ctx;
const verifiedNumbers = await ctx.prisma.verifiedNumber.findMany({
where: {
OR: [{ userId: user.id }, { teamId: input.teamId }],
},
});
return verifiedNumbers;
}),
getWorkflowActionOptions: authedProcedure.query(async ({ ctx }) => {
const { user } = ctx;
const isCurrentUsernamePremium = user && user.metadata && hasKeyInMetadata(user, "isPremium");
let isTeamsPlan = false;
if (!isCurrentUsernamePremium) {
const { hasTeamPlan } = await viewerTeamsRouter.createCaller(ctx).hasTeamPlan();
isTeamsPlan = !!hasTeamPlan;
}
const t = await getTranslation(ctx.user.locale, "common");
return getWorkflowActionOptions(t, IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan);
}),
getByViewer: authedProcedure.query(async ({ ctx }) => {
const { prisma } = ctx;
const user = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
id: true,
username: true,
avatar: true,
name: true,
startTime: true,
endTime: true,
bufferTime: true,
workflows: {
select: {
id: true,
name: true,
},
},
teams: {
where: {
accepted: true,
},
select: {
role: true,
team: {
select: {
id: true,
name: true,
slug: true,
members: {
select: {
userId: true,
},
},
workflows: {
select: {
id: true,
name: true,
},
},
},
},
},
},
},
});
if (!user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const userWorkflows = user.workflows;
type WorkflowGroup = {
teamId?: number | null;
profile: {
slug: (typeof user)["username"];
name: (typeof user)["name"];
image?: string;
};
metadata?: {
readOnly: boolean;
};
workflows: typeof userWorkflows;
};
let workflowGroups: WorkflowGroup[] = [];
workflowGroups.push({
teamId: null,
profile: {
slug: user.username,
name: user.name,
image: user.avatar || undefined,
},
workflows: userWorkflows,
metadata: {
readOnly: false,
},
});
workflowGroups = ([] as WorkflowGroup[]).concat(
workflowGroups,
user.teams.map((membership) => ({
teamId: membership.team.id,
profile: {
name: membership.team.name,
slug: "team/" + membership.team.slug,
image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`,
},
metadata: {
readOnly: membership.role === MembershipRole.MEMBER,
},
workflows: membership.team.workflows,
}))
);
return {
workflowGroups: workflowGroups.filter((groupBy) => !!groupBy.workflows?.length),
profiles: workflowGroups.map((group) => ({
teamId: group.teamId,
...group.profile,
...group.metadata,
})),
};
}),
});
async function upsertSmsReminderFieldForBooking({
workflowId,
eventTypeId,
isSmsReminderNumberRequired,
}: {
workflowId: number;
isSmsReminderNumberRequired: boolean;
eventTypeId: number;
}) {
await upsertBookingField(
getSmsReminderNumberField(),
getSmsReminderNumberSource({
workflowId,
isSmsReminderNumberRequired,
}),
eventTypeId
);
}
async function removeSmsReminderFieldForBooking({
workflowId,
eventTypeId,
}: {
workflowId: number;
eventTypeId: number;
}) {
await removeBookingField(
{
name: SMS_REMINDER_NUMBER_FIELD,
},
{
id: "" + workflowId,
type: "workflow",
},
eventTypeId
);
}