Conversation
…-Robin & Collective scheduling (#65) Add EventTeamTab with strategy selector (Round-Robin, Collective, Managed), advanced Round-Robin controls (weighted lead distribution, same host rescheduling, per-host locations), and CheckedTeamSelect host assignments. Wire EventTeamAssignmentTabWebWrapper in EventTypeWebWrapper.
…tomation Engine (#66) Add Prisma models for Workflow, WorkflowStep, WorkflowsOnEventTypes, WorkflowReminder. Implement WorkflowService in @calcom/features/workflows with timing calculation and reminder dispatch. Add viewerWorkflowsRouter in tRPC. Add Workflows dashboard UI at /workflows with template presets, trigger/action builders, and event type mapping.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_e71f2ae0-218a-4936-9233-48cf24d97020) |
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive Workflows and Automations feature, allowing users to schedule automated email, SMS, and WhatsApp reminders and follow-ups around meetings. It includes database schema updates, a backend WorkflowService with tRPC endpoints, and a frontend WorkflowsListingView dashboard, alongside team scheduling strategy updates in the event type settings. The review identified several critical security vulnerabilities regarding Broken Object Level Authorization (BOLA) and Privilege Escalation in team workflow operations, a logic bug preventing global workflows from triggering, database atomicity issues during updates, and missing mechanisms for canceling reminders and managing team workflows in the UI.
| const workflows = await this.prisma.workflow.findMany({ | ||
| where: { | ||
| active: true, | ||
| trigger, | ||
| activeOn: { | ||
| some: { eventTypeId }, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Critical Logic Bug: Global Workflows Will Never Trigger
Workflows can be configured to apply to "all event types" by leaving activeOn empty. However, the query in scheduleRemindersForBooking uses activeOn: { some: { eventTypeId } }, which requires at least one record in WorkflowsOnEventTypes matching the eventTypeId. This means any workflow intended for all event types will never be matched or scheduled.
We should update the query to match workflows where either activeOn is empty (none: {}) OR it contains the specific eventTypeId.
const workflows = await this.prisma.workflow.findMany({
where: {
active: true,
trigger,
OR: [
{
activeOn: {
none: {},
},
},
{
activeOn: {
some: { eventTypeId },
},
},
],
},| async getWorkflows({ userId, teamId }: { userId: number; teamId?: number | null }) { | ||
| const where: Prisma.WorkflowWhereInput = teamId | ||
| ? { teamId } | ||
| : { userId, teamId: null }; |
There was a problem hiding this comment.
Security Vulnerability: Broken Object Level Authorization (BOLA)
There is no validation checking if the requesting userId is actually a member of the specified teamId. Any authenticated user can query workflows for any team by passing their teamId.
We should verify that the user is an active member of the team before returning the workflows.
async getWorkflows({ userId, teamId }: { userId: number; teamId?: number | null }) {
if (teamId) {
const membership = await this.prisma.membership.findFirst({
where: { userId, teamId, accepted: true },
});
if (!membership) {
throw new Error("Access denied: You are not a member of this team");
}
}
const where: Prisma.WorkflowWhereInput = teamId
? { teamId }
: { userId, teamId: null };| async getWorkflowById({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) { | ||
| const where: Prisma.WorkflowWhereInput = teamId | ||
| ? { id, teamId } | ||
| : { id, userId }; |
There was a problem hiding this comment.
Security Vulnerability: Broken Object Level Authorization (BOLA)
There is no validation checking if the requesting userId is a member of the specified teamId when fetching a single workflow by ID. Any authenticated user can fetch any team's workflow by passing the corresponding teamId.
We should verify that the user is an active member of the team before returning the workflow.
async getWorkflowById({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) {
if (teamId) {
const membership = await this.prisma.membership.findFirst({
where: { userId, teamId, accepted: true },
});
if (!membership) {
throw new Error("Access denied: You are not a member of this team");
}
}
const where: Prisma.WorkflowWhereInput = teamId
? { id, teamId }
: { id, userId };| async createWorkflow({ | ||
| userId, | ||
| teamId, | ||
| input, | ||
| }: { | ||
| userId: number; | ||
| teamId?: number | null; | ||
| input: CreateWorkflowInput; | ||
| }) { |
There was a problem hiding this comment.
Security Vulnerability: Privilege Escalation / Unauthorized Creation
There is no validation checking if the requesting userId has administrative permissions (ADMIN or OWNER) on the specified teamId when creating a team workflow. Any authenticated user (even non-members) can create workflows for any team.
We should verify that the user is an active ADMIN or OWNER of the team before allowing them to create a team workflow.
async createWorkflow({
userId,
teamId,
input,
}: {
userId: number;
teamId?: number | null;
input: CreateWorkflowInput;
}) {
if (teamId) {
const membership = await this.prisma.membership.findFirst({
where: {
userId,
teamId,
accepted: true,
role: { in: ["ADMIN", "OWNER"] },
},
});
if (!membership) {
throw new Error("Access denied: You must be a team admin or owner to create team workflows");
}
}| async updateWorkflow({ | ||
| id, | ||
| userId, | ||
| teamId, | ||
| input, | ||
| }: { | ||
| id: number; | ||
| userId: number; | ||
| teamId?: number | null; | ||
| input: UpdateWorkflowInput; | ||
| }) { |
There was a problem hiding this comment.
Security Vulnerability: Privilege Escalation / Unauthorized Modification
There is no validation checking if the requesting userId has administrative permissions (ADMIN or OWNER) on the specified teamId when updating a team workflow. Any authenticated user can update workflows for any team.
We should verify that the user is an active ADMIN or OWNER of the team before allowing them to update a team workflow.
async updateWorkflow({
id,
userId,
teamId,
input,
}: {
id: number;
userId: number;
teamId?: number | null;
input: UpdateWorkflowInput;
}) {
if (teamId) {
const membership = await this.prisma.membership.findFirst({
where: {
userId,
teamId,
accepted: true,
role: { in: ["ADMIN", "OWNER"] },
},
});
if (!membership) {
throw new Error("Access denied: You must be a team admin or owner to update team workflows");
}
}| async duplicateWorkflow({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) { | ||
| const original = await this.getWorkflowById({ id, userId, teamId }); |
There was a problem hiding this comment.
Security Vulnerability: Privilege Escalation / Unauthorized Duplication
There is no validation checking if the requesting userId has administrative permissions (ADMIN or OWNER) on the specified teamId when duplicating a team workflow. Any authenticated user can duplicate workflows for any team.
We should verify that the user is an active ADMIN or OWNER of the team before allowing them to duplicate a team workflow.
async duplicateWorkflow({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) {
if (teamId) {
const membership = await this.prisma.membership.findFirst({
where: {
userId,
teamId,
accepted: true,
role: { in: ["ADMIN", "OWNER"] },
},
});
if (!membership) {
throw new Error("Access denied: You must be a team admin or owner to duplicate team workflows");
}
}
const original = await this.getWorkflowById({ id, userId, teamId });| // Handle steps replacement if provided | ||
| if (input.steps) { | ||
| await this.prisma.workflowStep.deleteMany({ | ||
| where: { workflowId: id }, | ||
| }); | ||
| } | ||
|
|
||
| // Handle activeOn event types update if provided | ||
| if (input.activeOn) { | ||
| await this.prisma.workflowsOnEventTypes.deleteMany({ | ||
| where: { workflowId: id }, | ||
| }); | ||
| } | ||
|
|
||
| return this.prisma.workflow.update({ | ||
| where: { id }, | ||
| data: { | ||
| ...(input.name ? { name: input.name.trim() } : {}), | ||
| ...(input.trigger !== undefined ? { trigger: input.trigger } : {}), | ||
| ...(input.time !== undefined ? { time: input.time } : {}), | ||
| ...(input.timeUnit !== undefined ? { timeUnit: input.timeUnit } : {}), | ||
| ...(input.active !== undefined ? { active: input.active } : {}), | ||
| ...(input.isOrganiserEvent !== undefined ? { isOrganiserEvent: input.isOrganiserEvent } : {}), | ||
| ...(input.steps | ||
| ? { | ||
| steps: { | ||
| create: input.steps.map((step, idx) => ({ | ||
| stepNumber: step.stepNumber || idx + 1, | ||
| action: step.action, | ||
| sendTo: step.sendTo || null, | ||
| reminderBody: step.reminderBody || null, | ||
| emailSubject: step.emailSubject || null, | ||
| template: step.template || WorkflowTemplates.REMINDER, | ||
| sender: step.sender || null, | ||
| numberRequired: step.numberRequired || null, | ||
| includeCalendarEvent: step.includeCalendarEvent ?? false, | ||
| })), | ||
| }, | ||
| } | ||
| : {}), | ||
| ...(input.activeOn | ||
| ? { | ||
| activeOn: { | ||
| create: input.activeOn.map((eventTypeId) => ({ | ||
| eventTypeId, | ||
| })), | ||
| }, | ||
| } | ||
| : {}), | ||
| }, | ||
| include: { | ||
| steps: { orderBy: { stepNumber: "asc" } }, | ||
| activeOn: { | ||
| include: { | ||
| eventType: { select: { id: true, title: true, slug: true } }, | ||
| }, | ||
| }, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Database Integrity Issue: Non-Atomic Deletions and Updates
In updateWorkflow, old steps and event type mappings are deleted first using separate deleteMany calls, and then the workflow is updated. If the subsequent update call fails (e.g., due to database constraints or validation errors), the workflow will be left in a corrupted state with zero steps and no event type mappings.
We should wrap these operations in a Prisma transaction ($transaction) to ensure atomicity and prevent data corruption.
return this.prisma.$transaction(async (tx) => {
// Handle steps replacement if provided
if (input.steps) {
await tx.workflowStep.deleteMany({
where: { workflowId: id },
});
}
// Handle activeOn event types update if provided
if (input.activeOn) {
await tx.workflowsOnEventTypes.deleteMany({
where: { workflowId: id },
});
}
return tx.workflow.update({
where: { id },
data: {
...(input.name ? { name: input.name.trim() } : {}),
...(input.trigger !== undefined ? { trigger: input.trigger } : {}),
...(input.time !== undefined ? { time: input.time } : {}),
...(input.timeUnit !== undefined ? { timeUnit: input.timeUnit } : {}),
...(input.active !== undefined ? { active: input.active } : {}),
...(input.isOrganiserEvent !== undefined ? { isOrganiserEvent: input.isOrganiserEvent } : {}),
...(input.steps
? {
steps: {
create: input.steps.map((step, idx) => ({
stepNumber: step.stepNumber || idx + 1,
action: step.action,
sendTo: step.sendTo || null,
reminderBody: step.reminderBody || null,
emailSubject: step.emailSubject || null,
template: step.template || WorkflowTemplates.REMINDER,
sender: step.sender || null,
numberRequired: step.numberRequired || null,
includeCalendarEvent: step.includeCalendarEvent ?? false,
})),
},
}
: {}),
...(input.activeOn
? {
activeOn: {
create: input.activeOn.map((eventTypeId) => ({
eventTypeId,
})),
},
}
: {}),
},
include: {
steps: { orderBy: { stepNumber: "asc" } },
activeOn: {
include: {
eventType: { select: { id: true, title: true, slug: true } },
},
},
},
});
});| for (const wf of workflows) { | ||
| const scheduledDate = WorkflowService.calculateScheduledDate({ | ||
| trigger: wf.trigger, | ||
| startTime, | ||
| endTime, | ||
| time: wf.time, | ||
| timeUnit: wf.timeUnit, | ||
| }); |
There was a problem hiding this comment.
Logic Bug: Outdated Reminders Scheduled for Immediate Delivery
If a booking is created within the reminder window (e.g., a booking is made 2 hours before the meeting, but there is a "24 hours before" email reminder workflow), calculateScheduledDate will return a date in the past. This causes the background worker to immediately send an outdated reminder right after booking, which is a confusing user experience.
We should add a guard to skip scheduling BEFORE_EVENT reminders if the calculated trigger time is already in the past.
for (const wf of workflows) {
const scheduledDate = WorkflowService.calculateScheduledDate({
trigger: wf.trigger,
startTime,
endTime,
time: wf.time,
timeUnit: wf.timeUnit,
});
// Skip scheduling if the trigger time is in the past (e.g. BEFORE_EVENT reminder where the offset has already passed)
if (wf.trigger === WorkflowTriggerEvents.BEFORE_EVENT && scheduledDate.getTime() < Date.now()) {
continue;
}| } | ||
|
|
||
| return createdReminders; | ||
| } |
There was a problem hiding this comment.
Missing Feature: No Cleanup/Cancellation of Reminders on Reschedule or Cancellation
When a booking is cancelled or rescheduled, any pending scheduled reminders (e.g., "24 hours before" emails) will still be sent because there is no mechanism in WorkflowService to cancel or delete them. This will result in spamming users with reminders for cancelled or rescheduled meetings.
We should add a cancelRemindersForBooking method to WorkflowService so that the booking controller can clean up pending reminders when a booking's status changes.
}
/**
* Cancel all pending reminders for a booking
*/
async cancelRemindersForBooking({ bookingUid }: { bookingUid: string }) {
return this.prisma.workflowReminder.updateMany({
where: {
bookingUid,
scheduled: false,
cancelled: false,
},
data: {
cancelled: true,
},
});
}| [WorkflowActions.WHATSAPP_NUMBER]: "WhatsApp Custom Number", | ||
| }; | ||
|
|
||
| export function WorkflowsListingView() { |
There was a problem hiding this comment.
Missing Feature: No Team Workflows Support in Dashboard UI
Although the backend routers and services support teamId for managing team-level workflows, the WorkflowsListingView component does not accept or pass a teamId prop. Consequently, any workflows created or listed via this dashboard will default to personal workflows, making it impossible for team admins to manage team workflows.
We should add an optional teamId prop to WorkflowsListingView and pass it to the tRPC queries and mutations.
| export function WorkflowsListingView() { | |
| export function WorkflowsListingView({ teamId }: { teamId?: number | null }) { |
Summary
escheduleWithSameRoundRobinHost), and Per-Host Meeting Locations (enablePerHostLocations).
Test plan
Note
Medium Risk
Schema migrations and new reminder scheduling touch bookings and outbound comms; reminder scheduling is not yet hooked from booking flows, and empty
activeOnmay not match the UI’s “all event types” behavior.Overview
This PR replaces the no-op team tab on event type setup with a real Team assignment experience: scheduling strategy selection (round-robin, collective, managed), round-robin options (weighted distribution, same-host reschedule, per-host locations), and host picking via existing
CheckedTeamSelect/ assign-all controls wired into the event type form.It also introduces a workflows automation stack: new Prisma models and relations (
Workflow, steps, event-type links, booking reminders),WorkflowService(CRUD, duplicate, trigger time math, reminder row creation),viewer.workflowstRPC endpoints, a/workflowsdashboard (templates, create/edit/delete/duplicate, event-type scoping), and a nav link to reach it. Unit tests coverEventTeamTabandWorkflowService; Vitest gets a@calcom/prisma/enumsalias.CHANGELOG entries for 2.1.0/2.2.0 are added in the same diff but describe broader platform work beyond these file changes.
Reviewed by Cursor Bugbot for commit f508fb9. Configure here.