diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/workflows/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/workflows/page.tsx new file mode 100644 index 00000000000..8d2599042b8 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/workflows/page.tsx @@ -0,0 +1,29 @@ +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { APP_NAME } from "@calcom/lib/constants"; +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import { WorkflowsListingView } from "~/workflows/views/workflows-listing-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => "Workflows", + (t) => `Automate meeting email/SMS reminders and follow-up notifications in ${APP_NAME}`, + undefined, + undefined, + "/workflows" + ); + +const WorkflowsPage = async () => { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + if (!session?.user?.id) { + redirect("/auth/login"); + } + + return ; +}; + +export default WorkflowsPage; diff --git a/apps/web/modules/shell/navigation/Navigation.tsx b/apps/web/modules/shell/navigation/Navigation.tsx index 892a95b5673..11b4612d242 100644 --- a/apps/web/modules/shell/navigation/Navigation.tsx +++ b/apps/web/modules/shell/navigation/Navigation.tsx @@ -34,6 +34,11 @@ const getNavigationItems = (): NavigationItemType[] => [ href: "/teams", icon: "users", }, + { + name: "workflows", + href: "/workflows", + icon: "zap", + }, { name: "apps", href: "/apps", diff --git a/apps/web/modules/workflows/views/workflows-listing-view.tsx b/apps/web/modules/workflows/views/workflows-listing-view.tsx new file mode 100644 index 00000000000..61385c4d142 --- /dev/null +++ b/apps/web/modules/workflows/views/workflows-listing-view.tsx @@ -0,0 +1,611 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { + TimeUnit, + WorkflowActions, + WorkflowTemplates, + WorkflowTriggerEvents, +} from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; +import classNames from "@calcom/ui/classNames"; +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, +} from "@calcom/ui/components/dialog"; +import { EmptyScreen } from "@calcom/ui/components/empty-screen"; +import { Form, Label, Select, SettingsToggle, TextAreaField, TextField } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; +import { + BellIcon, + CheckCircle2Icon, + ClockIcon, + CopyIcon, + MailIcon, + MessageSquareIcon, + PlusIcon, + Trash2Icon, + ZapIcon, +} from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; + +interface WorkflowFormData { + name: string; + trigger: WorkflowTriggerEvents; + time: number; + timeUnit: TimeUnit; + action: WorkflowActions; + emailSubject: string; + reminderBody: string; + sendTo?: string; + activeOn: number[]; +} + +const TRIGGER_LABELS: Record = { + [WorkflowTriggerEvents.BEFORE_EVENT]: "Before Event", + [WorkflowTriggerEvents.AFTER_EVENT]: "After Event", + [WorkflowTriggerEvents.NEW_EVENT]: "When Event is Booked", + [WorkflowTriggerEvents.RESCHEDULE_EVENT]: "When Event is Rescheduled", + [WorkflowTriggerEvents.EVENT_CANCELLED]: "When Event is Cancelled", +}; + +const ACTION_LABELS: Record = { + [WorkflowActions.EMAIL_ATTENDEE]: "Email Attendee", + [WorkflowActions.EMAIL_HOST]: "Email Host", + [WorkflowActions.EMAIL_ADDRESS]: "Email Custom Address", + [WorkflowActions.SMS_ATTENDEE]: "SMS Attendee", + [WorkflowActions.SMS_NUMBER]: "SMS Custom Number", + [WorkflowActions.WHATSAPP_ATTENDEE]: "WhatsApp Attendee", + [WorkflowActions.WHATSAPP_NUMBER]: "WhatsApp Custom Number", +}; + +export function WorkflowsListingView() { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editingWorkflowId, setEditingWorkflowId] = useState(null); + + const { data: workflows, isLoading } = trpc.viewer.workflows.list.useQuery(); + const { data: eventTypesData } = trpc.viewer.eventTypes.list.useQuery(); + + const eventTypeOptions = useMemo(() => { + if (!eventTypesData || !Array.isArray(eventTypesData)) return []; + return eventTypesData.map((et: { id: number; title: string }) => ({ + value: String(et.id), + label: et.title, + })); + }, [eventTypesData]); + + const form = useForm({ + defaultValues: { + name: "", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 24, + timeUnit: TimeUnit.HOUR, + action: WorkflowActions.EMAIL_ATTENDEE, + emailSubject: "Reminder: {EVENT_NAME} with {ORGANIZER_NAME}", + reminderBody: "Hi {ATTENDEE_NAME},\n\nThis is a quick reminder for your upcoming meeting: {EVENT_NAME} on {EVENT_DATE} at {EVENT_TIME}.\n\nLocation: {LOCATION}\n\nSee you soon!", + sendTo: "", + activeOn: [], + }, + }); + + const createMutation = trpc.viewer.workflows.create.useMutation({ + onSuccess: (newWf) => { + showToast(`Workflow "${newWf.name}" created successfully`, "success"); + setCreateDialogOpen(false); + form.reset(); + utils.viewer.workflows.list.invalidate(); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + const updateMutation = trpc.viewer.workflows.update.useMutation({ + onSuccess: (updatedWf) => { + showToast(`Workflow "${updatedWf.name}" updated successfully`, "success"); + setCreateDialogOpen(false); + setEditingWorkflowId(null); + form.reset(); + utils.viewer.workflows.list.invalidate(); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + const deleteMutation = trpc.viewer.workflows.delete.useMutation({ + onSuccess: () => { + showToast("Workflow deleted successfully", "success"); + utils.viewer.workflows.list.invalidate(); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + const duplicateMutation = trpc.viewer.workflows.duplicate.useMutation({ + onSuccess: (copy) => { + showToast(`Duplicated as "${copy.name}"`, "success"); + utils.viewer.workflows.list.invalidate(); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + const onSubmit = (values: WorkflowFormData) => { + const payload = { + name: values.name, + trigger: values.trigger, + time: values.trigger === WorkflowTriggerEvents.BEFORE_EVENT || values.trigger === WorkflowTriggerEvents.AFTER_EVENT ? Number(values.time) : null, + timeUnit: values.trigger === WorkflowTriggerEvents.BEFORE_EVENT || values.trigger === WorkflowTriggerEvents.AFTER_EVENT ? values.timeUnit : null, + steps: [ + { + stepNumber: 1, + action: values.action, + emailSubject: values.emailSubject, + reminderBody: values.reminderBody, + sendTo: values.sendTo || null, + template: WorkflowTemplates.REMINDER, + }, + ], + activeOn: values.activeOn.map(Number), + }; + + if (editingWorkflowId) { + updateMutation.mutate({ + id: editingWorkflowId, + ...payload, + }); + } else { + createMutation.mutate(payload); + } + }; + + const handleOpenPreset = (preset: { + name: string; + trigger: WorkflowTriggerEvents; + time: number; + timeUnit: TimeUnit; + action: WorkflowActions; + subject: string; + body: string; + }) => { + setEditingWorkflowId(null); + form.reset({ + name: preset.name, + trigger: preset.trigger, + time: preset.time, + timeUnit: preset.timeUnit, + action: preset.action, + emailSubject: preset.subject, + reminderBody: preset.body, + sendTo: "", + activeOn: [], + }); + setCreateDialogOpen(true); + }; + + const handleEditWorkflow = (wf: any) => { + setEditingWorkflowId(wf.id); + const firstStep = wf.steps?.[0] || {}; + form.reset({ + name: wf.name, + trigger: wf.trigger, + time: wf.time || 24, + timeUnit: wf.timeUnit || TimeUnit.HOUR, + action: firstStep.action || WorkflowActions.EMAIL_ATTENDEE, + emailSubject: firstStep.emailSubject || "", + reminderBody: firstStep.reminderBody || "", + sendTo: firstStep.sendTo || "", + activeOn: wf.activeOn?.map((a: any) => a.eventTypeId) || [], + }); + setCreateDialogOpen(true); + }; + + const watchTrigger = form.watch("trigger"); + const isTimeOffsetTrigger = + watchTrigger === WorkflowTriggerEvents.BEFORE_EVENT || watchTrigger === WorkflowTriggerEvents.AFTER_EVENT; + + return ( +
+ {/* Top Header */} +
+
+

