Skip to content

feat: implement Team Event Types (Round-Robin & Collective) and Workflows Engine - #67

Merged
JOY (JOY) merged 2 commits into
mainfrom
dev
Sep 6, 2026
Merged

feat: implement Team Event Types (Round-Robin & Collective) and Workflows Engine#67
JOY (JOY) merged 2 commits into
mainfrom
dev

Conversation

@JOY

@JOY JOY (JOY) commented Sep 6, 2026

Copy link
Copy Markdown

Summary

  • Phase 1: Team Event Types (Round-Robin & Collective):
    • Added clean-room EventTeamTab supporting Round-Robin, Collective (All-Hands), and Managed event types.
    • Added switches for Weighted Lead Distribution (isRRWeightsEnabled), Reschedule with Same Host (
      escheduleWithSameRoundRobinHost), and Per-Host Meeting Locations (enablePerHostLocations).
    • Added host assignments with priority ranking, percentage weights, fixed host pins, and schedule selection.
  • Phase 2: Workflows & Meeting Reminder Automation Engine:
    • Implemented clean-room Workflow engine with models Workflow, WorkflowStep, WorkflowsOnEventTypes, and WorkflowReminder.
    • Added WorkflowService in @calcom/features/workflows with trigger time offset calculation, reminder scheduling for bookings, and CRUD operations.
    • Added tRPC router �iewer.workflows (list, get, create, update, delete, duplicate).
    • Added full Workflows dashboard at /workflows with template presets (24h email reminder, 1h SMS urgency, follow-up feedback), custom trigger/action builder, and event type mapping.
  • All 115 monorepo packages passed urbo type-check and unit tests passed 100%.

Test plan

  • Yarn turbo type-check: 115/115 packages passed
  • Vitest tests: EventTeamTab.test.tsx and WorkflowService.test.ts passed 100%
  • Docker build on GHCR

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 activeOn may 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.workflows tRPC endpoints, a /workflows dashboard (templates, create/edit/delete/duplicate, event-type scoping), and a nav link to reach it. Unit tests cover EventTeamTab and WorkflowService; Vitest gets a @calcom/prisma/enums alias.

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.

…-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.
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: c1f50358-90f5-434d-aff4-5f47c5cfaa76

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@cursor

cursor Bot commented Sep 6, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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)

@JOY
JOY (JOY) merged commit 4751643 into main Sep 6, 2026
31 checks passed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +345 to +352
const workflows = await this.prisma.workflow.findMany({
where: {
active: true,
trigger,
activeOn: {
some: { eventTypeId },
},
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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 },
            },
          },
        ],
      },

Comment on lines +48 to +51
async getWorkflows({ userId, teamId }: { userId: number; teamId?: number | null }) {
const where: Prisma.WorkflowWhereInput = teamId
? { teamId }
: { userId, teamId: null };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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 };

Comment on lines +74 to +77
async getWorkflowById({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) {
const where: Prisma.WorkflowWhereInput = teamId
? { id, teamId }
: { id, userId };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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 };

Comment on lines +105 to +113
async createWorkflow({
userId,
teamId,
input,
}: {
userId: number;
teamId?: number | null;
input: CreateWorkflowInput;
}) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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");
      }
    }

Comment on lines +163 to +173
async updateWorkflow({
id,
userId,
teamId,
input,
}: {
id: number;
userId: number;
teamId?: number | null;
input: UpdateWorkflowInput;
}) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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");
      }
    }

Comment on lines +251 to +252
async duplicateWorkflow({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) {
const original = await this.getWorkflowById({ id, userId, teamId });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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 });

Comment on lines +177 to +235
// 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 } },
},
},
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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 } },
            },
          },
        },
      });
    });

Comment on lines +360 to +367
for (const wf of workflows) {
const scheduledDate = WorkflowService.calculateScheduledDate({
trigger: wf.trigger,
startTime,
endTime,
time: wf.time,
timeUnit: wf.timeUnit,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
export function WorkflowsListingView() {
export function WorkflowsListingView({ teamId }: { teamId?: number | null }) {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant