diff --git a/CHANGELOG.md b/CHANGELOG.md index 218bbedac4d..dfba4c975ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ All notable changes to the **Crove Cal** platform will be documented in this fil --- +## [2.2.0] - 2026-09-03 + +### Added +- **DOS.Me Organization -> Teams Hierarchy & Zero-Latency JIT Token Claims**: + - Adopted new JWT token claims structure with `claims.organizations`, `claims.teams`, and `claims.active_org_id`. + - Automatic sub-team hierarchy provisioning (`parentId` mapping from `dosTeamId` to parent `dosOrgId`). + - Automatic role mapping from SSO (`LEAD` and `ADMIN` map to Cal.com `MembershipRole.ADMIN`). + - Real-time webhook handlers for `team.created`, `team.updated`, `team.deleted`, `team.member_added`, `team.member_removed`. +- **Webhook Health & Realtime Monitoring**: + - Implemented `@calcom/lib/webhookMonitor` service tracking latency, success rates, event volumes, and delivery audit logs in an in-memory telemetry buffer. + - Added public `/api/webhooks/health` API endpoint supporting GET metrics and POST simulated test pings. + - Added dedicated Webhook Health & Monitoring Dashboard UI at `/settings/developer/webhooks/monitoring` with live auto-refresh. +- **Crove CRM Direct Integration (`crm.crove.com`)**: + - Added `@calcom/features/crove-crm` (`CroveCrmService`) for contact upsertion and booking activity timeline synchronization with Team/Org attribution. + - Added `/api/webhooks/crove-crm` webhook bridge endpoint. + - Registered native **Crove CRM** app card (`@calcom/crovecrm`) in the Cal.com App Store under the CRM category (`/apps/categories/crm`) with full `CrmServiceMap` integration. +- **Deep Database Health Check Endpoint**: + - Implemented `/api/health` probe endpoint returning DB connectivity, latency in milliseconds, uptime, and application version for container orchestration and uptime monitors. + +### Optimized & Fixed +- Removed unused `@ts-expect-error` in `useRouterQuery.ts` for clean ES2024 native `entries` iteration. +- Guarded `husky install` in Docker build stages when `.git` is absent. +- Guarded `required` jobs in `.github/workflows/pr.yml` and `all-checks.yml` (`if: github.repository == 'calcom/cal.diy'`), permanently eliminating failed notification emails on the repository fork. + +--- + +## [2.1.0] - 2026-09-01 + +### Added +- **TypeScript 6.0.3 Monorepo Upgrade**: + - Upgraded `typescript` to `6.0.3` across all 115 packages and applications in the Turborepo monorepo. + - Modernized compiler targets to `ES2024` / `ES2022` in `packages/tsconfig/base.json`, `nextjs.json`, and package-level configurations. + - Added `docs/TypeScript-7-Migration-Roadmap.md` with technical audit and migration plan for future TypeScript 7.x release. +- **Clean-Room Multi-Tenant Teams & Organizations**: + - Implemented `TeamService` and `OrganizationService` in `packages/features/teams` and `packages/features/organizations`. + - Added viewer tRPC routers `viewer.teams` and `viewer.organizations`. + - Added `/teams` frontend listing view with department creation dialog. +- **Universal Crove App Switcher**: + - Implemented `` component in `@calcom/ui` with comprehensive ecosystem directory (Crove Suite & DOS Ecosystem apps). + - Integrated App Switcher into `TopNav.tsx` and `SideBar.tsx`. +- **Expanded E2E Playwright Test Suite**: + - Added comprehensive E2E tests for DOS ID login, App Switcher, multi-tenant page protections, and webhook health checks. + +--- + ## [2.0.0] - 2026-08-26 ### Added 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/event-types/components/EventTypeWebWrapper.tsx b/apps/web/modules/event-types/components/EventTypeWebWrapper.tsx index 1643fb8f7bc..30f07351c8b 100644 --- a/apps/web/modules/event-types/components/EventTypeWebWrapper.tsx +++ b/apps/web/modules/event-types/components/EventTypeWebWrapper.tsx @@ -45,7 +45,9 @@ const EventAvailabilityTab = dynamic(() => import("./tabs/availability/EventAvailabilityTabWebWrapper").then((mod) => mod) ); -const EventTeamAssignmentTab = dynamic(() => Promise.resolve((_props: Record) => null)); +const EventTeamAssignmentTab = dynamic(() => + import("./tabs/team/EventTeamAssignmentTabWebWrapper").then((mod) => mod) +); const EventLimitsTab = dynamic(() => import("./tabs/limits/EventLimitsTabWebWrapper").then((mod) => mod)); diff --git a/apps/web/modules/event-types/components/tabs/team/EventTeamAssignmentTabWebWrapper.tsx b/apps/web/modules/event-types/components/tabs/team/EventTeamAssignmentTabWebWrapper.tsx new file mode 100644 index 00000000000..0ecc83da6b8 --- /dev/null +++ b/apps/web/modules/event-types/components/tabs/team/EventTeamAssignmentTabWebWrapper.tsx @@ -0,0 +1,29 @@ +"use client"; + +import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; +import { EventTeamTab, type GenericTeamMember } from "./EventTeamTab"; + +export interface EventTeamAssignmentTabWebWrapperProps { + eventType: EventTypeSetupProps["eventType"]; + team: EventTypeSetupProps["team"]; + teamMembers: GenericTeamMember[]; + orgId?: number | null; +} + +export function EventTeamAssignmentTabWebWrapper({ + eventType, + team, + teamMembers, + orgId, +}: EventTeamAssignmentTabWebWrapperProps) { + return ( + + ); +} + +export default EventTeamAssignmentTabWebWrapper; diff --git a/apps/web/modules/event-types/components/tabs/team/EventTeamTab.tsx b/apps/web/modules/event-types/components/tabs/team/EventTeamTab.tsx new file mode 100644 index 00000000000..24421854677 --- /dev/null +++ b/apps/web/modules/event-types/components/tabs/team/EventTeamTab.tsx @@ -0,0 +1,287 @@ +"use client"; + +import AssignAllTeamMembers from "@calcom/features/eventtypes/components/AssignAllTeamMembers"; +import CheckedTeamSelect, { type CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect"; +import type { EventTypeSetupProps, FormValues } from "@calcom/features/eventtypes/lib/types"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { SchedulingType } from "@calcom/prisma/enums"; +import classNames from "@calcom/ui/classNames"; +import { Badge } from "@calcom/ui/components/badge"; +import { Label, SettingsToggle } from "@calcom/ui/components/form"; +import { RefreshCwIcon, UsersIcon, LayersIcon, ShieldCheckIcon, UserCheckIcon } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +export type GenericTeamMember = { + id?: number; + value?: string; + label?: string | null; + name?: string | null; + email: string; + avatar?: string | null; + avatarUrl?: string | null; + defaultScheduleId?: number | null; +}; + +export interface EventTeamTabProps { + eventType: EventTypeSetupProps["eventType"]; + team: EventTypeSetupProps["team"]; + teamMembers: GenericTeamMember[]; + orgId?: number | null; +} + +export function EventTeamTab({ eventType, team, teamMembers = [], orgId }: EventTeamTabProps) { + const { t } = useLocale(); + const form = useFormContext(); + + const watchSchedulingType = form.watch("schedulingType") || eventType.schedulingType || SchedulingType.ROUND_ROBIN; + const watchHosts = form.watch("hosts") || []; + const watchIsRRWeightsEnabled = form.watch("isRRWeightsEnabled") ?? false; + const watchAssignAll = form.watch("assignAllTeamMembers") ?? false; + const [assignAllTeamMembers, setAssignAllTeamMembers] = useState(watchAssignAll); + + // Convert TeamMembers to options for CheckedTeamSelect + const memberOptions: CheckedSelectOption[] = useMemo(() => { + return teamMembers.map((member) => { + const val = member.value ?? String(member.id ?? ""); + const lbl = member.label ?? member.name ?? member.email; + const avt = member.avatar ?? member.avatarUrl ?? ""; + return { + value: val, + label: lbl, + avatar: avt, + defaultScheduleId: member.defaultScheduleId ?? null, + groupId: null, + }; + }); + }, [teamMembers]); + + // Convert current selected hosts in form to CheckedSelectOption[] + const selectedHostOptions: CheckedSelectOption[] = useMemo(() => { + return watchHosts.map((host) => { + const member = teamMembers.find( + (m) => (m.value !== undefined && Number(m.value) === host.userId) || (m.id !== undefined && m.id === host.userId) + ); + return { + value: String(host.userId), + label: member?.label ?? member?.name ?? member?.email ?? `User #${host.userId}`, + avatar: member?.avatar ?? member?.avatarUrl ?? "", + defaultScheduleId: host.scheduleId ?? member?.defaultScheduleId ?? null, + priority: host.priority ?? 2, + weight: host.weight ?? 100, + isFixed: host.isFixed ?? false, + groupId: host.groupId ?? null, + }; + }); + }, [watchHosts, teamMembers]); + + const handleHostsChange = (newOptions: readonly CheckedSelectOption[]) => { + const updatedHosts = newOptions.map((opt) => ({ + userId: Number(opt.value), + isFixed: opt.isFixed ?? false, + priority: opt.priority ?? 2, + weight: opt.weight ?? 100, + scheduleId: opt.defaultScheduleId ?? null, + groupId: opt.groupId ?? null, + })); + + form.setValue("hosts", updatedHosts, { shouldDirty: true }); + }; + + const handleSelectSchedulingType = (type: SchedulingType) => { + form.setValue("schedulingType", type, { shouldDirty: true }); + }; + + const schedulingTypeCards = [ + { + type: SchedulingType.ROUND_ROBIN, + title: "Round-Robin", + description: "Distribute incoming bookings and leads among available team members (evenly or weighted).", + icon: RefreshCwIcon, + badge: "Most Popular", + }, + { + type: SchedulingType.COLLECTIVE, + title: "Collective", + description: "Allow clients to book a group meeting when ALL selected team members are free at once.", + icon: UsersIcon, + badge: "All-Hands", + }, + { + type: SchedulingType.MANAGED, + title: "Managed Event", + description: "Organization admin template distributed to all member calendars with locked settings.", + icon: LayersIcon, + badge: "Enterprise", + }, + ]; + + return ( +
+ {/* Section 1: Scheduling Type Strategy Selection */} +
+
+