{t("workflows")}

+

+ Automate personalized email and SMS reminders, notifications, and post-meeting follow-ups. +

+
+ +
+ + {/* Preset Quick Start Templates */} +
+

Quick-Start Workflow Templates

+

+ Deploy high-conversion notification automations with one click. +

+ +
+ + + + + +
+
+ + {/* Workflows List */} + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : workflows && workflows.length > 0 ? ( +
+ {workflows.map((wf) => { + const firstStep = wf.steps?.[0]; + const isEmail = firstStep?.action?.startsWith("EMAIL"); + + return ( +
+
+
+

{wf.name}

+ + {wf.active ? "Active" : "Disabled"} + +
+ +
+
+ + + {wf.time ? `${wf.time} ${wf.timeUnit?.toLowerCase()}(s) ` : ""} + {TRIGGER_LABELS[wf.trigger] || wf.trigger} + +
+ + • + +
+ {isEmail ? : } + {firstStep ? ACTION_LABELS[firstStep.action] || firstStep.action : "No action"} +
+ + • + + + {wf.activeOn.length === 0 + ? "Applied to all Event Types" + : `${wf.activeOn.length} Event Type(s)`} + +
+
+ +
+ + + + + +
+
+ ); + })} +
+ ) : ( + { + setEditingWorkflowId(null); + form.reset(); + setCreateDialogOpen(true); + }}> + Create Workflow + + } + /> + )} + + {/* Create / Edit Workflow Dialog */} + + + + +
+
+
+ + +
+ + {/* Trigger Selection */} +
+
+ + ( + + )} + /> +
+ + {isTimeOffsetTrigger && ( +
+
+ + +
+
+ + ( + + )} + /> +
+
+ )} +
+ + {/* Action Type */} +
+ + ( + + )} + /> +
+ + {/* Email Subject (if email action) */} + {form.watch("action").startsWith("EMAIL") && ( +
+ + +
+ )} + + {/* Reminder Body */} +
+
+ + + Supports: {EVENT_NAME}, {ATTENDEE_NAME}, {ORGANIZER_NAME}, {EVENT_DATE}, {EVENT_TIME}, {LOCATION} + +
+ +
+ + {/* Event Types Target */} + {eventTypeOptions.length > 0 && ( +
+ +

+ Leave unselected to apply this workflow across all event types. +

+
+ {eventTypeOptions.map((opt) => { + const activeOnList = form.watch("activeOn") || []; + const isChecked = activeOnList.includes(Number(opt.value)); + + return ( + + ); + })} +
+
+ )} +
+ + + + + +
+
+
+
+ ); +} + +export default WorkflowsListingView; diff --git a/packages/features/workflows/__tests__/WorkflowService.test.ts b/packages/features/workflows/__tests__/WorkflowService.test.ts new file mode 100644 index 00000000000..92caefac492 --- /dev/null +++ b/packages/features/workflows/__tests__/WorkflowService.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { WorkflowService } from "../lib/WorkflowService"; +import { + TimeUnit, + WorkflowActions, + WorkflowMethods, + WorkflowTemplates, + WorkflowTriggerEvents, +} from "@calcom/prisma/enums"; + +describe("WorkflowService", () => { + const mockPrisma: any = { + workflow: { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + workflowStep: { + deleteMany: vi.fn(), + }, + workflowsOnEventTypes: { + deleteMany: vi.fn(), + }, + workflowReminder: { + create: vi.fn(), + findMany: vi.fn(), + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("calculateScheduledDate", () => { + const startTime = new Date("2026-09-10T10:00:00.000Z"); + const endTime = new Date("2026-09-10T10:30:00.000Z"); + + it("should calculate 24 hours before meeting correctly", () => { + const scheduled = WorkflowService.calculateScheduledDate({ + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + startTime, + endTime, + time: 24, + timeUnit: TimeUnit.HOUR, + }); + + expect(scheduled.toISOString()).toBe("2026-09-09T10:00:00.000Z"); + }); + + it("should calculate 1 hour after meeting correctly", () => { + const scheduled = WorkflowService.calculateScheduledDate({ + trigger: WorkflowTriggerEvents.AFTER_EVENT, + startTime, + endTime, + time: 1, + timeUnit: TimeUnit.HOUR, + }); + + expect(scheduled.toISOString()).toBe("2026-09-10T11:30:00.000Z"); + }); + + it("should calculate immediate for NEW_EVENT", () => { + const before = Date.now(); + const scheduled = WorkflowService.calculateScheduledDate({ + trigger: WorkflowTriggerEvents.NEW_EVENT, + startTime, + endTime, + }); + const after = Date.now(); + + expect(scheduled.getTime()).toBeGreaterThanOrEqual(before); + expect(scheduled.getTime()).toBeLessThanOrEqual(after); + }); + }); + + describe("createWorkflow", () => { + it("should create workflow with steps and activeOn event types", async () => { + const service = new WorkflowService(mockPrisma); + mockPrisma.workflow.create.mockResolvedValueOnce({ + id: 1, + name: "24h Email Reminder", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 24, + timeUnit: TimeUnit.HOUR, + }); + + const result = await service.createWorkflow({ + userId: 10, + teamId: null, + input: { + name: "24h Email Reminder", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 24, + timeUnit: TimeUnit.HOUR, + steps: [ + { + stepNumber: 1, + action: WorkflowActions.EMAIL_ATTENDEE, + emailSubject: "Reminder: Meeting tomorrow", + reminderBody: "Hi {ATTENDEE_NAME}, see you tomorrow!", + }, + ], + activeOn: [101, 102], + }, + }); + + expect(result.id).toBe(1); + expect(mockPrisma.workflow.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: "24h Email Reminder", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 24, + timeUnit: TimeUnit.HOUR, + user: { connect: { id: 10 } }, + steps: { + create: [ + expect.objectContaining({ + action: WorkflowActions.EMAIL_ATTENDEE, + emailSubject: "Reminder: Meeting tomorrow", + }), + ], + }, + activeOn: { + create: [{ eventTypeId: 101 }, { eventTypeId: 102 }], + }, + }), + }) + ); + }); + }); + + describe("scheduleRemindersForBooking", () => { + it("should find active workflows and create reminder records", async () => { + const service = new WorkflowService(mockPrisma); + mockPrisma.workflow.findMany.mockResolvedValueOnce([ + { + id: 1, + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 1, + timeUnit: TimeUnit.HOUR, + steps: [ + { id: 10, stepNumber: 1, action: WorkflowActions.EMAIL_ATTENDEE }, + { id: 11, stepNumber: 2, action: WorkflowActions.SMS_ATTENDEE }, + ], + }, + ]); + mockPrisma.workflowReminder.create.mockImplementation(({ data }) => Promise.resolve({ id: Math.random(), ...data })); + + const reminders = await service.scheduleRemindersForBooking({ + bookingUid: "bk_123456", + eventTypeId: 101, + startTime: new Date("2026-09-10T15:00:00.000Z"), + endTime: new Date("2026-09-10T15:30:00.000Z"), + }); + + expect(reminders).toHaveLength(2); + expect(reminders[0].method).toBe(WorkflowMethods.EMAIL); + expect(reminders[0].scheduledDate.toISOString()).toBe("2026-09-10T14:00:00.000Z"); + expect(reminders[1].method).toBe(WorkflowMethods.SMS); + }); + }); +}); diff --git a/packages/features/workflows/index.ts b/packages/features/workflows/index.ts new file mode 100644 index 00000000000..e481b24fcd4 --- /dev/null +++ b/packages/features/workflows/index.ts @@ -0,0 +1 @@ +export * from "./lib/WorkflowService"; diff --git a/packages/features/workflows/lib/WorkflowService.ts b/packages/features/workflows/lib/WorkflowService.ts new file mode 100644 index 00000000000..6a218ff0657 --- /dev/null +++ b/packages/features/workflows/lib/WorkflowService.ts @@ -0,0 +1,401 @@ +import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import { + TimeUnit, + WorkflowActions, + WorkflowMethods, + WorkflowTemplates, + WorkflowTriggerEvents, +} from "@calcom/prisma/enums"; + +export interface WorkflowStepInput { + id?: number; + stepNumber: number; + action: WorkflowActions; + sendTo?: string | null; + reminderBody?: string | null; + emailSubject?: string | null; + template?: WorkflowTemplates; + sender?: string | null; + numberRequired?: boolean | null; + includeCalendarEvent?: boolean; +} + +export interface CreateWorkflowInput { + name: string; + trigger: WorkflowTriggerEvents; + time?: number | null; + timeUnit?: TimeUnit | null; + steps: WorkflowStepInput[]; + activeOn?: number[]; + isOrganiserEvent?: boolean; +} + +export interface UpdateWorkflowInput extends Partial { + active?: boolean; +} + +export class WorkflowService { + private prisma: PrismaClient; + + constructor(prisma: PrismaClient) { + this.prisma = prisma; + } + + /** + * Get all workflows accessible to a user (personal + team workflows) + */ + async getWorkflows({ userId, teamId }: { userId: number; teamId?: number | null }) { + const where: Prisma.WorkflowWhereInput = teamId + ? { teamId } + : { userId, teamId: null }; + + return this.prisma.workflow.findMany({ + where, + include: { + steps: { + orderBy: { stepNumber: "asc" }, + }, + activeOn: { + include: { + eventType: { + select: { id: true, title: true, slug: true }, + }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + } + + /** + * Get a single workflow by ID with security validation + */ + async getWorkflowById({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) { + const where: Prisma.WorkflowWhereInput = teamId + ? { id, teamId } + : { id, userId }; + + const workflow = await this.prisma.workflow.findFirst({ + where, + include: { + steps: { + orderBy: { stepNumber: "asc" }, + }, + activeOn: { + include: { + eventType: { + select: { id: true, title: true, slug: true }, + }, + }, + }, + }, + }); + + if (!workflow) { + throw new Error(`Workflow with ID ${id} not found or access denied`); + } + + return workflow; + } + + /** + * Create a new automated Workflow with triggers, steps, and active event types + */ + async createWorkflow({ + userId, + teamId, + input, + }: { + userId: number; + teamId?: number | null; + input: CreateWorkflowInput; + }) { + if (!input.name || input.name.trim().length === 0) { + throw new Error("Workflow name is required"); + } + + if (!input.steps || input.steps.length === 0) { + throw new Error("At least one workflow action step is required"); + } + + return this.prisma.workflow.create({ + data: { + name: input.name.trim(), + trigger: input.trigger, + time: input.time || null, + timeUnit: input.timeUnit || null, + isOrganiserEvent: input.isOrganiserEvent ?? false, + active: true, + user: teamId ? undefined : { connect: { id: userId } }, + team: teamId ? { connect: { id: teamId } } : undefined, + 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, + })), + }, + activeOn: input.activeOn && input.activeOn.length > 0 + ? { + create: input.activeOn.map((eventTypeId) => ({ + eventTypeId, + })), + } + : undefined, + }, + include: { + steps: true, + activeOn: true, + }, + }); + } + + /** + * Update an existing workflow + */ + async updateWorkflow({ + id, + userId, + teamId, + input, + }: { + id: number; + userId: number; + teamId?: number | null; + input: UpdateWorkflowInput; + }) { + // Validate existence & ownership + 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 } }, + }, + }, + }, + }); + } + + /** + * Delete a workflow + */ + async deleteWorkflow({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) { + await this.getWorkflowById({ id, userId, teamId }); + return this.prisma.workflow.delete({ + where: { id }, + }); + } + + /** + * Duplicate a workflow + */ + async duplicateWorkflow({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) { + const original = await this.getWorkflowById({ id, userId, teamId }); + + return this.createWorkflow({ + userId, + teamId, + input: { + name: `${original.name} (Copy)`, + trigger: original.trigger, + time: original.time, + timeUnit: original.timeUnit, + isOrganiserEvent: original.isOrganiserEvent, + steps: original.steps.map((s) => ({ + stepNumber: s.stepNumber, + action: s.action, + sendTo: s.sendTo, + reminderBody: s.reminderBody, + emailSubject: s.emailSubject, + template: s.template, + sender: s.sender, + numberRequired: s.numberRequired, + includeCalendarEvent: s.includeCalendarEvent, + })), + activeOn: original.activeOn.map((a) => a.eventTypeId), + }, + }); + } + + /** + * Calculate scheduledDate for a workflow trigger based on booking time + */ + static calculateScheduledDate({ + trigger, + startTime, + endTime, + time, + timeUnit, + }: { + trigger: WorkflowTriggerEvents; + startTime: Date; + endTime: Date; + time?: number | null; + timeUnit?: TimeUnit | null; + }): Date { + const startMs = new Date(startTime).getTime(); + const endMs = new Date(endTime).getTime(); + + if (trigger === WorkflowTriggerEvents.NEW_EVENT || trigger === WorkflowTriggerEvents.EVENT_CANCELLED || trigger === WorkflowTriggerEvents.RESCHEDULE_EVENT) { + return new Date(); // Send immediately + } + + let offsetMs = 0; + if (time && timeUnit) { + switch (timeUnit) { + case TimeUnit.DAY: + offsetMs = time * 24 * 60 * 60 * 1000; + break; + case TimeUnit.HOUR: + offsetMs = time * 60 * 60 * 1000; + break; + case TimeUnit.MINUTE: + offsetMs = time * 60 * 1000; + break; + } + } + + if (trigger === WorkflowTriggerEvents.BEFORE_EVENT) { + return new Date(startMs - offsetMs); + } + + if (trigger === WorkflowTriggerEvents.AFTER_EVENT) { + return new Date(endMs + offsetMs); + } + + return new Date(); + } + + /** + * Schedule all workflow reminders for a newly created or rescheduled booking + */ + async scheduleRemindersForBooking({ + bookingUid, + eventTypeId, + startTime, + endTime, + trigger = WorkflowTriggerEvents.BEFORE_EVENT, + }: { + bookingUid: string; + eventTypeId: number; + startTime: Date; + endTime: Date; + trigger?: WorkflowTriggerEvents; + }) { + // Find all active workflows associated with this eventType matching trigger + const workflows = await this.prisma.workflow.findMany({ + where: { + active: true, + trigger, + activeOn: { + some: { eventTypeId }, + }, + }, + include: { + steps: true, + }, + }); + + const createdReminders = []; + + for (const wf of workflows) { + const scheduledDate = WorkflowService.calculateScheduledDate({ + trigger: wf.trigger, + startTime, + endTime, + time: wf.time, + timeUnit: wf.timeUnit, + }); + + for (const step of wf.steps) { + let method: WorkflowMethods = WorkflowMethods.EMAIL; + if ( + step.action === WorkflowActions.SMS_ATTENDEE || + step.action === WorkflowActions.SMS_NUMBER + ) { + method = WorkflowMethods.SMS; + } else if ( + step.action === WorkflowActions.WHATSAPP_ATTENDEE || + step.action === WorkflowActions.WHATSAPP_NUMBER + ) { + method = WorkflowMethods.WHATSAPP; + } + + const reminder = await this.prisma.workflowReminder.create({ + data: { + bookingUid, + workflowStepId: step.id, + method, + scheduledDate, + scheduled: false, + referenceId: `rem_${bookingUid}_${step.id}_${Date.now()}`, + }, + }); + createdReminders.push(reminder); + } + } + + return createdReminders; + } +} + +export default WorkflowService; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 743ca560407..e1b4e767e6a 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -286,6 +286,7 @@ model EventType { rrHostSubsetEnabled Boolean @default(false) requiresCancellationReason CancellationReasonRequirement? @default(MANDATORY_HOST_ONLY) enablePerHostLocations Boolean @default(false) + workflows WorkflowsOnEventTypes[] createdAt DateTime? @default(now()) updatedAt DateTime? @updatedAt @@ -465,6 +466,7 @@ model User { verifiedNumbers VerifiedNumber[] verifiedEmails VerifiedEmail[] hosts Host[] + workflows Workflow[] // organizationId is deprecated. Instead, rely on the Profile to search profiles by organizationId and then get user from the profile. organizationId Int? organization Team? @relation("scope", fields: [organizationId], references: [id], onDelete: SetNull) @@ -639,7 +641,8 @@ model Team { organizationBilling OrganizationBilling? @relation("OrganizationBilling") integrationAttributeSyncs IntegrationAttributeSync[] - autoOptInFeatures Boolean @default(false) + autoOptInFeatures Boolean @default(false) + workflows Workflow[] // Monthly proration tracking seatChangeLogs SeatChangeLog[] @@ -912,6 +915,7 @@ model Booking { expenseLogs CreditExpenseLog[] report BookingReport? wrongAssignmentReports WrongAssignmentReport[] + workflowReminders WorkflowReminder[] // @@partial_index([reassignById]) @@ -2849,3 +2853,105 @@ model AttributeSyncFieldMapping { @@unique([integrationAttributeSyncId, attributeId]) @@index([integrationAttributeSyncId]) } + +// ----------------------------------------------------------------------------- +// WORKFLOWS & AUTOMATIONS (Phase 2) +// ----------------------------------------------------------------------------- + +enum WorkflowTriggerEvents { + BEFORE_EVENT + AFTER_EVENT + NEW_EVENT + RESCHEDULE_EVENT + EVENT_CANCELLED +} + +enum WorkflowActions { + EMAIL_HOST + EMAIL_ATTENDEE + EMAIL_ADDRESS + SMS_ATTENDEE + SMS_NUMBER + WHATSAPP_ATTENDEE + WHATSAPP_NUMBER +} + +enum WorkflowTemplates { + REMINDER + CUSTOM +} + +enum WorkflowMethods { + EMAIL + SMS + WHATSAPP +} + +model Workflow { + id Int @id @default(autoincrement()) + name String + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + trigger WorkflowTriggerEvents + time Int? + timeUnit TimeUnit? + steps WorkflowStep[] + activeOn WorkflowsOnEventTypes[] + isOrganiserEvent Boolean @default(false) + active Boolean @default(true) + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([teamId]) +} + +model WorkflowStep { + id Int @id @default(autoincrement()) + stepNumber Int + action WorkflowActions + workflowId Int + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + sendTo String? + reminderBody String? + emailSubject String? + template WorkflowTemplates @default(REMINDER) + sender String? + numberRequired Boolean? + numberVerificationPending Boolean @default(false) + includeCalendarEvent Boolean @default(false) + reminders WorkflowReminder[] + + @@index([workflowId]) +} + +model WorkflowsOnEventTypes { + id Int @id @default(autoincrement()) + workflowId Int + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + eventTypeId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + + @@unique([workflowId, eventTypeId]) + @@index([workflowId]) + @@index([eventTypeId]) +} + +model WorkflowReminder { + id Int @id @default(autoincrement()) + bookingUid String? + booking Booking? @relation(fields: [bookingUid], references: [uid], onDelete: Cascade) + method WorkflowMethods + scheduledDate DateTime + referenceId String? @unique + scheduled Boolean @default(false) + cancelled Boolean? @default(false) + workflowStepId Int? + workflowStep WorkflowStep? @relation(fields: [workflowStepId], references: [id], onDelete: Cascade) + seatReferenceId String? + + @@index([bookingUid]) + @@index([workflowStepId]) + @@index([scheduledDate, scheduled]) +} diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx index 33d428ad640..1f8f3c7bcab 100644 --- a/packages/trpc/server/routers/viewer/_router.tsx +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -28,6 +28,7 @@ import { viewerTeamsRouter } from "./teams/_router"; import { travelSchedulesRouter } from "./travelSchedules/_router"; import { userAdminRouter } from "./users/_router"; import { webhookRouter } from "./webhook/_router"; +import { viewerWorkflowsRouter } from "./workflows/_router"; export const viewerRouter = router({ loggedInViewerRouter, @@ -45,6 +46,7 @@ export const viewerRouter = router({ availability: availabilityRouter, timezones: timezonesRouter, webhook: webhookRouter, + workflows: viewerWorkflowsRouter, slots: slotsRouter, i18n: i18nRouter, features: featureFlagRouter, diff --git a/packages/trpc/server/routers/viewer/workflows/_router.tsx b/packages/trpc/server/routers/viewer/workflows/_router.tsx new file mode 100644 index 00000000000..7135ce2a262 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/_router.tsx @@ -0,0 +1,116 @@ +import { WorkflowService } from "@calcom/features/workflows/lib/WorkflowService"; +import { + TimeUnit, + WorkflowActions, + WorkflowTemplates, + WorkflowTriggerEvents, +} from "@calcom/prisma/enums"; +import { z } from "zod"; +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; + +export const ZWorkflowStepSchema = z.object({ + id: z.number().optional(), + stepNumber: z.number().int(), + action: z.nativeEnum(WorkflowActions), + sendTo: z.string().nullable().optional(), + reminderBody: z.string().nullable().optional(), + emailSubject: z.string().nullable().optional(), + template: z.nativeEnum(WorkflowTemplates).optional(), + sender: z.string().nullable().optional(), + numberRequired: z.boolean().nullable().optional(), + includeCalendarEvent: z.boolean().optional(), +}); + +export const ZCreateWorkflowSchema = z.object({ + teamId: z.number().nullable().optional(), + name: z.string().min(1), + trigger: z.nativeEnum(WorkflowTriggerEvents), + time: z.number().int().nullable().optional(), + timeUnit: z.nativeEnum(TimeUnit).nullable().optional(), + steps: z.array(ZWorkflowStepSchema).min(1), + activeOn: z.array(z.number().int()).optional(), + isOrganiserEvent: z.boolean().optional(), +}); + +export const ZUpdateWorkflowSchema = z.object({ + id: z.number().int(), + teamId: z.number().nullable().optional(), + name: z.string().min(1).optional(), + trigger: z.nativeEnum(WorkflowTriggerEvents).optional(), + time: z.number().int().nullable().optional(), + timeUnit: z.nativeEnum(TimeUnit).nullable().optional(), + active: z.boolean().optional(), + steps: z.array(ZWorkflowStepSchema).optional(), + activeOn: z.array(z.number().int()).optional(), + isOrganiserEvent: z.boolean().optional(), +}); + +export const ZGetWorkflowSchema = z.object({ + id: z.number().int(), + teamId: z.number().nullable().optional(), +}); + +export const ZListWorkflowsSchema = z + .object({ + teamId: z.number().nullable().optional(), + }) + .optional(); + +export const viewerWorkflowsRouter = router({ + list: authedProcedure.input(ZListWorkflowsSchema).query(async ({ ctx, input }) => { + const service = new WorkflowService(ctx.prisma); + return await service.getWorkflows({ + userId: ctx.user.id, + teamId: input?.teamId ?? null, + }); + }), + + get: authedProcedure.input(ZGetWorkflowSchema).query(async ({ ctx, input }) => { + const service = new WorkflowService(ctx.prisma); + return await service.getWorkflowById({ + id: input.id, + userId: ctx.user.id, + teamId: input.teamId ?? null, + }); + }), + + create: authedProcedure.input(ZCreateWorkflowSchema).mutation(async ({ ctx, input }) => { + const service = new WorkflowService(ctx.prisma); + return await service.createWorkflow({ + userId: ctx.user.id, + teamId: input.teamId ?? null, + input, + }); + }), + + update: authedProcedure.input(ZUpdateWorkflowSchema).mutation(async ({ ctx, input }) => { + const service = new WorkflowService(ctx.prisma); + return await service.updateWorkflow({ + id: input.id, + userId: ctx.user.id, + teamId: input.teamId ?? null, + input, + }); + }), + + delete: authedProcedure.input(ZGetWorkflowSchema).mutation(async ({ ctx, input }) => { + const service = new WorkflowService(ctx.prisma); + return await service.deleteWorkflow({ + id: input.id, + userId: ctx.user.id, + teamId: input.teamId ?? null, + }); + }), + + duplicate: authedProcedure.input(ZGetWorkflowSchema).mutation(async ({ ctx, input }) => { + const service = new WorkflowService(ctx.prisma); + return await service.duplicateWorkflow({ + id: input.id, + userId: ctx.user.id, + teamId: input.teamId ?? null, + }); + }), +}); + +export default viewerWorkflowsRouter; diff --git a/vitest.config.mts b/vitest.config.mts index 0c843820ca0..526828ed8bc 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -78,6 +78,13 @@ export default defineConfig({ { find: "app", replacement: path.resolve(__dirname, "apps/web/app") }, { find: "@calcom/web", replacement: path.resolve(__dirname, "apps/web") }, // Platform packages that need to be resolved from source in CI + { + find: "@calcom/prisma/enums", + replacement: path.resolve( + __dirname, + "packages/prisma/enums/index.ts" + ), + }, { find: "@calcom/platform-constants", replacement: path.resolve(