Team Scheduling Strategy

+

+ Select how bookings for this team event type will be assigned among team members. +

+
+ +
+ {schedulingTypeCards.map((card) => { + const isSelected = watchSchedulingType === card.type; + const IconComponent = card.icon; + + return ( + + ); + })} +
+
+ + {/* Section 2: Round-Robin Advanced Distribution Controls */} + {watchSchedulingType === SchedulingType.ROUND_ROBIN && ( +
+
+ +

Round-Robin Assignment Settings

+
+ +
+ {/* Toggle: Weighted Lead Distribution */} +
+ ( + { + onChange(checked); + form.setValue("isRRWeightsEnabled", checked, { shouldDirty: true }); + }} + /> + )} + /> +
+ + {/* Toggle: Reschedule with Same Host */} +
+ ( + { + onChange(checked); + form.setValue("rescheduleWithSameRoundRobinHost", checked, { shouldDirty: true }); + }} + /> + )} + /> +
+ + {/* Toggle: Enable Per-Host Locations */} +
+ ( + { + onChange(checked); + form.setValue("enablePerHostLocations", checked, { shouldDirty: true }); + }} + /> + )} + /> +
+
+
+ )} + + {/* Section 3: Team Hosts Assignment */} +
+
+
+

Assigned Hosts

+

+ Choose which team members participate in this event type and customize their priorities. +

+
+ + { + const allHosts: CheckedSelectOption[] = memberOptions.map((opt) => ({ + ...opt, + priority: 2, + weight: 100, + isFixed: false, + })); + handleHostsChange(allHosts); + }} + onInactive={() => { + handleHostsChange([]); + }} + /> +
+ +
+ + + +
+
+
+ ); +} + +export default EventTeamTab; diff --git a/apps/web/modules/event-types/components/tabs/team/__tests__/EventTeamTab.test.tsx b/apps/web/modules/event-types/components/tabs/team/__tests__/EventTeamTab.test.tsx new file mode 100644 index 00000000000..b76c21e9098 --- /dev/null +++ b/apps/web/modules/event-types/components/tabs/team/__tests__/EventTeamTab.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { TooltipProvider } from "@radix-ui/react-tooltip"; +import { describe, expect, it, vi } from "vitest"; +import { EventTeamTab } from "../EventTeamTab"; +import { SchedulingType } from "@calcom/prisma/enums"; + +vi.mock("@formkit/auto-animate/react", () => ({ + useAutoAnimate: () => [{ current: null }], +})); + +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +const mockTeamMembers = [ + { id: 1, name: "Alice Nguyen", email: "alice@crove.com", avatar: "avatar1.png" }, + { id: 2, name: "Bob Tran", email: "bob@crove.com", avatar: "avatar2.png" }, + { id: 3, name: "Charlie Le", email: "charlie@crove.com", avatar: "avatar3.png" }, +]; + +const mockEventType: any = { + id: 101, + title: "Team Discovery 30m", + slug: "team-discovery", + schedulingType: SchedulingType.ROUND_ROBIN, + hosts: [ + { userId: 1, priority: 1, weight: 100, isFixed: true, groupId: null }, + { userId: 2, priority: 2, weight: 50, isFixed: false, groupId: null }, + ], + assignAllTeamMembers: false, + isRRWeightsEnabled: true, +}; + +function TestWrapper({ defaultValues = {} }: { defaultValues?: any }) { + const form = useForm({ + defaultValues: { + schedulingType: SchedulingType.ROUND_ROBIN, + hosts: mockEventType.hosts, + isRRWeightsEnabled: true, + assignAllTeamMembers: false, + rescheduleWithSameRoundRobinHost: false, + enablePerHostLocations: false, + ...defaultValues, + }, + }); + + return ( + + + + + + ); +} + +describe("EventTeamTab Component", () => { + it("renders team scheduling strategy cards (Round-Robin, Collective, Managed)", () => { + render(); + + expect(screen.getByText("Team Scheduling Strategy")).toBeInTheDocument(); + expect(screen.getByText("Round-Robin")).toBeInTheDocument(); + expect(screen.getByText("Collective")).toBeInTheDocument(); + expect(screen.getByText("Managed Event")).toBeInTheDocument(); + }); + + it("displays Round-Robin settings (weights toggle, reschedule same host) when Round-Robin is selected", () => { + render(); + + expect(screen.getByText("Round-Robin Assignment Settings")).toBeInTheDocument(); + expect(screen.getByText("Enable Weighted Distribution")).toBeInTheDocument(); + expect(screen.getByText("Reschedule with Same Host")).toBeInTheDocument(); + }); + + it("displays assigned hosts and team members list", () => { + render(); + + expect(screen.getByText("Assigned Hosts")).toBeInTheDocument(); + expect(screen.getAllByText("Alice Nguyen").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Bob Tran").length).toBeGreaterThanOrEqual(1); + }); +}); 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(