feat: form submitted workflow triggers #1 (#23704)

* feat: add 5 new workflow triggers for booking events

- Add BOOKING_REJECTED, BOOKING_REQUESTED, BOOKING_PAYMENT_INITIATED, BOOKING_PAID, BOOKING_NO_SHOW_UPDATED to WorkflowTriggerEvents enum
- Update workflow constants to include new trigger options
- Implement workflow trigger logic for booking rejected and requested events
- Add translations for new workflow triggers following {enum}_trigger format
- Generate updated Prisma types for new schema changes

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: type check, remove as any

* feat: add workflow trigger for BOOKING_REQUESTED in handleNewBooking.ts

- Add WorkflowTriggerEvents import to handleNewBooking.ts
- Implement workflow trigger logic for BOOKING_REQUESTED in else block
- Filter workflows by BOOKING_REQUESTED trigger and call scheduleWorkflowReminders
- Use proper calendar event object construction without type casting
- Add error handling for workflow reminder scheduling

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: resolve type errors in workflow trigger implementations

- Add proper database includes for user information in handleConfirmation.ts
- Fix ExtendedCalendarEvent type structure with correct hosts mapping
- Add missing properties to calendar event objects in handleMarkNoShow.ts
- Ensure all workflow triggers follow proper type patterns

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* feat: add workflow test configurations for new booking triggers

- Add workflow configurations for BOOKING_REQUESTED and BOOKING_PAYMENT_INITIATED in fresh-booking.test.ts
- Add workflow configuration for BOOKING_REJECTED in confirm.handler.test.ts
- Enable previously skipped confirm.handler.test.ts
- Remove workflow test assertions temporarily until triggers are fully functional
- Maintain webhook test coverage while adding workflow test infrastructure

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: add missing mockSuccessfulVideoMeetingCreation import to confirm.handler.test.ts

- Import mockSuccessfulVideoMeetingCreation from bookingScenario utils
- Add mock call to BOOKING_REJECTED workflow test case
- Resolves ReferenceError that was causing unit test CI failure

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* add new triggers

* refactor: improve _scheduleWorkflowReminders readability and add missing booking trigger events

- Extract complex conditional logic into helper functions (isImmediateTrigger, isTimeBased, shouldProcessWorkflow)
- Add missing workflow trigger events with immediate execution logic
- Update test workflows to use different actions (EMAIL_ATTENDEE, SMS_ATTENDEE) for better differentiation
- Fix translation function mock in confirm.handler.test.ts using mockNoTranslations utility
- Maintain existing functionality while improving code maintainability

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* only show customt emplate for form triggers

* filter outside scheduleWorkflowReminder

* fix type check

* chore: add more tests

* test: add comprehensive unit tests for handleMarkNoShow with webhook and workflow coverage

- Create handleMarkNoShow.test.ts following confirm.handler.test.ts pattern
- Add expectBookingNoShowUpdatedWebhookToHaveBeenFired utility function
- Test both webhook and workflow triggers for BOOKING_NO_SHOW_UPDATED
- Cover attendee/host no-show scenarios, multiple attendees, and error cases
- All 6 unit tests pass with proper mocking of external dependencies

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* Revert "test: add comprehensive unit tests for handleMarkNoShow with webhook and workflow coverage"

This reverts commit 764299220279f0c012392dec24d3150246bfc4ad.

* fix: add new workflow triggers to api/v2

* update swagger docs

* fix: e2e

* fix type check

* fix tests, add test for before after events

* fix unit tests

* revert confirm.handler.test

* fix: unit tests

* dummy form variables

* add routing forms to active on dropdown

* add migration file

* Ui fixes for variables dropdown

* remove other translation keys

* review fixes

* allow routing forms for activeOn

* use repository function to get routing forms

* remove unnecessary code

* adjust logic in update handler

* add triggers to api v2

* remvoe unused file

* rename to getAcitveOnOptions handler

* remove routingFormOptions handler

* clean up getActiveOnOptions

* refactor WorkflowService

* remove logs

* remove unused

* fix: type check

* fix: missed before after events for recurring

* fix: calendarEvent handleMarkNoShow

* fix error message

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* don't query disabled routing forms

* create tasker function

* add tasker code

* move isFormTrigger function

* small adjustments + todo comments

* remove email to host action for form triggers

* throw trpc error if email to host is added as step

* fix dialog on how to use form responses as variables

* remove add variable dropdown for form triggers

* remove form workfows in event workflows tab

* improvements for workflow logic on form submission

* review fixes

* base setup for seperate schedule functions (evt and form)

* add missing BOOKING_PAID workflow trigger

* fix pathname

* fix: test for BOOKING_REQUESTED

* fix activeOn ids

* pass hideBranding and smsReminderNumber

* adjustments to reminderScheduler

* create empty scheduelForForm functions

* pass locale and timezone with form user

* pass formData instead of responses

* pass timeFormat and locale

* reusable function for email sending and reminder creation

* implement scheduleEmailReminderForForm

* remove added editor field from merge conflict

* don't support cal.ai action with form triggers

* throw bad request if form trigger and cal.ai is combined

* add tests for scheduleFormWorkflows

* add form submission tests

* remove form response varibe info

* clean up workflow actions

* fixes for getting template options

* pass triggerType to getAllWorkflows

* move reusable logic to scheduleSMSReminder

* add formdata to param type

* type fixes for text reminder managers

* implement scheduleSMSReminderForForm

* fix import

* fix isAuthorizedToAddActiveOnIds

* disble whatsapp action

* implement triggerFormSubmittedNoEventWorkflow

* code clean up

* Merge branch 'devin/1755107037-add-workflow-triggers' into feat/routing-form-workflow-triggers

* fix type errors

* remove async from getSubmitterEmail

* fix type errors

* revert cal.ai changes

* fix type error

* add sublogger

* code clean up

* fix type errors

* remove label for attendee whatsapp action

* code clean up

* fixes saving teams on org workflows

* fix type error

* code improvements for activeOn ids

* Revert "code improvements for activeOn ids"

This reverts commit 0a3590a4e2ce541b17d63483ad86ed458795a6a3.

* improve variable name

* fix unit tests

* small fixes

* type fixes

* remove unused translation keys

* fix merge conflict issues

* code clean up

* remove SMS action support

* remove more SMS code

* add missing imports

* set custom template for form action

* type fixes

* fix tasker endpoint

* fix duplicate check

* fix workfows.test.ts

* use repository funciton to getHideBranding

* code clean up

* fix hasDuplicateSubmission

* code clean up

* select only needed properties

* remove repository functions

* Revert "remove repository functions"

This reverts commit 7aa47b1c59c9abd7f964ebf26f746934c53a44f4.

* add scheduleWorkflows function

* Revert "add scheduleWorkflows function"

This reverts commit fe5db4fe3b65e2743c95475d585300cab98beed7.

* move type to /types

* Revert "move type to /types"

This reverts commit 91e0152154594b3772a801a426260068a8ccea54.

* revert changes causing type errors

* remove import

* remove unused import

* Revert "remove unused import"

This reverts commit 1916768c875ea5d0ac5598ccb8f9c796c5622dc9.

* revert changed from attempt to fix type errors

* pass object to gt all workflows

* fix isAuthorized check

* trigger filtering

* remove form submitted no event booked code

* remove form submitted no event from schema

* remove more code

* remove test

* fixes

* add getSubmitterEmail function

* add missing workflow DTOs

* small fixes

* use activeOnWithChildren

* fix active on when switching trigger type

* remove add variable dropdown

* add getAllWorkflowsFromRoutingForm to WorkflowService

* fix error caused by undefined evt

* fix type error

* fix type error

* fix tests

* code clean up

* remove console.log

* remove template text form from triggers

* add routing form repoditory function

* fix bug with key

* add missing trigger in update-workflow.input.ts

* ForEvt and ForForm function for aiPhoneCallManager

* chore: add support for form workflows on api v2

* fixup! chore: add support for form workflows on api v2

* use only repository functions in update handler

* move all prisma queries from list.handler

* review suggestions

* chore: handle workflows api v2

* chore: handle workflows api v2, split in 2 endpoints

* fix workflow step creation

* remove connect agent and fixes types

* add type to workflow

* chore: use workflow type in apiv2 WorkflowsOutputService

* update worklfow type on update

* chore: use workflow type in apiv2 WorkflowsOutputService

* fix template body for torm trigger

* some UI fixes for email subject/body

* resetting email body when changing form triggers

* use type field to query workflows

* clean up all old active on values

* remove responseId from all funciton calls

* remove undefined from updateTemplate

* refactor: split routing form and event-type workflows code

* refactor: split routing form and event-type workflows code

* fix template  text when adding action

* chore: don't rename WorkflowActivationDto to avoid ci blocking

* refine update schedule to use only allowed actions

* fix type error

* don't allow whatsapp action with form trigger

* fix type error

* return early if activeOn array is empty

* fix: from step type in BaseFormWorkflowStepDto

* fixup! fix: from step type in BaseFormWorkflowStepDto

* move all prisma calls to repository (service/workflows.ts)

* use FORM_TRIGGER_WORKFLOW_EVENTS for form queries

* use userRepository

* use FORM_TRIGGER_WORKFLOW_EVENTS in isFormTrigger

* code clean up

* code clean up

* add back trpc  import

* fix agent repository functions

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: amit@cal.com <samit91848@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Carina Wollendorfer
2025-10-13 12:20:47 +02:00
committed by GitHub
co-authored by amit@cal.com coderabbitai[bot] Devin AI Amit Sharma CarinaWolli Udit Takkar Benny Joo cal.com Morgan
parent c0c52750b6
commit 44a3a9eabb
69 changed files with 5401 additions and 2034 deletions
@@ -9,7 +9,7 @@ import {
import { Request } from "express";
@Injectable()
export class IsWorkflowInTeam implements CanActivate {
export class IsEventTypeWorkflowInTeam implements CanActivate {
constructor(private workflowsRepository: WorkflowsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -41,10 +41,13 @@ export class IsWorkflowInTeam implements CanActivate {
teamId: string,
workflowId: string
): Promise<{ canAccess: boolean; workflow?: WorkflowType }> {
const workflow = await this.workflowsRepository.getTeamWorkflowById(Number(teamId), Number(workflowId));
const workflow = await this.workflowsRepository.getEventTypeTeamWorkflowById(
Number(teamId),
Number(workflowId)
);
if (!workflow) {
throw new NotFoundException(`IsWorkflowInTeam - workflow (${workflowId}) not found.`);
throw new NotFoundException(`IsWorkflowInTeam - event-type workflow (${workflowId}) not found.`);
}
if (workflow.teamId === Number(teamId)) {
@@ -0,0 +1,59 @@
import { WorkflowsRepository, WorkflowType } from "@/modules/workflows/workflows.repository";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
@Injectable()
export class IsRoutingFormWorkflowInTeam implements CanActivate {
constructor(private workflowsRepository: WorkflowsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { workflow?: WorkflowType }>();
const teamId: string = request.params.teamId;
const workflowId: string = request.params.workflowId;
if (!workflowId) {
throw new ForbiddenException("IsWorkflowInTeam - No workflow found in request params.");
}
if (!teamId) {
throw new ForbiddenException("IsWorkflowInTeam - No team id found in request params.");
}
const { canAccess, workflow } = await this.checkIfWorkflowIsInTeam(teamId, workflowId);
if (!canAccess) {
throw new ForbiddenException(
`IsTeamInOrg - Workflow with id=${workflowId} is not part of the team with id=${teamId}.`
);
}
request.workflow = workflow;
return true;
}
async checkIfWorkflowIsInTeam(
teamId: string,
workflowId: string
): Promise<{ canAccess: boolean; workflow?: WorkflowType }> {
const workflow = await this.workflowsRepository.getRoutingFormTeamWorkflowById(
Number(teamId),
Number(workflowId)
);
if (!workflow) {
throw new NotFoundException(`IsWorkflowInTeam - routing form workflow (${workflowId}) not found.`);
}
if (workflow.teamId === Number(teamId)) {
return { canAccess: true, workflow };
}
return { canAccess: false };
}
}
@@ -46,8 +46,8 @@ import { OrganizationsStripeService } from "@/modules/organizations/stripe/servi
import { OrganizationsTeamsController } from "@/modules/organizations/teams/index/organizations-teams.controller";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsInviteController } from "@/modules/organizations/teams/invite/organizations-teams-invite.controller";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
@@ -76,7 +76,8 @@ import { UsersModule } from "@/modules/users/users.module";
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
import { TeamWorkflowsService } from "@/modules/workflows/services/team-workflows.service";
import { TeamEventTypeWorkflowsService } from "@/modules/workflows/services/team-event-type-workflows.service";
import { TeamRoutingFormWorkflowsService } from "@/modules/workflows/services/team-routing-form-workflows.service";
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
@@ -145,7 +146,8 @@ import { Module } from "@nestjs/common";
TokensRepository,
TeamsVerifiedResourcesRepository,
WorkflowsRepository,
TeamWorkflowsService,
TeamEventTypeWorkflowsService,
TeamRoutingFormWorkflowsService,
WorkflowsInputService,
WorkflowsOutputService,
TeamsSchedulesService,
@@ -4,22 +4,42 @@ import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
import {
CreateEventTypeWorkflowDto,
WorkflowActivationDto,
} from "@/modules/workflows/inputs/create-event-type-workflow.input";
import {
CreateFormWorkflowDto,
WorkflowFormActivationDto,
} from "@/modules/workflows/inputs/create-form-workflow";
import {
ATTENDEE,
REMINDER,
PHONE_NUMBER,
EMAIL,
WorkflowEmailAttendeeStepDto,
WorkflowEmailAddressStepDto,
UpdateEmailAddressWorkflowStepDto,
UpdatePhoneWhatsAppNumberWorkflowStepDto,
} from "@/modules/workflows/inputs/workflow-step.input";
import {
AFTER_EVENT,
BEFORE_EVENT,
DAY,
FORM_SUBMITTED,
OnAfterEventTriggerDto,
OnBeforeEventTriggerDto,
OnFormSubmittedTriggerDto,
} from "@/modules/workflows/inputs/workflow-trigger.input";
import {
GetEventTypeWorkflowOutput,
GetEventTypeWorkflowsOutput,
} from "@/modules/workflows/outputs/event-type-workflow.output";
// Adjust path if needed
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
import {
GetRoutingFormWorkflowOutput,
GetRoutingFormWorkflowsOutput,
} from "@/modules/workflows/outputs/routing-form-workflow.output";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
@@ -51,14 +71,21 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
let org: Team;
let orgTeam: Team;
let createdWorkflowId: number;
let createdFormWorkflowId: number;
const authEmail = `org-teams-workflows-user-${randomString()}@example.com`;
let user: User;
let apiKeyString: string;
let verifiedPhoneId: number;
let verifiedPhoneId2: number;
let verifiedEmailId: number;
let createdWorkflow: GetWorkflowOutput["data"];
let verifiedEmailId2: number;
let createdWorkflow: GetEventTypeWorkflowOutput["data"];
let createdFormWorkflow: GetRoutingFormWorkflowOutput["data"];
let sampleCreateWorkflowDto: CreateWorkflowDto = {
const emailToVerify = `org-teams-workflows-team-${randomString()}@example.com`;
const phoneToVerify = `+37255556666`;
let sampleCreateEventTypeWorkflowDto = {
name: `E2E Test Workflow ${randomString()}`,
activation: {
isActiveOnAllEventTypes: true,
@@ -72,6 +99,18 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
},
},
steps: [],
} as unknown as CreateEventTypeWorkflowDto;
let sampleCreateWorkflowRoutingFormDto: CreateFormWorkflowDto = {
name: `E2E Test Workflow ${randomString()}`,
activation: {
activeOnRoutingFormIds: [],
isActiveOnAllRoutingForms: true,
},
trigger: {
type: FORM_SUBMITTED,
},
steps: [],
};
beforeAll(async () => {
@@ -149,15 +188,29 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
phoneNumber: "+37255555555",
team: { connect: { id: orgTeam.id } },
});
const verifiedPhone2 = await verifiedResourcesRepositoryFixtures.createPhone({
user: { connect: { id: user.id } },
phoneNumber: phoneToVerify,
team: { connect: { id: orgTeam.id } },
});
const verifiedEmail = await verifiedResourcesRepositoryFixtures.createEmail({
user: { connect: { id: user.id } },
email: authEmail,
team: { connect: { id: orgTeam.id } },
});
verifiedEmailId = verifiedEmail.id;
verifiedPhoneId = verifiedPhone.id;
sampleCreateWorkflowDto = {
const verifiedEmail2 = await verifiedResourcesRepositoryFixtures.createEmail({
user: { connect: { id: user.id } },
email: emailToVerify,
team: { connect: { id: orgTeam.id } },
});
verifiedEmailId = verifiedEmail.id;
verifiedEmailId2 = verifiedEmail2.id;
verifiedPhoneId = verifiedPhone.id;
verifiedPhoneId2 = verifiedPhone2.id;
sampleCreateEventTypeWorkflowDto = {
name: `E2E Test Workflow ${randomString()}`,
activation: {
isActiveOnAllEventTypes: true,
@@ -208,6 +261,43 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
},
},
{
stepNumber: 4,
action: "sms_attendee",
recipient: PHONE_NUMBER,
template: REMINDER,
phoneRequired: true,
sender: "CalcomE2EStep4",
message: {
subject: "Upcoming: {EVENT_NAME}",
text: "Reminder for your event {EVENT_NAME}.",
},
},
],
};
sampleCreateWorkflowRoutingFormDto = {
name: `E2E Test Form Workflow ${randomString()}`,
activation: {
isActiveOnAllRoutingForms: true,
activeOnRoutingFormIds: [],
},
trigger: {
type: FORM_SUBMITTED,
},
steps: [
{
stepNumber: 1,
action: "email_attendee",
recipient: ATTENDEE,
template: REMINDER,
sender: "CalcomE2EStep1",
includeCalendarEvent: true,
message: {
subject: "Upcoming: {EVENT_NAME}",
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
},
},
],
};
basePath = `/v2/organizations/${org.id}/teams/${orgTeam.id}/workflows`;
@@ -220,7 +310,9 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
if (createdWorkflowId) {
try {
await workflowsRepositoryFixture.delete(createdWorkflowId);
} catch (error) {}
} catch {
/* empty */
}
}
await userRepositoryFixture.deleteByEmail(user.email);
@@ -235,18 +327,169 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
return request(app.getHttpServer())
.post(basePath)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(sampleCreateWorkflowDto)
.send(sampleCreateEventTypeWorkflowDto)
.expect(201)
.then((response) => {
const responseBody: GetWorkflowOutput = response.body;
const responseBody: GetEventTypeWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(sampleCreateWorkflowDto.name);
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
sampleCreateWorkflowDto.activation.isActiveOnAllEventTypes
expect(responseBody.data.activation).toBeDefined();
expect(responseBody.data.name).toEqual(sampleCreateEventTypeWorkflowDto.name);
if (responseBody.data.activation instanceof WorkflowActivationDto) {
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
sampleCreateEventTypeWorkflowDto.activation.isActiveOnAllEventTypes
);
}
if (
responseBody.data.activation instanceof WorkflowFormActivationDto &&
sampleCreateEventTypeWorkflowDto.activation instanceof WorkflowFormActivationDto
) {
expect(responseBody.data.activation.isActiveOnAllRoutingForms).toEqual(
sampleCreateEventTypeWorkflowDto.activation.isActiveOnAllRoutingForms
);
}
expect(responseBody.data.trigger.type).toEqual(sampleCreateEventTypeWorkflowDto.trigger.type);
expect(responseBody.data.steps).toHaveLength(sampleCreateEventTypeWorkflowDto.steps.length);
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 4)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual(
"CalcomE2EStep1"
);
expect(responseBody.data.trigger.type).toEqual(sampleCreateWorkflowDto.trigger.type);
expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowDto.steps.length);
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.includeCalendarEvent).toEqual(
true
);
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.sender).toEqual(
"CalcomE2EStep2"
);
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.phone).toEqual(
"+37255555555"
);
expect(responseBody.data.steps.find((step) => step.stepNumber === 4)?.phoneRequired).toEqual(true);
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
const trigger = sampleCreateEventTypeWorkflowDto.trigger as OnBeforeEventTriggerDto;
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
createdWorkflowId = responseBody.data.id;
createdWorkflow = responseBody.data;
expect(responseBody.data.type).toEqual("event-type");
});
});
it("should not create a new routing form workflow with trigger not FORM_SUBMITTED", async () => {
const invalidWorkflow = structuredClone(
sampleCreateWorkflowRoutingFormDto
) as unknown as CreateEventTypeWorkflowDto;
invalidWorkflow.trigger.type = AFTER_EVENT;
return request(app.getHttpServer())
.post(`${basePath}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(invalidWorkflow)
.expect(400);
});
it("should not create a new routing form workflow with not allowed actions", async () => {
// force impossible step to test validation, should fail with 400
const invalidWorkflow = structuredClone(
sampleCreateWorkflowRoutingFormDto
) as unknown as CreateEventTypeWorkflowDto;
invalidWorkflow.steps = [
{
stepNumber: 1,
action: "sms_number",
recipient: PHONE_NUMBER,
template: REMINDER,
verifiedPhoneId: verifiedPhoneId,
sender: "CalcomE2EStep2",
message: {
subject: "Upcoming: {EVENT_NAME}",
text: "Reminder for your event {EVENT_NAME}.",
},
} as unknown as WorkflowEmailAddressStepDto,
];
return request(app.getHttpServer())
.post(`${basePath}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(invalidWorkflow)
.expect(400);
});
it("should create a new routing form workflow with allowed actions", async () => {
const validWorkflow = structuredClone(
sampleCreateWorkflowRoutingFormDto
) as unknown as CreateEventTypeWorkflowDto;
validWorkflow.steps = [
{
stepNumber: 1,
action: "email_attendee",
recipient: ATTENDEE,
template: REMINDER,
sender: "CalcomE2EStep1",
includeCalendarEvent: true,
message: {
subject: "Upcoming: {EVENT_NAME}",
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
},
},
];
return request(app.getHttpServer())
.post(`${basePath}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(validWorkflow)
.expect(201)
.then((response) => {
const responseBody: GetRoutingFormWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(sampleCreateWorkflowRoutingFormDto.name);
expect(responseBody.data.type).toEqual("routing-form");
if (responseBody.data.activation instanceof WorkflowFormActivationDto) {
expect(responseBody.data.activation.isActiveOnAllRoutingForms).toEqual(
sampleCreateWorkflowRoutingFormDto.activation.isActiveOnAllRoutingForms
);
}
expect(responseBody.data.trigger.type).toEqual(sampleCreateWorkflowRoutingFormDto.trigger.type);
expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowRoutingFormDto.steps.length);
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual(
"CalcomE2EStep1"
);
const trigger = sampleCreateWorkflowRoutingFormDto.trigger as OnFormSubmittedTriggerDto;
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
createdFormWorkflowId = responseBody.data.id;
createdFormWorkflow = responseBody.data;
});
});
it("should create a new workflow", async () => {
return request(app.getHttpServer())
.post(basePath)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ ...sampleCreateEventTypeWorkflowDto, type: undefined })
.expect(201)
.then((response) => {
const responseBody: GetEventTypeWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(sampleCreateEventTypeWorkflowDto.name);
if (responseBody.data.activation instanceof WorkflowActivationDto) {
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
sampleCreateEventTypeWorkflowDto.activation.isActiveOnAllEventTypes
);
}
expect(responseBody.data.trigger.type).toEqual(sampleCreateEventTypeWorkflowDto.trigger.type);
expect(responseBody.data.steps).toHaveLength(sampleCreateEventTypeWorkflowDto.steps.length);
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.id).toBeDefined();
@@ -260,7 +503,7 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
"+37255555555"
);
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
const trigger = sampleCreateWorkflowDto.trigger as OnBeforeEventTriggerDto;
const trigger = sampleCreateEventTypeWorkflowDto.trigger as OnBeforeEventTriggerDto;
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
@@ -269,12 +512,23 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
});
});
it("should not create a new workflow", async () => {
return request(app.getHttpServer())
.post(basePath)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
...sampleCreateEventTypeWorkflowDto,
trigger: { ...sampleCreateEventTypeWorkflowDto, type: "formSubmitted" },
})
.expect(400);
});
it("should return 401 if not authenticated", async () => {
return request(app.getHttpServer()).post(basePath).send(sampleCreateWorkflowDto).expect(401);
return request(app.getHttpServer()).post(basePath).send(sampleCreateEventTypeWorkflowDto).expect(401);
});
it("should return 400 for invalid data (e.g. missing name)", async () => {
const invalidDto = { ...sampleCreateWorkflowDto, name: undefined };
const invalidDto = { ...sampleCreateEventTypeWorkflowDto, name: undefined };
return request(app.getHttpServer())
.post(basePath)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
@@ -284,17 +538,33 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
});
describe(`GET ${basePath}`, () => {
it("should get a list of workflows for the team", async () => {
it("should get a list of event-type workflows for the team", async () => {
return request(app.getHttpServer())
.get(`${basePath}?skip=0&take=10`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetWorkflowsOutput = response.body;
const responseBody: GetEventTypeWorkflowsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeInstanceOf(Array);
expect(responseBody.data.length).toBeGreaterThanOrEqual(1);
expect(responseBody.data.some((wf) => wf.id === createdWorkflowId)).toBe(true);
expect(responseBody.data.every((wf) => wf.type === "event-type")).toBe(true);
});
});
it("should get a list of routing-form workflows for the team", async () => {
return request(app.getHttpServer())
.get(`${basePath}/routing-form?skip=0&take=10`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetRoutingFormWorkflowsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeInstanceOf(Array);
expect(responseBody.data.length).toBeGreaterThanOrEqual(1);
expect(responseBody.data.some((wf) => wf.id === createdFormWorkflowId)).toBe(true);
expect(responseBody.data.every((wf) => wf.type === "routing-form")).toBe(true);
});
});
@@ -311,10 +581,26 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetWorkflowOutput = response.body;
const responseBody: GetEventTypeWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toEqual(createdWorkflowId);
expect(responseBody.data.type).toEqual("event-type");
});
});
it("should get a specific routing-form workflow by ID", async () => {
expect(createdWorkflowId).toBeDefined();
return request(app.getHttpServer())
.get(`${basePath}/${createdFormWorkflowId}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetEventTypeWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toEqual(createdFormWorkflowId);
expect(responseBody.data.type).toEqual("routing-form");
});
});
@@ -334,10 +620,12 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
describe(`PATCH ${basePath}/:workflowId`, () => {
const updatedName = `Updated Workflow Name ${randomString()}`;
it("should update an existing workflow, update the first step and discard other steps", async () => {
const step1 = createdWorkflow.steps.find((step) => step.stepNumber === 1);
expect(step1).toBeDefined();
const partialUpdateDto: Partial<CreateWorkflowDto> = {
it("should update an existing workflow, update the first and second step and discard other steps", async () => {
const step2 = createdWorkflow.steps.find((step) => step.stepNumber === 2);
expect(step2).toBeDefined();
const step3 = createdWorkflow.steps.find((step) => step.stepNumber === 3);
expect(step3).toBeDefined();
const partialUpdateDto: Partial<CreateEventTypeWorkflowDto> = {
name: updatedName,
trigger: {
type: "afterEvent",
@@ -346,7 +634,38 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
value: 10,
},
},
steps: step1 ? [{ ...step1, sender: "updatedSender" } as WorkflowEmailAttendeeStepDto] : [],
steps:
step3 && step2
? [
{
stepNumber: 1,
id: step3.id,
action: "email_address",
recipient: EMAIL,
template: REMINDER,
verifiedEmailId: verifiedEmailId2,
sender: "updatedSender",
includeCalendarEvent: false,
message: {
subject: "Update Upcoming: {EVENT_NAME}",
html: "<p>Update Reminder for your event {EVENT_NAME}.</p>",
},
} as UpdateEmailAddressWorkflowStepDto,
{
stepNumber: 2,
id: step2.id,
action: "whatsapp_number",
recipient: PHONE_NUMBER,
template: REMINDER,
verifiedPhoneId: verifiedPhoneId2,
sender: "updatedSender",
message: {
subject: "Update Upcoming: {EVENT_NAME}",
text: "Update Reminder for your event {EVENT_NAME}.",
},
} as UpdatePhoneWhatsAppNumberWorkflowStepDto,
]
: [],
};
expect(createdWorkflowId).toBeDefined();
expect(createdWorkflow).toBeDefined();
@@ -356,14 +675,32 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
.send(partialUpdateDto)
.expect(200)
.then((response) => {
const responseBody: GetWorkflowOutput = response.body;
const responseBody: GetEventTypeWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toEqual(createdWorkflowId);
expect(responseBody.data.name).toEqual(updatedName);
step1 && expect(responseBody.data.steps[0].id).toEqual(step1.id);
expect(responseBody.data.steps[0].sender).toEqual("updatedSender");
expect(responseBody.data.steps[1]?.id).toBeUndefined();
if (step3) {
const newStep3 = responseBody.data.steps.find((step) => step.id === step3.id);
expect(newStep3).toBeDefined();
if (newStep3) {
expect(newStep3.sender).toEqual("updatedSender");
expect(newStep3.email).toEqual(emailToVerify);
expect(newStep3.includeCalendarEvent).toEqual(false);
}
}
if (step2) {
const newStep2 = responseBody.data.steps.find((step) => step.id === step2.id);
expect(newStep2).toBeDefined();
if (newStep2) {
expect(responseBody.data.steps[1].sender).toEqual("updatedSender");
expect(responseBody.data.steps[1].phone).toEqual(phoneToVerify);
}
}
// we updated 2 steps, third one should have been discarded
expect(responseBody.data.steps[2]?.id).toBeUndefined();
const trigger = partialUpdateDto.trigger as OnAfterEventTriggerDto;
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
@@ -371,8 +708,107 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
});
});
it("should not update an existing event-type workflow, trying to use form workflow trigger", async () => {
const step2 = createdWorkflow.steps.find((step) => step.stepNumber === 2);
expect(step2).toBeDefined();
const step3 = createdWorkflow.steps.find((step) => step.stepNumber === 3);
expect(step3).toBeDefined();
const partialUpdateDto = {
name: updatedName,
trigger: {
type: "formSubmitted",
offset: {
unit: "minute",
value: 10,
},
},
};
expect(createdWorkflowId).toBeDefined();
expect(createdWorkflow).toBeDefined();
return request(app.getHttpServer())
.patch(`${basePath}/${createdWorkflowId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(partialUpdateDto)
.expect(400);
});
it("should not update an existing event-type workflow, trying to use routing-form workflow endpoint", async () => {
const partialUpdateDto = {
name: updatedName,
};
expect(createdWorkflowId).toBeDefined();
expect(createdWorkflow).toBeDefined();
return request(app.getHttpServer())
.patch(`${basePath}/${createdWorkflowId}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(partialUpdateDto)
.expect(404);
});
it("should update an existing routing form workflow, update the first step and discard any other steps", async () => {
const step1 = createdFormWorkflow.steps.find((step) => step.stepNumber === 1);
expect(step1).toBeDefined();
const partialUpdateDto: Partial<CreateFormWorkflowDto> = {
name: updatedName,
trigger: {
type: "formSubmitted",
},
steps: step1
? [
{
stepNumber: 1,
id: step1.id,
action: "email_address",
recipient: EMAIL,
template: REMINDER,
verifiedEmailId: verifiedEmailId2,
sender: "updatedSender",
includeCalendarEvent: true,
message: {
subject: "Update Upcoming: {EVENT_NAME}",
html: "<p>Update Reminder for your event {EVENT_NAME}.</p>",
},
} as UpdateEmailAddressWorkflowStepDto,
]
: [],
};
expect(createdFormWorkflowId).toBeDefined();
expect(createdFormWorkflow).toBeDefined();
return request(app.getHttpServer())
.patch(`${basePath}/${createdFormWorkflowId}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(partialUpdateDto)
.expect(200)
.then((response) => {
const responseBody: GetRoutingFormWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toEqual(createdFormWorkflowId);
expect(responseBody.data.name).toEqual(updatedName);
expect(responseBody.data.activation).toBeDefined();
if (step1) {
const newStep1 = responseBody.data.steps.find((step) => step.id === step1.id);
expect(newStep1).toBeDefined();
if (newStep1) {
expect(newStep1.sender).toEqual("updatedSender");
expect(newStep1.email).toEqual(emailToVerify);
expect(newStep1.includeCalendarEvent).toEqual(true);
}
}
// we updated 1 steps, no more steps should be defined
expect(responseBody.data.steps[1]?.id).toBeUndefined();
const trigger = partialUpdateDto.trigger as OnFormSubmittedTriggerDto;
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
expect(responseBody.data.type).toEqual("routing-form");
});
});
it("should return 404 for updating a non-existent workflow ID", async () => {
const partialUpdateDto: Partial<CreateWorkflowDto> = {
const partialUpdateDto: Partial<CreateEventTypeWorkflowDto> = {
name: updatedName,
steps: [{ ...createdWorkflow.steps[0], sender: "updatedSender" } as WorkflowEmailAttendeeStepDto],
};
@@ -384,7 +820,7 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
});
it("should return 401 if not authenticated", async () => {
const partialUpdateDto: Partial<CreateWorkflowDto> = {
const partialUpdateDto: Partial<CreateEventTypeWorkflowDto> = {
name: updatedName,
steps: [{ ...createdWorkflow.steps[0], sender: "updatedSender" } as WorkflowEmailAttendeeStepDto],
};
@@ -403,11 +839,11 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
const res = await request(app.getHttpServer())
.post(basePath)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ ...sampleCreateWorkflowDto, name: `Workflow To Delete ${randomString()}` });
.send({ ...sampleCreateEventTypeWorkflowDto, name: `Workflow To Delete ${randomString()}` });
workflowToDeleteId = res.body.data.id;
});
it("should delete an existing workflow", async () => {
it("should delete an existing event-type workflow", async () => {
return request(app.getHttpServer())
.delete(`${basePath}/${workflowToDeleteId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
@@ -417,6 +853,16 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
});
});
it("should delete an existing routing-form workflow", async () => {
return request(app.getHttpServer())
.delete(`${basePath}/${createdFormWorkflowId}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
});
});
it("should return 404 when trying to delete a non-existent workflow ID", async () => {
return request(app.getHttpServer())
.delete(`${basePath}/999999`)
@@ -13,12 +13,23 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { IsWorkflowInTeam } from "@/modules/auth/guards/workflows/is-workflow-in-team";
import { IsEventTypeWorkflowInTeam } from "@/modules/auth/guards/workflows/is-event-type-workflow-in-team";
import { IsRoutingFormWorkflowInTeam } from "@/modules/auth/guards/workflows/is-routing-form-workflow-in-team";
import { UserWithProfile } from "@/modules/users/users.repository";
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
import { UpdateWorkflowDto } from "@/modules/workflows/inputs/update-workflow.input";
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
import { TeamWorkflowsService } from "@/modules/workflows/services/team-workflows.service";
import { CreateEventTypeWorkflowDto } from "@/modules/workflows/inputs/create-event-type-workflow.input";
import { CreateFormWorkflowDto } from "@/modules/workflows/inputs/create-form-workflow";
import { UpdateEventTypeWorkflowDto } from "@/modules/workflows/inputs/update-event-type-workflow.input";
import { UpdateFormWorkflowDto } from "@/modules/workflows/inputs/update-form-workflow.input";
import {
GetEventTypeWorkflowsOutput,
GetEventTypeWorkflowOutput,
} from "@/modules/workflows/outputs/event-type-workflow.output";
import {
GetRoutingFormWorkflowOutput,
GetRoutingFormWorkflowsOutput,
} from "@/modules/workflows/outputs/routing-form-workflow.output";
import { TeamEventTypeWorkflowsService } from "@/modules/workflows/services/team-event-type-workflows.service";
import { TeamRoutingFormWorkflowsService } from "@/modules/workflows/services/team-routing-form-workflows.service";
import {
Controller,
Get,
@@ -46,7 +57,10 @@ import { SkipTakePagination } from "@calcom/platform-types";
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationTeamWorkflowsController {
constructor(private readonly workflowsService: TeamWorkflowsService) {}
constructor(
private readonly eventTypeWorkflowsService: TeamEventTypeWorkflowsService,
private readonly routingFormWorkflowsService: TeamRoutingFormWorkflowsService
) {}
@Get("/")
@ApiOperation({ summary: "Get organization team workflows" })
@@ -56,43 +70,89 @@ export class OrganizationTeamWorkflowsController {
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetWorkflowsOutput> {
): Promise<GetEventTypeWorkflowsOutput> {
const { skip, take } = queryParams;
const workflows = await this.workflowsService.getTeamWorkflows(teamId, skip, take);
const workflows = await this.eventTypeWorkflowsService.getEventTypeTeamWorkflows(teamId, skip, take);
return { data: workflows, status: SUCCESS_STATUS };
}
@Get("/routing-form")
@ApiOperation({ summary: "Get organization team workflows" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async getRoutingFormWorkflows(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetRoutingFormWorkflowsOutput> {
const { skip, take } = queryParams;
const workflows = await this.routingFormWorkflowsService.getRoutingFormTeamWorkflows(teamId, skip, take);
return { data: workflows, status: SUCCESS_STATUS };
}
@Get("/:workflowId")
@UseGuards(IsWorkflowInTeam)
@UseGuards(IsEventTypeWorkflowInTeam)
@ApiOperation({ summary: "Get organization team workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async getWorkflowById(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("workflowId", ParseIntPipe) workflowId: number
): Promise<GetWorkflowOutput> {
const workflow = await this.workflowsService.getTeamWorkflowById(teamId, workflowId);
): Promise<GetEventTypeWorkflowOutput> {
const workflow = await this.eventTypeWorkflowsService.getEventTypeTeamWorkflowById(teamId, workflowId);
return { data: workflow, status: SUCCESS_STATUS };
}
@Get("/:workflowId/routing-form")
@UseGuards(IsRoutingFormWorkflowInTeam)
@ApiOperation({ summary: "Get organization team workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async getRoutingFormWorkflowById(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("workflowId", ParseIntPipe) workflowId: number
): Promise<GetRoutingFormWorkflowOutput> {
const workflow = await this.routingFormWorkflowsService.getRoutingFormTeamWorkflowById(
teamId,
workflowId
);
return { data: workflow, status: SUCCESS_STATUS };
}
@Post("/")
@ApiOperation({ summary: "Create organization team workflow" })
@ApiOperation({ summary: "Create organization team workflow for event-types" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async createWorkflow(
async createEventTypeWorkflow(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() data: CreateWorkflowDto
): Promise<GetWorkflowOutput> {
const workflow = await this.workflowsService.createTeamWorkflow(user, teamId, data);
@Body() data: CreateEventTypeWorkflowDto
): Promise<GetEventTypeWorkflowOutput> {
const workflow = await this.eventTypeWorkflowsService.createEventTypeTeamWorkflow(user, teamId, data);
return { data: workflow, status: SUCCESS_STATUS };
}
@Post("/routing-form")
@ApiOperation({ summary: "Create organization team workflow for routing-forms" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async createFormWorkflow(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() data: CreateFormWorkflowDto
): Promise<GetRoutingFormWorkflowOutput> {
const workflow = await this.routingFormWorkflowsService.createFormTeamWorkflow(user, teamId, data);
return { data: workflow, status: SUCCESS_STATUS };
}
@Patch("/:workflowId")
@UseGuards(IsWorkflowInTeam)
@UseGuards(IsEventTypeWorkflowInTeam)
@ApiOperation({ summary: "Update organization team workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
@@ -100,14 +160,39 @@ export class OrganizationTeamWorkflowsController {
@Param("teamId", ParseIntPipe) teamId: number,
@Param("workflowId", ParseIntPipe) workflowId: number,
@GetUser() user: UserWithProfile,
@Body() data: UpdateWorkflowDto
): Promise<GetWorkflowOutput> {
const workflow = await this.workflowsService.updateTeamWorkflow(user, teamId, workflowId, data);
@Body() data: UpdateEventTypeWorkflowDto
): Promise<GetEventTypeWorkflowOutput> {
const workflow = await this.eventTypeWorkflowsService.updateEventTypeTeamWorkflow(
user,
teamId,
workflowId,
data
);
return { data: workflow, status: SUCCESS_STATUS };
}
@Patch("/:workflowId/routing-form")
@UseGuards(IsRoutingFormWorkflowInTeam)
@ApiOperation({ summary: "Update organization routing form team workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async updateRoutingFormWorkflow(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("workflowId", ParseIntPipe) workflowId: number,
@GetUser() user: UserWithProfile,
@Body() data: UpdateFormWorkflowDto
): Promise<GetRoutingFormWorkflowOutput> {
const workflow = await this.routingFormWorkflowsService.updateFormTeamWorkflow(
user,
teamId,
workflowId,
data
);
return { data: workflow, status: SUCCESS_STATUS };
}
@Delete("/:workflowId")
@UseGuards(IsWorkflowInTeam)
@UseGuards(IsEventTypeWorkflowInTeam)
@ApiOperation({ summary: "Delete organization team workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
@@ -115,7 +200,20 @@ export class OrganizationTeamWorkflowsController {
@Param("teamId", ParseIntPipe) teamId: number,
@Param("workflowId") workflowId: number
): Promise<{ status: typeof SUCCESS_STATUS }> {
await this.workflowsService.deleteTeamWorkflow(teamId, workflowId);
await this.eventTypeWorkflowsService.deleteTeamEventTypeWorkflow(teamId, workflowId);
return { status: SUCCESS_STATUS };
}
@Delete("/:workflowId/routing-form")
@UseGuards(IsRoutingFormWorkflowInTeam)
@ApiOperation({ summary: "Delete organization team routing-form workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async deleteRoutingFormWorkflow(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("workflowId") workflowId: number
): Promise<{ status: typeof SUCCESS_STATUS }> {
await this.routingFormWorkflowsService.deleteTeamRoutingFormWorkflow(teamId, workflowId);
return { status: SUCCESS_STATUS };
}
}
@@ -17,6 +17,7 @@ import {
EMAIL_HOST,
SMS_ATTENDEE,
SMS_NUMBER,
STEP_ACTIONS,
WHATSAPP_ATTENDEE,
WHATSAPP_NUMBER,
WorkflowEmailAddressStepDto,
@@ -31,7 +32,7 @@ import {
AFTER_EVENT,
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
BaseWorkflowTriggerDto,
EventTypeWorkflowTriggerDto,
BEFORE_EVENT,
BOOKING_NO_SHOW_UPDATED,
BOOKING_PAID,
@@ -39,6 +40,7 @@ import {
BOOKING_REJECTED,
BOOKING_REQUESTED,
EVENT_CANCELLED,
EVENT_TYPE_WORKFLOW_TRIGGER_TYPES,
NEW_EVENT,
OnAfterCalVideoGuestsNoShowTriggerDto,
OnAfterCalVideoHostsNoShowTriggerDto,
@@ -70,26 +72,12 @@ export class WorkflowActivationDto {
example: [698191],
type: [Number],
})
@ValidateIf((o) => !Boolean(o.isActiveOnAllEventTypes))
@ValidateIf((o) => !o.isActiveOnAllEventTypes)
@IsOptional()
@IsNumber({}, { each: true })
activeOnEventTypeIds: number[] = [];
}
export type TriggerDtoType =
| OnAfterEventTriggerDto
| OnBeforeEventTriggerDto
| OnCreationTriggerDto
| OnRescheduleTriggerDto
| OnCancelTriggerDto
| OnAfterCalVideoGuestsNoShowTriggerDto
| OnRejectedTriggerDto
| OnRequestedTriggerDto
| OnPaymentInitiatedTriggerDto
| OnPaidTriggerDto
| OnNoShowUpdateTriggerDto
| OnAfterCalVideoHostsNoShowTriggerDto;
@ApiExtraModels(
OnBeforeEventTriggerDto,
OnAfterEventTriggerDto,
@@ -110,20 +98,24 @@ export type TriggerDtoType =
WorkflowPhoneWhatsAppNumberStepDto,
WorkflowPhoneNumberStepDto,
WorkflowPhoneAttendeeStepDto,
BaseWorkflowTriggerDto
EventTypeWorkflowTriggerDto,
WorkflowActivationDto
)
export class CreateWorkflowDto {
export class CreateEventTypeWorkflowDto {
@ApiProperty({ description: "Name of the workflow", example: "Platform Test Workflow" })
@IsString()
name!: string;
@ApiProperty({ description: "Activation settings for the workflow", type: WorkflowActivationDto })
@ApiProperty({
description: "Activation settings for the workflow",
type: WorkflowActivationDto,
})
@ValidateNested()
@Type(() => WorkflowActivationDto)
activation!: WorkflowActivationDto;
@ApiProperty({
description: "Trigger configuration for the workflow",
description: `Trigger configuration for the event-type workflow, allowed triggers are ${EVENT_TYPE_WORKFLOW_TRIGGER_TYPES.toString()}`,
oneOf: [
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
@@ -140,7 +132,8 @@ export class CreateWorkflowDto {
],
})
@ValidateNested()
@Type(() => BaseWorkflowTriggerDto, {
@Type(() => EventTypeWorkflowTriggerDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "type",
subTypes: [
@@ -170,11 +163,10 @@ export class CreateWorkflowDto {
| OnPaidTriggerDto
| OnPaymentInitiatedTriggerDto
| OnNoShowUpdateTriggerDto
| OnAfterCalVideoGuestsNoShowTriggerDto
| OnAfterCalVideoHostsNoShowTriggerDto;
| OnAfterCalVideoGuestsNoShowTriggerDto;
@ApiProperty({
description: "Steps to execute as part of the workflow",
description: `Steps to execute as part of the event-type workflow, allowed steps are ${STEP_ACTIONS.toString()}`,
oneOf: [
{ $ref: getSchemaPath(WorkflowEmailAddressStepDto) },
{ $ref: getSchemaPath(WorkflowEmailAttendeeStepDto) },
@@ -187,8 +179,11 @@ export class CreateWorkflowDto {
type: "array",
})
@ValidateNested({ each: true })
@ArrayMinSize(1, { message: "Your workflow must contain at least one step." })
@ArrayMinSize(1, {
message: `Your workflow must contain at least one allowed step. allowed steps are ${STEP_ACTIONS.toString()}`,
})
@Type(() => BaseWorkflowStepDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "action",
subTypes: [
@@ -0,0 +1,97 @@
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsBoolean, ArrayMinSize, IsOptional, IsString, ValidateNested, ValidateIf } from "class-validator";
import {
BaseFormWorkflowStepDto,
EMAIL_ADDRESS,
EMAIL_ATTENDEE,
FORM_ALLOWED_STEP_ACTIONS,
WorkflowEmailAddressStepDto,
WorkflowEmailAttendeeStepDto,
} from "./workflow-step.input";
import {
RoutingFormWorkflowTriggerDto,
FORM_SUBMITTED,
FORM_WORKFLOW_TRIGGER_TYPES,
OnFormSubmittedTriggerDto,
} from "./workflow-trigger.input";
export class WorkflowFormActivationDto {
@ApiProperty({
description: "Whether the workflow is active for all the routing forms",
example: false,
type: Boolean,
})
@IsBoolean()
isActiveOnAllRoutingForms = false;
@ApiPropertyOptional({
description: "List of routing form IDs the workflow applies to",
example: ["abd1-123edf-a213d-123dfwf"],
type: [Number],
})
@ValidateIf((o) => !o.isActiveOnAllEventTypes)
@IsOptional()
@IsString({ each: true })
activeOnRoutingFormIds: string[] = [];
}
@ApiExtraModels(
OnFormSubmittedTriggerDto,
WorkflowEmailAddressStepDto,
WorkflowEmailAttendeeStepDto,
RoutingFormWorkflowTriggerDto,
WorkflowFormActivationDto
)
export class CreateFormWorkflowDto {
@ApiProperty({ description: "Name of the workflow", example: "Platform Test Workflow" })
@IsString()
name!: string;
@ApiProperty({
description: "Activation settings for the workflow",
type: WorkflowFormActivationDto,
})
@ValidateNested()
@Type(() => WorkflowFormActivationDto)
activation!: WorkflowFormActivationDto;
@ApiProperty({
description: `Trigger configuration for the routing-form workflow, allowed triggers are ${FORM_WORKFLOW_TRIGGER_TYPES.toString()}`,
oneOf: [{ $ref: getSchemaPath(OnFormSubmittedTriggerDto) }],
})
@ValidateNested()
@Type(() => RoutingFormWorkflowTriggerDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "type",
subTypes: [{ value: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED }],
},
})
trigger!: OnFormSubmittedTriggerDto;
@ApiProperty({
description: `Steps to execute as part of the routing-form workflow, allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`,
oneOf: [
{ $ref: getSchemaPath(WorkflowEmailAddressStepDto) },
{ $ref: getSchemaPath(WorkflowEmailAttendeeStepDto) },
],
type: "array",
})
@ValidateNested({ each: true })
@ArrayMinSize(1, {
message: `Your workflow must contain at least one allowed step. allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`,
})
@Type(() => BaseFormWorkflowStepDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "action",
subTypes: [
{ value: WorkflowEmailAddressStepDto, name: EMAIL_ADDRESS },
{ value: WorkflowEmailAttendeeStepDto, name: EMAIL_ATTENDEE },
],
},
})
steps!: (WorkflowEmailAddressStepDto | WorkflowEmailAttendeeStepDto)[];
}
@@ -1,16 +1,9 @@
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsNumber, IsString, IsOptional, ValidateNested, ArrayMinSize } from "class-validator";
import { IsString, IsOptional, ValidateNested, ArrayMinSize } from "class-validator";
import { WorkflowActivationDto } from "./create-workflow.input";
import { WorkflowActivationDto } from "./create-event-type-workflow.input";
import {
WorkflowEmailAttendeeStepDto,
WorkflowEmailAddressStepDto,
WorkflowEmailHostStepDto,
WorkflowPhoneWhatsAppNumberStepDto,
WorkflowPhoneAttendeeStepDto,
WorkflowPhoneNumberStepDto,
WorkflowPhoneWhatsAppAttendeeStepDto,
BaseWorkflowStepDto,
EMAIL_ADDRESS,
EMAIL_ATTENDEE,
@@ -19,9 +12,17 @@ import {
WHATSAPP_NUMBER,
SMS_NUMBER,
SMS_ATTENDEE,
UpdateEmailAddressWorkflowStepDto,
UpdateEmailAttendeeWorkflowStepDto,
UpdateEmailHostWorkflowStepDto,
UpdatePhoneAttendeeWorkflowStepDto,
UpdatePhoneNumberWorkflowStepDto,
UpdatePhoneWhatsAppNumberWorkflowStepDto,
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
STEP_ACTIONS,
} from "./workflow-step.input";
import {
BaseWorkflowTriggerDto,
EventTypeWorkflowTriggerDto,
OnBeforeEventTriggerDto,
BEFORE_EVENT,
OnAfterEventTriggerDto,
@@ -46,83 +47,9 @@ import {
BOOKING_PAYMENT_INITIATED,
BOOKING_PAID,
BOOKING_NO_SHOW_UPDATED,
EVENT_TYPE_WORKFLOW_TRIGGER_TYPES,
} from "./workflow-trigger.input";
export type UpdateWorkflowStepDto =
| UpdateEmailAttendeeWorkflowStepDto
| UpdateEmailAddressWorkflowStepDto
| UpdateEmailHostWorkflowStepDto
| UpdateWhatsAppAttendeePhoneWorkflowStepDto
| UpdatePhoneWhatsAppNumberWorkflowStepDto
| UpdatePhoneAttendeeWorkflowStepDto
| UpdatePhoneNumberWorkflowStepDto;
export class UpdateEmailAttendeeWorkflowStepDto extends WorkflowEmailAttendeeStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdateEmailAddressWorkflowStepDto extends WorkflowEmailAddressStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdateEmailHostWorkflowStepDto extends WorkflowEmailHostStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdatePhoneWhatsAppNumberWorkflowStepDto extends WorkflowPhoneWhatsAppNumberStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdatePhoneAttendeeWorkflowStepDto extends WorkflowPhoneAttendeeStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdatePhoneNumberWorkflowStepDto extends WorkflowPhoneNumberStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWhatsAppAttendeeStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
@ApiExtraModels(
OnBeforeEventTriggerDto,
OnAfterEventTriggerDto,
@@ -143,16 +70,17 @@ export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWha
UpdatePhoneWhatsAppNumberWorkflowStepDto,
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
UpdatePhoneNumberWorkflowStepDto,
BaseWorkflowTriggerDto
EventTypeWorkflowTriggerDto,
WorkflowActivationDto
)
export class UpdateWorkflowDto {
export class UpdateEventTypeWorkflowDto {
@ApiPropertyOptional({ description: "Name of the workflow", example: "Platform Test Workflow" })
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional({
description: "Activation settings for the workflow, the action that will trigger the workflow.",
@ApiProperty({
description: "Activation settings for the workflow",
type: WorkflowActivationDto,
})
@ValidateNested()
@@ -161,7 +89,7 @@ export class UpdateWorkflowDto {
activation?: WorkflowActivationDto;
@ApiPropertyOptional({
description: "Trigger configuration for the workflow",
description: `Trigger configuration for the event-type workflow, allowed triggers are ${EVENT_TYPE_WORKFLOW_TRIGGER_TYPES.toString()}`,
oneOf: [
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
@@ -179,7 +107,8 @@ export class UpdateWorkflowDto {
})
@IsOptional()
@ValidateNested()
@Type(() => BaseWorkflowTriggerDto, {
@Type(() => EventTypeWorkflowTriggerDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "type",
subTypes: [
@@ -213,7 +142,7 @@ export class UpdateWorkflowDto {
| OnAfterCalVideoHostsNoShowTriggerDto;
@ApiPropertyOptional({
description: "Steps to execute as part of the workflow",
description: `Steps to execute as part of the event-type workflow, allowed steps are ${STEP_ACTIONS.toString()}`,
oneOf: [
{ $ref: getSchemaPath(UpdateEmailAddressWorkflowStepDto) },
{ $ref: getSchemaPath(UpdateEmailAttendeeWorkflowStepDto) },
@@ -226,9 +155,12 @@ export class UpdateWorkflowDto {
type: "array",
})
@ValidateNested({ each: true })
@ArrayMinSize(1, { message: "Your workflow must contain at least one step." })
@ArrayMinSize(1, {
message: `Your workflow must contain at least one allowed step. allowed steps are ${STEP_ACTIONS.toString()}`,
})
@IsOptional()
@Type(() => BaseWorkflowStepDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "action",
subTypes: [
@@ -0,0 +1,88 @@
import { WorkflowFormActivationDto } from "@/modules/workflows/inputs/create-form-workflow";
import { ApiExtraModels, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsString, IsOptional, ValidateNested, ArrayMinSize } from "class-validator";
import {
BaseFormWorkflowStepDto,
EMAIL_ADDRESS,
EMAIL_ATTENDEE,
FORM_ALLOWED_STEP_ACTIONS,
UpdateEmailAddressWorkflowStepDto,
UpdateEmailAttendeeWorkflowStepDto,
UpdateEmailHostWorkflowStepDto,
UpdatePhoneAttendeeWorkflowStepDto,
UpdatePhoneNumberWorkflowStepDto,
UpdatePhoneWhatsAppNumberWorkflowStepDto,
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
} from "./workflow-step.input";
import {
OnFormSubmittedTriggerDto,
FORM_SUBMITTED,
FORM_WORKFLOW_TRIGGER_TYPES,
RoutingFormWorkflowTriggerDto,
} from "./workflow-trigger.input";
@ApiExtraModels(
OnFormSubmittedTriggerDto,
UpdateEmailAddressWorkflowStepDto,
UpdateEmailAttendeeWorkflowStepDto,
UpdateEmailHostWorkflowStepDto,
UpdatePhoneAttendeeWorkflowStepDto,
UpdatePhoneWhatsAppNumberWorkflowStepDto,
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
UpdatePhoneNumberWorkflowStepDto,
RoutingFormWorkflowTriggerDto,
WorkflowFormActivationDto
)
export class UpdateFormWorkflowDto {
@ApiPropertyOptional({ description: "Name of the workflow", example: "Rounting-form Test Workflow" })
@IsString()
@IsOptional()
name?: string;
@ValidateNested()
@Type(() => WorkflowFormActivationDto)
@IsOptional()
activation?: WorkflowFormActivationDto;
@ApiPropertyOptional({
description: `Trigger configuration for the routing-form workflow, allowed triggers are ${FORM_WORKFLOW_TRIGGER_TYPES}`,
oneOf: [{ $ref: getSchemaPath(OnFormSubmittedTriggerDto) }],
})
@IsOptional()
@ValidateNested()
@Type(() => RoutingFormWorkflowTriggerDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "type",
subTypes: [{ value: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED }],
},
})
trigger?: OnFormSubmittedTriggerDto;
@ApiPropertyOptional({
description: `Steps to execute as part of the routing-form workflow, allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`,
oneOf: [
{ $ref: getSchemaPath(UpdateEmailAddressWorkflowStepDto) },
{ $ref: getSchemaPath(UpdateEmailAttendeeWorkflowStepDto) },
],
type: "array",
})
@ValidateNested({ each: true })
@ArrayMinSize(1, {
message: `Your workflow must contain at least one allowed step. allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`,
})
@IsOptional()
@Type(() => BaseFormWorkflowStepDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "action",
subTypes: [
{ value: UpdateEmailAddressWorkflowStepDto, name: EMAIL_ADDRESS },
{ value: UpdateEmailAttendeeWorkflowStepDto, name: EMAIL_ATTENDEE },
],
},
})
steps?: (UpdateEmailAddressWorkflowStepDto | UpdateEmailAttendeeWorkflowStepDto)[];
}
@@ -1,6 +1,6 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsNumber, IsBoolean, IsString, ValidateNested, IsIn } from "class-validator";
import { IsNumber, IsBoolean, IsString, ValidateNested, IsIn, IsOptional } from "class-validator";
import { WorkflowActions, WorkflowTemplates } from "@calcom/platform-libraries";
@@ -24,6 +24,8 @@ export const STEP_ACTIONS = [
CAL_AI_PHONE_CALL,
] as const;
export const FORM_ALLOWED_STEP_ACTIONS = [EMAIL_ATTENDEE, EMAIL_ADDRESS] as const;
export const STEP_ACTIONS_TO_ENUM = {
[EMAIL_HOST]: WorkflowActions.EMAIL_HOST,
[EMAIL_ATTENDEE]: WorkflowActions.EMAIL_ATTENDEE,
@@ -47,7 +49,7 @@ export const ENUM_TO_STEP_ACTIONS = {
} as const;
export type StepAction = (typeof STEP_ACTIONS)[number];
export type StepActionsType = (typeof STEP_ACTIONS)[number];
export type FormAllowedStepAction = (typeof FORM_ALLOWED_STEP_ACTIONS)[number];
export const REMINDER = "reminder";
export const CUSTOM = "custom";
@@ -134,6 +136,13 @@ export class BaseWorkflowStepDto {
sender!: string;
}
export class BaseFormWorkflowStepDto extends BaseWorkflowStepDto {
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: STEP_ACTIONS })
@IsString()
@IsIn(FORM_ALLOWED_STEP_ACTIONS)
action!: FormAllowedStepAction;
}
export class WorkflowEmailHostStepDto extends BaseWorkflowStepDto {
@ApiProperty({
description: "Action to perform, send an email to the host of the event",
@@ -260,6 +269,15 @@ export class WorkflowPhoneAttendeeStepDto extends BaseWorkflowStepDto {
@ValidateNested()
@Type(() => TextWorkflowMessageDto)
message!: TextWorkflowMessageDto;
@ApiPropertyOptional({
description: "whether or not the attendees are required to provide their phone numbers when booking",
example: true,
default: false,
})
@IsBoolean()
@IsOptional()
phoneRequired: boolean = false;
}
export class WorkflowPhoneNumberStepDto extends BaseWorkflowStepDto {
@@ -302,4 +320,88 @@ export class WorkflowPhoneWhatsAppAttendeeStepDto extends BaseWorkflowStepDto {
@ValidateNested()
@Type(() => TextWorkflowMessageDto)
message!: TextWorkflowMessageDto;
@ApiPropertyOptional({
description: "whether or not the attendees are required to provide their phone numbers when booking",
example: true,
default: false,
})
@IsBoolean()
@IsOptional()
phoneRequired: boolean = false;
}
export type UpdateWorkflowStepDto =
| UpdateEmailAttendeeWorkflowStepDto
| UpdateEmailAddressWorkflowStepDto
| UpdateEmailHostWorkflowStepDto
| UpdateWhatsAppAttendeePhoneWorkflowStepDto
| UpdatePhoneWhatsAppNumberWorkflowStepDto
| UpdatePhoneAttendeeWorkflowStepDto
| UpdatePhoneNumberWorkflowStepDto;
export class UpdateEmailAttendeeWorkflowStepDto extends WorkflowEmailAttendeeStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdateEmailAddressWorkflowStepDto extends WorkflowEmailAddressStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdateEmailHostWorkflowStepDto extends WorkflowEmailHostStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdatePhoneWhatsAppNumberWorkflowStepDto extends WorkflowPhoneWhatsAppNumberStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdatePhoneAttendeeWorkflowStepDto extends WorkflowPhoneAttendeeStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdatePhoneNumberWorkflowStepDto extends WorkflowPhoneNumberStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWhatsAppAttendeeStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
@@ -11,11 +11,30 @@ export const AFTER_EVENT = "afterEvent";
export const RESCHEDULE_EVENT = "rescheduleEvent";
export const AFTER_HOSTS_CAL_VIDEO_NO_SHOW = "afterHostsCalVideoNoShow";
export const AFTER_GUESTS_CAL_VIDEO_NO_SHOW = "afterGuestsCalVideoNoShow";
export const FORM_SUBMITTED = "formSubmitted";
export const BOOKING_REJECTED = "bookingRejected";
export const BOOKING_REQUESTED = "bookingRequested";
export const BOOKING_PAYMENT_INITIATED = "bookingPaymentInitiated";
export const BOOKING_PAID = "bookingPaid";
export const BOOKING_NO_SHOW_UPDATED = "bookingNoShowUpdated";
export const FORM_WORKFLOW_TRIGGER_TYPES = [FORM_SUBMITTED] as const;
export const EVENT_TYPE_WORKFLOW_TRIGGER_TYPES = [
BEFORE_EVENT,
EVENT_CANCELLED,
NEW_EVENT,
AFTER_EVENT,
RESCHEDULE_EVENT,
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
BOOKING_REJECTED,
BOOKING_REQUESTED,
BOOKING_PAYMENT_INITIATED,
BOOKING_PAID,
BOOKING_NO_SHOW_UPDATED,
] as const;
export const WORKFLOW_TRIGGER_TYPES = [
BEFORE_EVENT,
EVENT_CANCELLED,
@@ -24,6 +43,7 @@ export const WORKFLOW_TRIGGER_TYPES = [
RESCHEDULE_EVENT,
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
FORM_SUBMITTED,
BOOKING_REJECTED,
BOOKING_REQUESTED,
BOOKING_PAYMENT_INITIATED,
@@ -39,6 +59,7 @@ export const WORKFLOW_TRIGGER_TO_ENUM = {
[RESCHEDULE_EVENT]: WorkflowTriggerEvents.RESCHEDULE_EVENT,
[AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
[AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
[FORM_SUBMITTED]: WorkflowTriggerEvents.FORM_SUBMITTED,
[BOOKING_REJECTED]: WorkflowTriggerEvents.BOOKING_REJECTED,
[BOOKING_REQUESTED]: WorkflowTriggerEvents.BOOKING_REQUESTED,
[BOOKING_PAYMENT_INITIATED]: WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED,
@@ -54,6 +75,7 @@ export const ENUM_TO_WORKFLOW_TRIGGER = {
[WorkflowTriggerEvents.RESCHEDULE_EVENT]: RESCHEDULE_EVENT,
[WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
[WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
[WorkflowTriggerEvents.FORM_SUBMITTED]: FORM_SUBMITTED,
[WorkflowTriggerEvents.BOOKING_REJECTED]: BOOKING_REJECTED,
[WorkflowTriggerEvents.BOOKING_REQUESTED]: BOOKING_REQUESTED,
[WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED]: BOOKING_PAYMENT_INITIATED,
@@ -61,6 +83,10 @@ export const ENUM_TO_WORKFLOW_TRIGGER = {
[WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED]: BOOKING_NO_SHOW_UPDATED,
} as const;
export const ENUM_TO_ROUNTING_FORM_WORKFLOW_TRIGGER = {
[WorkflowTriggerEvents.FORM_SUBMITTED]: FORM_SUBMITTED,
} as const;
export const HOUR = "hour";
export const MINUTE = "minute";
export const DAY = "day";
@@ -82,6 +108,8 @@ export const ENUM_TO_TIME_UNIT = {
} as const;
export type WorkflowTriggerType = (typeof WORKFLOW_TRIGGER_TYPES)[number];
export type WorkflowEventTypeTriggerType = (typeof EVENT_TYPE_WORKFLOW_TRIGGER_TYPES)[number];
export type WorkflowFormTriggerType = (typeof FORM_WORKFLOW_TRIGGER_TYPES)[number];
export class WorkflowTriggerOffsetDto {
@ApiProperty({ description: "Time value for offset before/after event trigger", example: 24, type: Number })
@@ -94,13 +122,24 @@ export class WorkflowTriggerOffsetDto {
unit!: TimeUnitType;
}
export class BaseWorkflowTriggerDto {
export class EventTypeWorkflowTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
description: "Trigger type for the event-type workflow",
example: "beforeEvent",
})
@IsString()
@IsIn([WORKFLOW_TRIGGER_TYPES])
type!: WorkflowTriggerType;
@IsIn(EVENT_TYPE_WORKFLOW_TRIGGER_TYPES)
type!: WorkflowEventTypeTriggerType;
}
export class RoutingFormWorkflowTriggerDto {
@ApiProperty({
description: "Trigger type for the routing-form workflow",
example: "formSubmitted",
})
@IsString()
@IsIn(FORM_WORKFLOW_TRIGGER_TYPES)
type!: WorkflowFormTriggerType;
}
export class OnCreationTriggerDto {
@@ -213,3 +252,12 @@ export class OnAfterCalVideoHostsNoShowTriggerDto extends TriggerOffsetDTO {
@IsIn([AFTER_HOSTS_CAL_VIDEO_NO_SHOW])
type: typeof AFTER_HOSTS_CAL_VIDEO_NO_SHOW = AFTER_HOSTS_CAL_VIDEO_NO_SHOW;
}
export class OnFormSubmittedTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
example: FORM_SUBMITTED,
})
@IsString()
@IsIn([FORM_SUBMITTED])
type: typeof FORM_SUBMITTED = FORM_SUBMITTED;
}
@@ -1,28 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsArray, IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import {
EMAIL_HOST,
HOST,
RECIPIENT_TYPES,
RecipientType,
REMINDER,
STEP_ACTIONS,
StepAction,
TEMPLATES,
TemplateType,
} from "../inputs/workflow-step.input";
import {
BEFORE_EVENT,
HOUR,
TIME_UNITS,
TimeUnitType,
WORKFLOW_TRIGGER_TYPES,
WorkflowTriggerType,
} from "../inputs/workflow-trigger.input";
} from "@/modules/workflows/inputs/workflow-step.input";
import { HOUR, TIME_UNITS, TimeUnitType } from "@/modules/workflows/inputs/workflow-trigger.input";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsBoolean, IsOptional, ValidateNested } from "class-validator";
export class WorkflowMessageOutputDto {
@ApiProperty({
@@ -47,7 +34,7 @@ export class WorkflowMessageOutputDto {
text?: string;
}
export class WorkflowStepOutputDto {
export class BaseWorkflowStepOutputDto {
@ApiProperty({ description: "Unique identifier of the step", example: 67244 })
@Expose()
id!: number;
@@ -56,10 +43,6 @@ export class WorkflowStepOutputDto {
@Expose()
stepNumber!: number;
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: STEP_ACTIONS })
@Expose()
action!: StepAction;
@ApiProperty({ description: "Intended recipient type", example: HOST, enum: RECIPIENT_TYPES })
@Expose()
recipient!: RecipientType;
@@ -74,6 +57,16 @@ export class WorkflowStepOutputDto {
@Expose()
phone?: string;
@ApiPropertyOptional({
description: "whether or not the attendees are required to provide their phone numbers when booking",
example: true,
default: false,
})
@IsBoolean()
@Expose()
@IsOptional()
phoneRequired?: boolean;
@ApiProperty({ description: "Template type used", example: REMINDER, enum: TEMPLATES })
@Expose()
template!: TemplateType;
@@ -110,45 +103,7 @@ export class WorkflowTriggerOffsetOutputDto {
unit!: TimeUnitType;
}
export class WorkflowTriggerOutputDto {
@ApiProperty({
description: "Trigger type for the workflow",
example: BEFORE_EVENT,
enum: WORKFLOW_TRIGGER_TYPES,
})
@Expose()
type!: WorkflowTriggerType;
@ApiPropertyOptional({
description: "Offset details (present for BEFORE_EVENT/AFTER_EVENT)",
type: WorkflowTriggerOffsetOutputDto,
})
@Expose()
@ValidateNested()
@Type(() => WorkflowTriggerOffsetOutputDto)
offset?: WorkflowTriggerOffsetOutputDto;
}
export class WorkflowActivationOutputDto {
@ApiProperty({
description: "Whether the workflow is active for all event types associated with the team/user",
example: false,
})
@Expose()
isActiveOnAllEventTypes?: boolean = false;
@ApiPropertyOptional({
description: "List of Event Type IDs the workflow is specifically active on (if not active on all)",
example: [698191, 698192],
})
@Expose()
@IsArray()
activeOnEventTypeIds?: number[];
}
// --- Main Workflow Output DTO ---
export class WorkflowOutput {
export class BaseWorkflowOutput {
@ApiProperty({ description: "Unique identifier of the workflow", example: 101 })
@Expose()
id!: number;
@@ -168,25 +123,6 @@ export class WorkflowOutput {
@Expose()
teamId?: number;
@ApiProperty({ description: "Activation settings (scope)", type: WorkflowActivationOutputDto })
@Expose()
@ValidateNested()
@Type(() => WorkflowActivationOutputDto)
activation!: WorkflowActivationOutputDto;
@ApiProperty({ description: "Trigger configuration", type: WorkflowTriggerOutputDto })
@Expose()
@ValidateNested()
@Type(() => WorkflowTriggerOutputDto)
trigger!: WorkflowTriggerOutputDto;
@ApiProperty({ description: "Steps comprising the workflow", type: [WorkflowStepOutputDto] })
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WorkflowStepOutputDto)
steps!: WorkflowStepOutputDto[];
@ApiPropertyOptional({ description: "Timestamp of creation", example: "2024-05-12T10:00:00.000Z" })
@Expose()
createdAt?: Date | string;
@@ -195,47 +131,3 @@ export class WorkflowOutput {
@Expose()
updatedAt?: Date | string;
}
// --- List Response Output DTO ---
export class GetWorkflowsOutput {
@ApiProperty({
description: "Indicates the status of the response",
example: SUCCESS_STATUS,
enum: [SUCCESS_STATUS, ERROR_STATUS],
})
@Expose()
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "List of workflows",
type: [WorkflowOutput],
})
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WorkflowOutput)
data!: WorkflowOutput[];
}
export class GetWorkflowOutput {
@ApiProperty({
description: "Indicates the status of the response",
example: SUCCESS_STATUS,
enum: [SUCCESS_STATUS, ERROR_STATUS],
})
@Expose()
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "workflow",
type: [WorkflowOutput],
})
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WorkflowOutput)
data!: WorkflowOutput;
}
@@ -0,0 +1,140 @@
import {
BaseWorkflowStepOutputDto,
WorkflowTriggerOffsetOutputDto,
BaseWorkflowOutput,
} from "@/modules/workflows/outputs/base-workflow.output";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsArray, IsEnum, IsIn, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { EMAIL_HOST, STEP_ACTIONS, StepAction } from "../inputs/workflow-step.input";
import {
BEFORE_EVENT,
EVENT_TYPE_WORKFLOW_TRIGGER_TYPES,
WorkflowEventTypeTriggerType,
} from "../inputs/workflow-trigger.input";
export const WORKFLOW_TYPE_FORM = "routing-form";
export const WORKFLOW_TYPE_EVENT_TYPE = "event-type";
export class EventTypeWorkflowStepOutputDto extends BaseWorkflowStepOutputDto {
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: STEP_ACTIONS })
@Expose()
action!: StepAction;
}
export class EventTypeWorkflowTriggerOutputDto {
@ApiProperty({
description: "Trigger type for the workflow",
example: BEFORE_EVENT,
enum: EVENT_TYPE_WORKFLOW_TRIGGER_TYPES,
})
@Expose()
type!: WorkflowEventTypeTriggerType;
@ApiPropertyOptional({
description: "Offset details (present for BEFORE_EVENT/AFTER_EVENT)",
type: WorkflowTriggerOffsetOutputDto,
})
@Expose()
@ValidateNested()
@Type(() => WorkflowTriggerOffsetOutputDto)
offset?: WorkflowTriggerOffsetOutputDto;
}
export class EventTypeWorkflowActivationOutputDto {
@ApiProperty({
description: "Whether the workflow is active for all event types associated with the team/user",
example: false,
})
@Expose()
isActiveOnAllEventTypes?: boolean = false;
@ApiPropertyOptional({
description: "List of Event Type IDs the workflow is specifically active on (if not active on all)",
example: [698191, 698192],
})
@Expose()
@IsArray()
activeOnEventTypeIds?: number[];
}
// --- Main Workflow Output DTO ---
export class EventTypeWorkflowOutput extends BaseWorkflowOutput {
@ApiProperty({
description: "type of the workflow",
example: WORKFLOW_TYPE_EVENT_TYPE,
default: WORKFLOW_TYPE_EVENT_TYPE,
})
@IsString()
@IsIn([WORKFLOW_TYPE_EVENT_TYPE])
type!: typeof WORKFLOW_TYPE_EVENT_TYPE;
@ApiProperty({
description: "Activation settings for the workflow",
})
@Expose()
@ValidateNested()
@Type(() => EventTypeWorkflowActivationOutputDto)
activation!: EventTypeWorkflowActivationOutputDto;
@ApiProperty({ description: "Trigger configuration", type: EventTypeWorkflowTriggerOutputDto })
@Expose()
@ValidateNested()
@Type(() => EventTypeWorkflowTriggerOutputDto)
trigger!: EventTypeWorkflowTriggerOutputDto;
@ApiProperty({ description: "Steps comprising the workflow", type: [EventTypeWorkflowStepOutputDto] })
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => EventTypeWorkflowStepOutputDto)
steps!: EventTypeWorkflowStepOutputDto[];
}
// --- List Response Output DTO ---
export class GetEventTypeWorkflowsOutput {
@ApiProperty({
description: "Indicates the status of the response",
example: SUCCESS_STATUS,
enum: [SUCCESS_STATUS, ERROR_STATUS],
})
@Expose()
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "List of workflows",
type: [EventTypeWorkflowOutput],
})
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => EventTypeWorkflowOutput)
data!: EventTypeWorkflowOutput[];
}
export class GetEventTypeWorkflowOutput {
@ApiProperty({
description: "Indicates the status of the response",
example: SUCCESS_STATUS,
enum: [SUCCESS_STATUS, ERROR_STATUS],
})
@Expose()
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "workflow",
type: [EventTypeWorkflowOutput],
})
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => EventTypeWorkflowOutput)
data!: EventTypeWorkflowOutput;
}
@@ -0,0 +1,138 @@
import {
BaseWorkflowOutput,
BaseWorkflowStepOutputDto,
WorkflowTriggerOffsetOutputDto,
} from "@/modules/workflows/outputs/base-workflow.output";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsArray, IsEnum, IsIn, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { EMAIL_HOST, FORM_ALLOWED_STEP_ACTIONS, FormAllowedStepAction } from "../inputs/workflow-step.input";
import {
FORM_SUBMITTED,
FORM_WORKFLOW_TRIGGER_TYPES,
WorkflowFormTriggerType,
} from "../inputs/workflow-trigger.input";
export const WORKFLOW_TYPE_FORM = "routing-form";
export const WORKFLOW_TYPE_EVENT_TYPE = "event-type";
export class RoutingFormWorkflowStepOutputDto extends BaseWorkflowStepOutputDto {
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: FORM_ALLOWED_STEP_ACTIONS })
@Expose()
action!: FormAllowedStepAction;
}
export class RoutingFormWorkflowTriggerOutputDto {
@ApiProperty({
description: "Trigger type for the workflow",
example: FORM_SUBMITTED,
enum: FORM_WORKFLOW_TRIGGER_TYPES,
})
@Expose()
type!: WorkflowFormTriggerType;
@ApiPropertyOptional({
description: "Offset details (present for BEFORE_EVENT/AFTER_EVENT)",
type: WorkflowTriggerOffsetOutputDto,
})
@Expose()
@ValidateNested()
@Type(() => WorkflowTriggerOffsetOutputDto)
offset?: WorkflowTriggerOffsetOutputDto;
}
export class RoutingFormWorkflowActivationOutputDto {
@ApiProperty({
description: "Whether the workflow is active for all routing forms associated with the team/user",
example: false,
})
@Expose()
isActiveOnAllRoutingForms?: boolean = false;
@ApiPropertyOptional({
description: "List of Event Type IDs the workflow is specifically active on (if not active on all)",
example: ["5cacdec7-1234-6e1b-78d9-7bcda8a1b332"],
})
@Expose()
@IsArray()
activeOnRoutingFormIds?: string[];
}
// --- Main Workflow Output DTO ---
export class RoutingFormWorkflowOutput extends BaseWorkflowOutput {
@ApiProperty({
description: "type of the workflow",
example: WORKFLOW_TYPE_FORM,
default: WORKFLOW_TYPE_FORM,
})
@IsString()
@IsIn([WORKFLOW_TYPE_FORM])
type!: typeof WORKFLOW_TYPE_FORM;
@ApiProperty({
description: "Activation settings for the workflow",
})
@Expose()
@Type(() => RoutingFormWorkflowActivationOutputDto)
@ValidateNested()
activation!: RoutingFormWorkflowActivationOutputDto;
@ApiProperty({ description: "Trigger configuration", type: RoutingFormWorkflowTriggerOutputDto })
@Expose()
@ValidateNested()
@Type(() => RoutingFormWorkflowTriggerOutputDto)
trigger!: RoutingFormWorkflowTriggerOutputDto;
@ApiProperty({ description: "Steps comprising the workflow", type: [RoutingFormWorkflowStepOutputDto] })
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => RoutingFormWorkflowStepOutputDto)
steps!: RoutingFormWorkflowStepOutputDto[];
}
export class GetRoutingFormWorkflowsOutput {
@ApiProperty({
description: "Indicates the status of the response",
example: SUCCESS_STATUS,
enum: [SUCCESS_STATUS, ERROR_STATUS],
})
@Expose()
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "List of workflows",
type: [RoutingFormWorkflowOutput],
})
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => RoutingFormWorkflowOutput)
data!: RoutingFormWorkflowOutput[];
}
export class GetRoutingFormWorkflowOutput {
@ApiProperty({
description: "Indicates the status of the response",
example: SUCCESS_STATUS,
enum: [SUCCESS_STATUS, ERROR_STATUS],
})
@Expose()
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "workflow",
type: [RoutingFormWorkflowOutput],
})
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => RoutingFormWorkflowOutput)
data!: RoutingFormWorkflowOutput;
}
@@ -0,0 +1,113 @@
import { UserWithProfile } from "@/modules/users/users.repository";
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
import { CreateEventTypeWorkflowDto } from "@/modules/workflows/inputs/create-event-type-workflow.input";
import { UpdateEventTypeWorkflowDto } from "@/modules/workflows/inputs/update-event-type-workflow.input";
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
@Injectable()
export class TeamEventTypeWorkflowsService {
constructor(
private readonly workflowsRepository: WorkflowsRepository,
private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository,
private readonly workflowInputService: WorkflowsInputService,
private readonly workflowOutputService: WorkflowsOutputService
) {}
async getEventTypeTeamWorkflows(teamId: number, skip: number, take: number) {
const workflows = await this.workflowsRepository.getEventTypeTeamWorkflows(teamId, skip, take);
return workflows.map((workflow) => {
const output = this.workflowOutputService.toEventTypeOutputDto(workflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
});
}
async getEventTypeTeamWorkflowById(teamId: number, workflowId: number) {
const workflow = await this.workflowsRepository.getEventTypeTeamWorkflowById(teamId, workflowId);
if (!workflow) {
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
}
const output = this.workflowOutputService.toEventTypeOutputDto(workflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
}
async createEventTypeTeamWorkflow(user: UserWithProfile, teamId: number, data: CreateEventTypeWorkflowDto) {
const workflowHusk = await this.workflowsRepository.createTeamWorkflowHusk(teamId);
const mappedData = await this.workflowInputService.mapEventTypeUpdateDtoToZodSchema(
data,
workflowHusk.id,
teamId,
workflowHusk
);
const createdWorkflow = await this.workflowsRepository.updateEventTypeTeamWorkflow(
user,
teamId,
workflowHusk.id,
mappedData
);
if (!createdWorkflow) {
throw new BadRequestException(`Could not create Workflow in team ${teamId}`);
}
const output = this.workflowOutputService.toEventTypeOutputDto(createdWorkflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
}
async updateEventTypeTeamWorkflow(
user: UserWithProfile,
teamId: number,
workflowId: number,
data: UpdateEventTypeWorkflowDto
) {
const currentWorkflow = await this.workflowsRepository.getEventTypeTeamWorkflowById(teamId, workflowId);
if (!currentWorkflow) {
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
}
const mappedData = await this.workflowInputService.mapEventTypeUpdateDtoToZodSchema(
data,
workflowId,
teamId,
currentWorkflow
);
const updatedWorkflow = await this.workflowsRepository.updateEventTypeTeamWorkflow(
user,
teamId,
workflowId,
mappedData
);
if (!updatedWorkflow) {
throw new BadRequestException(`Could not update Workflow with ID ${workflowId} in team ${teamId}`);
}
const output = this.workflowOutputService.toEventTypeOutputDto(updatedWorkflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
}
async deleteTeamEventTypeWorkflow(teamId: number, workflowId: number) {
return await this.workflowsRepository.deleteTeamWorkflowById(teamId, workflowId);
}
}
@@ -1,14 +1,14 @@
import { UserWithProfile } from "@/modules/users/users.repository";
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
import { UpdateWorkflowDto } from "@/modules/workflows/inputs/update-workflow.input";
import { CreateFormWorkflowDto } from "@/modules/workflows/inputs/create-form-workflow";
import { UpdateFormWorkflowDto } from "@/modules/workflows/inputs/update-form-workflow.input";
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
@Injectable()
export class TeamWorkflowsService {
export class TeamRoutingFormWorkflowsService {
constructor(
private readonly workflowsRepository: WorkflowsRepository,
private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository,
@@ -16,32 +16,44 @@ export class TeamWorkflowsService {
private readonly workflowOutputService: WorkflowsOutputService
) {}
async getTeamWorkflows(teamId: number, skip: number, take: number) {
const workflows = await this.workflowsRepository.getTeamWorkflows(teamId, skip, take);
async getRoutingFormTeamWorkflows(teamId: number, skip: number, take: number) {
const workflows = await this.workflowsRepository.getRoutingFormTeamWorkflows(teamId, skip, take);
return workflows.map((workflow) => this.workflowOutputService.toOutputDto(workflow));
return workflows.map((workflow) => {
const output = this.workflowOutputService.toRoutingFormOutputDto(workflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
});
}
async getTeamWorkflowById(teamId: number, workflowId: number) {
const workflow = await this.workflowsRepository.getTeamWorkflowById(teamId, workflowId);
async getRoutingFormTeamWorkflowById(teamId: number, workflowId: number) {
const workflow = await this.workflowsRepository.getRoutingFormTeamWorkflowById(teamId, workflowId);
if (!workflow) {
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
}
return this.workflowOutputService.toOutputDto(workflow);
const output = this.workflowOutputService.toRoutingFormOutputDto(workflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
}
async createTeamWorkflow(user: UserWithProfile, teamId: number, data: CreateWorkflowDto) {
async createFormTeamWorkflow(user: UserWithProfile, teamId: number, data: CreateFormWorkflowDto) {
const workflowHusk = await this.workflowsRepository.createTeamWorkflowHusk(teamId);
const mappedData = await this.workflowInputService.mapUpdateDtoToZodUpdateSchema(
const mappedData = await this.workflowInputService.mapFormUpdateDtoToZodSchema(
data,
workflowHusk.id,
teamId,
workflowHusk
);
const createdWorkflow = await this.workflowsRepository.updateTeamWorkflow(
const createdWorkflow = await this.workflowsRepository.updateRoutingFormTeamWorkflow(
user,
teamId,
workflowHusk.id,
@@ -51,28 +63,33 @@ export class TeamWorkflowsService {
throw new BadRequestException(`Could not create Workflow in team ${teamId}`);
}
return this.workflowOutputService.toOutputDto(createdWorkflow);
const output = this.workflowOutputService.toRoutingFormOutputDto(createdWorkflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
}
async updateTeamWorkflow(
async updateFormTeamWorkflow(
user: UserWithProfile,
teamId: number,
workflowId: number,
data: UpdateWorkflowDto
data: UpdateFormWorkflowDto
) {
const currentWorkflow = await this.workflowsRepository.getTeamWorkflowById(teamId, workflowId);
const currentWorkflow = await this.workflowsRepository.getRoutingFormTeamWorkflowById(teamId, workflowId);
if (!currentWorkflow) {
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
}
const mappedData = await this.workflowInputService.mapUpdateDtoToZodUpdateSchema(
const mappedData = await this.workflowInputService.mapFormUpdateDtoToZodSchema(
data,
workflowId,
teamId,
currentWorkflow
);
const updatedWorkflow = await this.workflowsRepository.updateTeamWorkflow(
const updatedWorkflow = await this.workflowsRepository.updateRoutingFormTeamWorkflow(
user,
teamId,
workflowId,
@@ -83,10 +100,15 @@ export class TeamWorkflowsService {
throw new BadRequestException(`Could not update Workflow with ID ${workflowId} in team ${teamId}`);
}
return this.workflowOutputService.toOutputDto(updatedWorkflow);
const output = this.workflowOutputService.toRoutingFormOutputDto(updatedWorkflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
}
async deleteTeamWorkflow(teamId: number, workflowId: number) {
async deleteTeamRoutingFormWorkflow(teamId: number, workflowId: number) {
return await this.workflowsRepository.deleteTeamWorkflowById(teamId, workflowId);
}
}
@@ -1,11 +1,6 @@
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
import {
UpdateWorkflowDto,
UpdateWorkflowStepDto,
UpdateEmailAttendeeWorkflowStepDto,
UpdateEmailAddressWorkflowStepDto,
UpdateEmailHostWorkflowStepDto,
} from "@/modules/workflows/inputs/update-workflow.input";
import { UpdateEventTypeWorkflowDto } from "@/modules/workflows/inputs/update-event-type-workflow.input";
import { UpdateFormWorkflowDto } from "@/modules/workflows/inputs/update-form-workflow.input";
import { WorkflowType } from "@/modules/workflows/workflows.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
@@ -21,10 +16,9 @@ import {
STEP_ACTIONS_TO_ENUM,
TemplateType,
TextWorkflowMessageDto,
UpdateWorkflowStepDto,
WHATSAPP_ATTENDEE,
WHATSAPP_NUMBER,
WorkflowPhoneNumberStepDto,
WorkflowPhoneWhatsAppNumberStepDto,
} from "../inputs/workflow-step.input";
import {
OnAfterEventTriggerDto,
@@ -37,6 +31,31 @@ import {
export class WorkflowsInputService {
constructor(private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository) {}
private async _getTeamPhoneNumberFromVerifiedId(teamId: number, verifiedPhoneId: number) {
const phoneResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumberById(
verifiedPhoneId,
teamId
);
if (!phoneResource?.phoneNumber) {
throw new BadRequestException("Invalid Verified Phone Id.");
}
return phoneResource.phoneNumber;
}
private async _getTeamEmailFromVerifiedId(teamId: number, verifiedEmailId: number) {
const emailResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedEmailById(
verifiedEmailId,
teamId
);
if (!emailResource?.email) {
throw new BadRequestException("Invalid Verified Email Id.");
}
return emailResource.email;
}
private async mapUpdateWorkflowStepToZodUpdateSchema(
stepDto: UpdateWorkflowStepDto,
index: number,
@@ -45,61 +64,42 @@ export class WorkflowsInputService {
) {
let reminderBody: string | null = null;
let sendTo: string | null = null;
let phoneRequired: boolean | null = null;
const html = stepDto.message instanceof HtmlWorkflowMessageDto ? stepDto.message.html : null;
const text = stepDto.message instanceof TextWorkflowMessageDto ? stepDto.message.text : null;
const includeCalendarEvent =
stepDto instanceof UpdateEmailAddressWorkflowStepDto ||
stepDto instanceof UpdateEmailAttendeeWorkflowStepDto ||
stepDto instanceof UpdateEmailHostWorkflowStepDto
? stepDto.includeCalendarEvent
: false;
let includeCalendarEvent = false;
switch (stepDto.action) {
case EMAIL_HOST:
case EMAIL_ATTENDEE:
case EMAIL_ADDRESS:
reminderBody = html ?? null;
includeCalendarEvent = stepDto.includeCalendarEvent;
break;
case SMS_ATTENDEE:
phoneRequired = stepDto.phoneRequired ?? false;
break;
case SMS_NUMBER:
break;
case WHATSAPP_ATTENDEE:
phoneRequired = stepDto.phoneRequired ?? false;
break;
case WHATSAPP_NUMBER:
reminderBody = text ?? null;
break;
}
if (stepDto.action === EMAIL_ADDRESS) {
if (stepDto.verifiedEmailId) {
const emailResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedEmailById(
stepDto.verifiedEmailId,
teamId
);
if (!emailResource?.email) {
throw new BadRequestException("Invalid Verified Email Id.");
}
sendTo = emailResource.email;
sendTo = await this._getTeamEmailFromVerifiedId(teamId, stepDto.verifiedEmailId);
}
} else if (stepDto.action === SMS_NUMBER || stepDto.action === WHATSAPP_NUMBER) {
if (
stepDto instanceof WorkflowPhoneNumberStepDto ||
stepDto instanceof WorkflowPhoneWhatsAppNumberStepDto
) {
if (stepDto.verifiedPhoneId) {
const phoneResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumberById(
stepDto.verifiedPhoneId,
teamId
);
if (!phoneResource?.phoneNumber) {
throw new BadRequestException("Invalid Verified Phone Id.");
}
sendTo = phoneResource.phoneNumber;
}
if (stepDto.verifiedPhoneId) {
sendTo = await this._getTeamPhoneNumberFromVerifiedId(teamId, stepDto.verifiedPhoneId);
}
}
const actionForZod = STEP_ACTIONS_TO_ENUM[stepDto.action];
const templateForZod = stepDto.template as unknown as Uppercase<TemplateType>;
const templateForZod = stepDto?.template?.toUpperCase() as unknown as Uppercase<TemplateType>;
return {
id: stepDto.id ?? -(index + 1),
@@ -110,19 +110,19 @@ export class WorkflowsInputService {
reminderBody: reminderBody,
emailSubject: stepDto.message.subject ?? null,
template: templateForZod,
numberRequired: null,
numberRequired: phoneRequired,
sender: stepDto.sender ?? null,
senderName: stepDto.sender ?? null,
includeCalendarEvent: includeCalendarEvent,
};
}
async mapUpdateDtoToZodUpdateSchema(
updateDto: UpdateWorkflowDto,
workflowIdToUse: number,
private async _mapCommonWorkflowProperties(
updateDto: UpdateEventTypeWorkflowDto | UpdateFormWorkflowDto,
currentData: WorkflowType,
teamId: number,
currentData: WorkflowType
): Promise<TUpdateInputSchema> {
workflowIdToUse: number
) {
const mappedSteps = updateDto?.steps
? await Promise.all(
updateDto.steps.map(async (stepDto: UpdateWorkflowStepDto, index: number) =>
@@ -134,28 +134,89 @@ export class WorkflowsInputService {
const triggerForZod = updateDto?.trigger?.type
? WORKFLOW_TRIGGER_TO_ENUM[updateDto?.trigger?.type]
: currentData.trigger;
const timeUnitForZod =
updateDto.trigger instanceof OnBeforeEventTriggerDto ||
updateDto.trigger instanceof OnAfterEventTriggerDto
? updateDto?.trigger?.offset?.unit ?? currentData.timeUnit ?? null
: undefined;
const time =
updateDto.trigger instanceof OnBeforeEventTriggerDto ||
updateDto.trigger instanceof OnAfterEventTriggerDto
? updateDto?.trigger?.offset?.value ?? currentData?.time ?? null
: null;
const timeUnit = timeUnitForZod ? TIME_UNIT_TO_ENUM[timeUnitForZod] : null;
return { mappedSteps, triggerForZod, time, timeUnit };
}
async mapEventTypeUpdateDtoToZodSchema(
updateDto: UpdateEventTypeWorkflowDto,
workflowIdToUse: number,
teamId: number,
currentData: WorkflowType
): Promise<TUpdateInputSchema> {
const { mappedSteps, triggerForZod, time, timeUnit } = await this._mapCommonWorkflowProperties(
updateDto,
currentData,
teamId,
workflowIdToUse
);
const updateData: TUpdateInputSchema = {
id: workflowIdToUse,
name: updateDto.name ?? currentData.name,
activeOn:
steps: mappedSteps,
trigger: triggerForZod,
time: time,
timeUnit: timeUnit,
// Event-type specific logic
activeOnEventTypeIds:
updateDto?.activation?.activeOnEventTypeIds ??
currentData?.activeOn.map((active) => active.eventTypeId) ??
[],
isActiveOnAll: updateDto?.activation?.isActiveOnAllEventTypes ?? currentData.isActiveOnAll ?? false,
// Explicitly set form-related fields to their default/empty state
activeOnRoutingFormIds: [],
} as const satisfies TUpdateInputSchema;
return updateData;
}
async mapFormUpdateDtoToZodSchema(
updateDto: UpdateFormWorkflowDto,
workflowIdToUse: number,
teamId: number,
currentData: WorkflowType
): Promise<TUpdateInputSchema> {
const { mappedSteps, triggerForZod, time, timeUnit } = await this._mapCommonWorkflowProperties(
updateDto,
currentData,
teamId,
workflowIdToUse
);
const updateData: TUpdateInputSchema = {
id: workflowIdToUse,
name: updateDto.name ?? currentData.name,
steps: mappedSteps,
trigger: triggerForZod,
time:
updateDto.trigger instanceof OnBeforeEventTriggerDto ||
updateDto.trigger instanceof OnAfterEventTriggerDto
? updateDto?.trigger?.offset?.value ?? currentData?.time ?? null
: null,
timeUnit: timeUnitForZod ? TIME_UNIT_TO_ENUM[timeUnitForZod] : null,
isActiveOnAll: updateDto?.activation?.isActiveOnAllEventTypes ?? currentData.isActiveOnAll ?? false,
time: time,
timeUnit: timeUnit,
// Form-specific logic
activeOnRoutingFormIds:
updateDto?.activation?.activeOnRoutingFormIds ??
currentData?.activeOnRoutingForms.map((active) => active.routingFormId) ??
[],
isActiveOnAll: updateDto?.activation?.isActiveOnAllRoutingForms ?? currentData.isActiveOnAll ?? false,
// Explicitly set event-type-related fields to their default/empty state
activeOnEventTypeIds: [],
} as const satisfies TUpdateInputSchema;
return updateData;
@@ -1,21 +1,33 @@
import { WorkflowActivationDto, TriggerDtoType } from "@/modules/workflows/inputs/create-workflow.input";
import { WorkflowOutput, WorkflowStepOutputDto } from "@/modules/workflows/outputs/workflow.output";
import { WorkflowActivationDto } from "@/modules/workflows/inputs/create-event-type-workflow.input";
import { WorkflowFormActivationDto } from "@/modules/workflows/inputs/create-form-workflow";
import {
EventTypeWorkflowStepOutputDto,
EventTypeWorkflowOutput,
} from "@/modules/workflows/outputs/event-type-workflow.output";
import {
RoutingFormWorkflowOutput,
RoutingFormWorkflowStepOutputDto,
} from "@/modules/workflows/outputs/routing-form-workflow.output";
import { WorkflowType } from "@/modules/workflows/workflows.repository";
import { Injectable } from "@nestjs/common";
import {
ATTENDEE,
CAL_AI_PHONE_CALL,
EMAIL,
EMAIL_ADDRESS,
EMAIL_ATTENDEE,
EMAIL_HOST,
ENUM_TO_STEP_ACTIONS,
ENUM_TO_TEMPLATES,
FORM_ALLOWED_STEP_ACTIONS,
FormAllowedStepAction,
HOST,
PHONE_NUMBER,
RecipientType,
SMS_ATTENDEE,
SMS_NUMBER,
StepAction,
WHATSAPP_ATTENDEE,
WHATSAPP_NUMBER,
} from "../inputs/workflow-step.input";
@@ -26,94 +38,225 @@ import {
BEFORE_EVENT,
ENUM_TO_TIME_UNIT,
ENUM_TO_WORKFLOW_TRIGGER,
FORM_SUBMITTED,
HOUR,
OnAfterCalVideoGuestsNoShowTriggerDto,
OnAfterCalVideoHostsNoShowTriggerDto,
OnAfterEventTriggerDto,
OnBeforeEventTriggerDto,
OnCancelTriggerDto,
OnCreationTriggerDto,
OnFormSubmittedTriggerDto,
OnNoShowUpdateTriggerDto,
OnPaidTriggerDto,
OnPaymentInitiatedTriggerDto,
OnRejectedTriggerDto,
OnRequestedTriggerDto,
OnRescheduleTriggerDto,
WORKFLOW_TRIGGER_TO_ENUM,
} from "../inputs/workflow-trigger.input";
export type TriggerDtoType =
| OnAfterEventTriggerDto
| OnBeforeEventTriggerDto
| OnCreationTriggerDto
| OnRescheduleTriggerDto
| OnCancelTriggerDto
| OnAfterCalVideoGuestsNoShowTriggerDto
| OnFormSubmittedTriggerDto
| OnRejectedTriggerDto
| OnRequestedTriggerDto
| OnPaymentInitiatedTriggerDto
| OnPaidTriggerDto
| OnNoShowUpdateTriggerDto
| OnAfterCalVideoHostsNoShowTriggerDto;
export type TriggerEventTypeDtoType =
| OnAfterEventTriggerDto
| OnBeforeEventTriggerDto
| OnCreationTriggerDto
| OnRescheduleTriggerDto
| OnCancelTriggerDto
| OnAfterCalVideoGuestsNoShowTriggerDto
| OnRejectedTriggerDto
| OnRequestedTriggerDto
| OnPaymentInitiatedTriggerDto
| OnPaidTriggerDto
| OnNoShowUpdateTriggerDto
| OnAfterCalVideoHostsNoShowTriggerDto;
type StepConfig = {
recipient: RecipientType;
messageKey: "html" | "text";
setsCustomRecipient: boolean;
requiresPhone: boolean;
};
const ACTION_CONFIG_MAP = {
[EMAIL_HOST]: {
recipient: HOST,
messageKey: "html",
setsCustomRecipient: false,
requiresPhone: false,
} satisfies StepConfig,
[EMAIL_ATTENDEE]: {
recipient: ATTENDEE,
messageKey: "html",
setsCustomRecipient: false,
requiresPhone: false,
},
[SMS_ATTENDEE]: {
recipient: ATTENDEE,
messageKey: "text",
setsCustomRecipient: false,
requiresPhone: true,
},
[WHATSAPP_ATTENDEE]: {
recipient: ATTENDEE,
messageKey: "text",
setsCustomRecipient: false,
requiresPhone: true,
},
[EMAIL_ADDRESS]: { recipient: EMAIL, messageKey: "html", setsCustomRecipient: true, requiresPhone: false },
[SMS_NUMBER]: {
recipient: PHONE_NUMBER,
messageKey: "text",
setsCustomRecipient: true,
requiresPhone: false,
},
[WHATSAPP_NUMBER]: {
recipient: PHONE_NUMBER,
messageKey: "text",
setsCustomRecipient: true,
requiresPhone: false,
},
[CAL_AI_PHONE_CALL]: {
recipient: PHONE_NUMBER,
messageKey: "text",
setsCustomRecipient: true,
requiresPhone: false,
},
} satisfies Record<StepAction, StepConfig>;
@Injectable()
export class WorkflowsOutputService {
toOutputDto(workflow: WorkflowType): WorkflowOutput {
const activation: WorkflowActivationDto = {
isActiveOnAllEventTypes: workflow.isActiveOnAll,
activeOnEventTypeIds: workflow.activeOn?.map((relation) => relation.eventTypeId) ?? [],
_isFormAllowedStepAction(action: StepAction): action is FormAllowedStepAction {
if (FORM_ALLOWED_STEP_ACTIONS.some((formAction) => formAction === action)) {
return true;
}
return false;
}
/**
* Maps a single workflow step from the database entity to its DTO representation.
* @param step The workflow step object from the database.
* @returns An EventTypeWorkflowStepOutputDto.
*/
mapStep(step: WorkflowType["steps"][number], _discriminator: "event-type"): EventTypeWorkflowStepOutputDto;
mapStep(
step: WorkflowType["steps"][number],
_discriminator: "routing-form"
): RoutingFormWorkflowStepOutputDto;
mapStep(
step: WorkflowType["steps"][number],
_discriminator: "event-type" | "routing-form"
): EventTypeWorkflowStepOutputDto | RoutingFormWorkflowStepOutputDto {
const action = ENUM_TO_STEP_ACTIONS[step.action];
const config = ACTION_CONFIG_MAP[action] || {
recipient: ATTENDEE,
setsCustomRecipient: false,
requiresPhone: false,
};
const trigger: TriggerDtoType =
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[BEFORE_EVENT] ||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_EVENT] ||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_GUESTS_CAL_VIDEO_NO_SHOW] ||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_HOSTS_CAL_VIDEO_NO_SHOW]
? {
type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger],
offset: {
value: workflow.time ?? 1,
unit: workflow.timeUnit ? ENUM_TO_TIME_UNIT[workflow.timeUnit] : HOUR,
},
}
: { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger] };
const customRecipient = step.sendTo ?? "";
const reminderBody = step.reminderBody ?? "";
const steps: WorkflowStepOutputDto[] = workflow.steps.map((step) => {
let recipient: RecipientType;
let email = "";
let phone = "";
let text;
let html;
switch (ENUM_TO_STEP_ACTIONS[step.action]) {
case EMAIL_HOST:
recipient = HOST;
html = step.reminderBody ?? "";
break;
case EMAIL_ATTENDEE:
html = step.reminderBody ?? "";
recipient = ATTENDEE;
break;
case SMS_ATTENDEE:
text = step.reminderBody ?? "";
recipient = ATTENDEE;
break;
case WHATSAPP_ATTENDEE:
text = step.reminderBody ?? "";
recipient = ATTENDEE;
break;
case EMAIL_ADDRESS:
html = step.reminderBody ?? "";
recipient = EMAIL;
email = step.sendTo ?? "";
break;
case SMS_NUMBER:
case WHATSAPP_NUMBER:
text = step.reminderBody ?? "";
recipient = PHONE_NUMBER;
phone = step.sendTo ?? "";
break;
default:
recipient = ATTENDEE;
}
const baseAction = {
id: step.id,
stepNumber: step.stepNumber,
template: ENUM_TO_TEMPLATES[step.template],
recipient: config.recipient,
sender: step.sender ?? "Default Sender",
includeCalendarEvent: step.includeCalendarEvent,
phoneRequired: config.requiresPhone ? step.numberRequired ?? false : undefined,
email: config.recipient === EMAIL ? customRecipient : "",
phone: config.recipient === PHONE_NUMBER ? customRecipient : "",
message: {
subject: step.emailSubject ?? "",
html: config.messageKey === "html" ? reminderBody : undefined,
text: config.messageKey === "text" ? reminderBody : undefined,
},
};
return this._isFormAllowedStepAction(action)
? ({
...baseAction,
action: action,
} satisfies RoutingFormWorkflowStepOutputDto)
: ({
...baseAction,
action: action,
} satisfies EventTypeWorkflowStepOutputDto);
}
toRoutingFormOutputDto(workflow: WorkflowType): RoutingFormWorkflowOutput | void {
if (workflow.type === "ROUTING_FORM" && workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED]) {
const activation: WorkflowFormActivationDto = {
isActiveOnAllRoutingForms: workflow.isActiveOnAll,
activeOnRoutingFormIds:
workflow.activeOnRoutingForms?.map((relation) => relation.routingFormId) ?? [],
};
const trigger: TriggerDtoType = { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger] };
const steps: RoutingFormWorkflowStepOutputDto[] = workflow.steps.map((step) => {
return this.mapStep(step, "routing-form");
});
return {
id: step.id,
stepNumber: step.stepNumber,
action: ENUM_TO_STEP_ACTIONS[step.action],
recipient: recipient,
email,
phone,
template: ENUM_TO_TEMPLATES[step.template],
includeCalendarEvent: step.includeCalendarEvent,
sender: step.sender ?? "Default Sender",
message: {
subject: step.emailSubject ?? "",
text,
html,
},
id: workflow.id,
name: workflow.name,
activation: activation,
trigger: trigger,
steps: steps.sort((stepA, stepB) => stepA.stepNumber - stepB.stepNumber),
type: "routing-form",
};
});
}
}
return {
id: workflow.id,
name: workflow.name,
activation: activation,
trigger: trigger,
steps: steps.sort((stepA, stepB) => stepA.stepNumber - stepB.stepNumber),
};
toEventTypeOutputDto(workflow: WorkflowType): EventTypeWorkflowOutput | void {
if (workflow.type === "EVENT_TYPE" && workflow.trigger !== WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED]) {
const activation: WorkflowActivationDto = {
isActiveOnAllEventTypes: workflow.isActiveOnAll,
activeOnEventTypeIds: workflow.activeOn?.map((relation) => relation.eventTypeId) ?? [],
};
const trigger: TriggerEventTypeDtoType =
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[BEFORE_EVENT] ||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_EVENT] ||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_GUESTS_CAL_VIDEO_NO_SHOW] ||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_HOSTS_CAL_VIDEO_NO_SHOW]
? {
type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger],
offset: {
value: workflow.time ?? 1,
unit: workflow.timeUnit ? ENUM_TO_TIME_UNIT[workflow.timeUnit] : HOUR,
},
}
: { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger] };
const steps: EventTypeWorkflowStepOutputDto[] = workflow.steps.map((step) => {
return this.mapStep(step, "event-type");
});
return {
id: workflow.id,
name: workflow.name,
activation: activation,
trigger: trigger,
steps: steps.sort((stepA, stepB) => stepA.stepNumber - stepB.stepNumber),
type: "event-type",
};
}
}
}
@@ -9,7 +9,11 @@ import { updateWorkflow } from "@calcom/platform-libraries/workflows";
import type { PrismaClient } from "@calcom/prisma";
import type { Workflow, WorkflowStep } from "@calcom/prisma/client";
export type WorkflowType = Workflow & { activeOn: { eventTypeId: number }[]; steps: WorkflowStep[] };
export type WorkflowType = Workflow & {
activeOn: { eventTypeId: number }[];
steps: WorkflowStep[];
activeOnRoutingForms: { routingFormId: string }[];
};
@Injectable()
export class WorkflowsRepository {
@@ -19,29 +23,68 @@ export class WorkflowsRepository {
return await this.dbWrite.prisma.workflow.delete({ where: { id: workflowId, teamId } });
}
async getTeamWorkflowById(teamId: number, id: number): Promise<WorkflowType | null> {
async getEventTypeTeamWorkflowById(teamId: number, id: number): Promise<WorkflowType | null> {
const workflow = await this.dbRead.prisma.workflow.findUnique({
where: {
id: id,
teamId: teamId,
type: "EVENT_TYPE",
},
include: {
steps: true,
activeOn: { select: { eventTypeId: true } },
activeOnRoutingForms: { select: { routingFormId: true } },
},
});
return workflow;
}
async getTeamWorkflows(teamId: number, skip: number, take: number): Promise<WorkflowType[]> {
const workflows = await this.dbRead.prisma.workflow.findMany({
async getRoutingFormTeamWorkflowById(teamId: number, id: number): Promise<WorkflowType | null> {
const workflow = await this.dbRead.prisma.workflow.findUnique({
where: {
id: id,
teamId: teamId,
type: "ROUTING_FORM",
},
include: {
steps: true,
activeOn: { select: { eventTypeId: true } },
activeOnRoutingForms: { select: { routingFormId: true } },
},
});
return workflow;
}
async getEventTypeTeamWorkflows(teamId: number, skip: number, take: number): Promise<WorkflowType[]> {
const workflows = await this.dbRead.prisma.workflow.findMany({
where: {
teamId: teamId,
type: "EVENT_TYPE",
},
include: {
steps: true,
activeOn: { select: { eventTypeId: true } },
activeOnRoutingForms: { select: { routingFormId: true } },
},
skip,
take,
});
return workflows;
}
async getRoutingFormTeamWorkflows(teamId: number, skip: number, take: number): Promise<WorkflowType[]> {
const workflows = await this.dbRead.prisma.workflow.findMany({
where: {
teamId: teamId,
type: "ROUTING_FORM",
},
include: {
steps: true,
activeOn: { select: { eventTypeId: true } },
activeOnRoutingForms: { select: { routingFormId: true } },
},
skip,
take,
@@ -59,11 +102,11 @@ export class WorkflowsRepository {
timeUnit: TimeUnit.HOUR,
teamId,
},
include: { activeOn: true, steps: true },
include: { activeOn: true, steps: true, activeOnRoutingForms: true },
});
}
async updateTeamWorkflow(
async updateRoutingFormTeamWorkflow(
user: UserWithProfile,
teamId: number,
workflowId: number,
@@ -77,7 +120,25 @@ export class WorkflowsRepository {
input: data,
});
const workflow = await this.getTeamWorkflowById(teamId, workflowId);
const workflow = await this.getRoutingFormTeamWorkflowById(teamId, workflowId);
return workflow;
}
async updateEventTypeTeamWorkflow(
user: UserWithProfile,
teamId: number,
workflowId: number,
data: TUpdateInputSchema
) {
await updateWorkflow({
ctx: {
user: { ...user, locale: user?.locale ?? "en" },
prisma: this.dbWrite.prisma as unknown as PrismaClient,
},
input: data,
});
const workflow = await this.getEventTypeTeamWorkflowById(teamId, workflowId);
return workflow;
}
}
@@ -1444,6 +1444,7 @@
"new_event_trigger": "when new event is booked",
"email_host_action": "send email to host",
"email_attendee_action": "send email to attendees",
"email_attendee_action_form": "Send email to submitted email address",
"sms_attendee_action": "Send SMS to attendee",
"sms_number_action": "send SMS to a specific number",
"send_reminder_sms": "Easily send meeting reminders via SMS to your attendees",
@@ -1515,6 +1516,8 @@
"may_require_confirmation": "May require confirmation",
"nr_event_type_one": "{{count}} event type",
"nr_event_type_other": "{{count}} event types",
"nr_routing_form_one": "{{count}} routing form",
"nr_routing_form_other": "{{count}} routing forms",
"count_team_one": "{{count}} team",
"count_team_other": "{{count}} teams",
"add_action": "Add action",
@@ -1631,7 +1634,9 @@
"2fa_required": "Two factor authentication required",
"incorrect_2fa": "Incorrect two factor authentication code",
"which_event_type_apply": "Which event type will this apply to?",
"which_routing_form_apply": "Which routing form will this apply to?",
"apply_to_all_event_types": "Apply to all, including future event types",
"apply_to_all_routing_forms": "Apply to all, including future routing forms",
"apply_to_all_teams": "Apply to all team and user event types",
"which_team_apply": "Which team will this apply to?",
"no_workflows_description": "Workflows enable simple automation to send notifications & reminders enabling you to build processes around your events.",
@@ -3711,6 +3716,7 @@
"webhook_metadata": "Metadata",
"stats": "Stats",
"booking_status": "Booking status",
"form_submitted_trigger": "When routing form is submitted",
"visit": "Visit",
"location_custom_label_input_label": "Custom label on booking page",
"meeting_link": "Meeting link",
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,13 @@ import { describe, it, vi, expect, beforeEach, afterEach } from "vitest";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import {
WebhookTriggerEvents,
WorkflowTriggerEvents,
WorkflowActions,
WorkflowTemplates,
} from "@calcom/prisma/enums";
import { _onFormSubmission } from "./formSubmissionUtils";
@@ -30,6 +36,14 @@ vi.mock("@calcom/features/tasker", () => {
return { default: Promise.resolve(tasker) };
});
// Mock workflow dependencies
vi.mock("@calcom/lib/server/service/workflows", () => ({
WorkflowService: {
getAllWorkflowsFromRoutingForm: vi.fn(() => Promise.resolve([])),
scheduleFormWorkflows: vi.fn(() => Promise.resolve()),
},
}));
const mockSendEmail = vi.fn(() => Promise.resolve());
const mockResponseEmailConstructor = vi.fn();
vi.mock("../emails/templates/response-email", () => ({
@@ -49,7 +63,7 @@ describe("_onFormSubmission", () => {
{ id: "field-1", identifier: "email", label: "Email", type: "email" },
{ id: "field-2", identifier: "name", label: "Name", type: "text" },
],
user: { id: 1, email: "test@example.com" },
user: { id: 1, email: "test@example.com", timeFormat: 12, locale: "en" },
teamId: null,
settings: { emailOwnerOnSubmission: true },
};
@@ -83,47 +97,58 @@ describe("_onFormSubmission", () => {
});
expect(sendGenericWebhookPayload).toHaveBeenCalledTimes(1);
});
});
it("should schedule FORM_SUBMITTED_NO_EVENT webhooks via tasker", async () => {
const tasker = await (await import("@calcom/features/tasker")).default;
const mockWebhook = { id: "wh-no-event-1", secret: "secret" };
const chosenAction = { type: "customPageMessage" as const, value: "test" };
vi.mocked(getWebhooks).mockImplementation(async (options) => {
if (options.triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT) {
return [mockWebhook as any];
}
return [];
});
await _onFormSubmission(mockForm as any, mockResponse, responseId, chosenAction);
expect(getWebhooks).toHaveBeenCalledWith(
expect.objectContaining({
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT,
})
);
expect(tasker.create).toHaveBeenCalledWith(
"triggerFormSubmittedNoEventWebhook",
describe("Workflows", () => {
it("should call WorkflowService.scheduleFormWorkflows for FORM_SUBMITTED workflows", async () => {
const mockWorkflows = [
{
responseId,
form: {
id: mockForm.id,
name: mockForm.name,
teamId: mockForm.teamId,
},
responses: {
email: {
value: "test@response.com",
response: "test@response.com",
id: 1,
name: "Form Submitted Workflow",
userId: 1,
teamId: null,
trigger: WorkflowTriggerEvents.FORM_SUBMITTED,
time: null,
timeUnit: null,
steps: [
{
id: 1,
action: WorkflowActions.EMAIL_ATTENDEE,
sendTo: null,
reminderBody: "Thank you for your submission!",
emailSubject: "Form Received",
template: WorkflowTemplates.CUSTOM,
verifiedAt: new Date(),
includeCalendarEvent: false,
numberVerificationPending: false,
numberRequired: false,
},
name: { value: "Test Name", response: "Test Name" },
},
redirect: chosenAction,
webhook: mockWebhook,
],
},
{ scheduledAt: expect.any(Date) }
);
];
vi.mocked(WorkflowService.getAllWorkflowsFromRoutingForm).mockResolvedValueOnce(mockWorkflows as any);
await _onFormSubmission(mockForm as any, mockResponse, responseId);
expect(WorkflowService.getAllWorkflowsFromRoutingForm).toHaveBeenCalledWith(mockForm);
expect(WorkflowService.scheduleFormWorkflows).toHaveBeenCalledWith({
workflows: mockWorkflows,
responses: {
email: {
value: "test@response.com",
response: "test@response.com",
},
name: { value: "Test Name", response: "Test Name" },
},
form: {
...mockForm,
fields: mockForm.fields.map((field) => ({
type: field.type,
identifier: field.identifier,
})),
},
});
});
});
@@ -133,7 +158,7 @@ describe("_onFormSubmission", () => {
...mockForm,
teamId: 1,
userWithEmails: ["team-member1@example.com", "team-member2@example.com"],
user: { id: 1, email: "test@example.com" },
user: { id: 1, email: "test@example.com", timeFormat: 12, locale: "en" },
};
await _onFormSubmission(teamForm as any, mockResponse, responseId);
@@ -6,6 +6,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { App_RoutingForms_Form, User } from "@calcom/prisma/client";
@@ -14,6 +15,7 @@ import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import type { Ensure } from "@calcom/types/utils";
import type { FormResponse, SerializableForm, SerializableField, OrderedResponses } from "../types/types";
import getFieldIdentifier from "./getFieldIdentifier";
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/formSubmissionUtils"] });
@@ -112,7 +114,10 @@ function getWebhookTargetEntity(form: { teamId?: number | null; user: { id: numb
*/
export async function _onFormSubmission(
form: Ensure<
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
SerializableForm<App_RoutingForms_Form> & {
user: Pick<User, "id" | "email" | "timeFormat" | "locale">;
userWithEmails?: string[];
},
"fields"
>,
response: FormResponse,
@@ -159,6 +164,7 @@ export async function _onFormSubmission(
};
const webhooksFormSubmitted = await getWebhooks(subscriberOptionsFormSubmitted);
const webhooksFormSubmittedNoEvent = await getWebhooks(subscriberOptionsFormSubmittedNoEvent);
const promisesFormSubmitted = webhooksFormSubmitted.map((webhook) => {
@@ -211,6 +217,21 @@ export async function _onFormSubmission(
const promises = [...promisesFormSubmitted, ...promisesFormSubmittedNoEvent];
await Promise.all(promises);
const workflows = await WorkflowService.getAllWorkflowsFromRoutingForm(form);
await WorkflowService.scheduleFormWorkflows({
workflows,
responses: fieldResponsesByIdentifier,
form: {
...form,
fields: form.fields.map((field) => ({
type: field.type,
identifier: getFieldIdentifier(field),
})),
},
});
const orderedResponses = form.fields.reduce((acc, field) => {
acc.push(response[field.id]);
return acc;
@@ -243,6 +264,8 @@ export type TargetRoutingFormForResponse = SerializableForm<
user: {
id: number;
email: string;
timeFormat: number | null;
locale: string | null;
};
team: {
parentId: number | null;
@@ -84,6 +84,8 @@ const mockForm: TargetRoutingFormForResponse = {
user: {
id: 1,
email: "test@example.com",
timeFormat: null,
locale: null,
},
team: {
parentId: 2,
@@ -6,6 +6,7 @@ import { buildDryRunBooking } from "../../handleNewBooking";
vi.mock("@calcom/prisma", () => ({
default: {}, // empty object as default export
prisma: {},
}));
describe("buildDryRunBooking", () => {
@@ -12,6 +12,7 @@ vi.mock("@calcom/lib/server/i18n", () => ({
vi.mock("@calcom/prisma", () => {
return {
default: vi.fn(),
prisma: {},
};
});
@@ -10,7 +10,6 @@ import PhoneInput from "@calcom/features/components/phone-input";
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WorkflowActions } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { EmailField } from "@calcom/ui/components/form";
@@ -35,6 +34,13 @@ interface IAddActionDialog {
senderId?: string,
senderName?: string
) => void;
actionOptions: {
label: string;
value: WorkflowActions;
needsCredits: boolean;
creditsTeamId?: number;
isOrganization?: boolean;
}[];
}
interface ISelectActionOption {
@@ -52,11 +58,10 @@ type AddActionFormValues = {
export const AddActionDialog = (props: IAddActionDialog) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, addAction } = props;
const { isOpenDialog, setIsOpenDialog, addAction, actionOptions } = props;
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(false);
const [isSenderIdNeeded, setIsSenderIdNeeded] = useState(false);
const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState(false);
const { data: actionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery();
const formSchema = z.object({
action: z.enum(WORKFLOW_ACTIONS),
@@ -165,9 +170,7 @@ export const AddActionDialog = (props: IAddActionDialog) => {
menuPlacement="bottom"
defaultValue={actionOptions[0]}
onChange={handleSelectAction}
options={actionOptions.map((option) => ({
...option,
}))}
options={actionOptions}
/>
);
}}
@@ -6,16 +6,20 @@ import type { UseFormReturn } from "react-hook-form";
import { SENDER_ID, SENDER_NAME, SCANNING_WORKFLOW_STEPS } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WorkflowPermissions } from "@calcom/lib/server/repository/workflow-permissions";
import type { WorkflowActions } from "@calcom/prisma/enums";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { WorkflowActions } from "@calcom/prisma/enums";
import { WorkflowTemplates } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { FormCard, FormCardBody } from "@calcom/ui/components/card";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { useAgentsData } from "../hooks/useAgentsData";
import { isCalAIAction, isSMSAction } from "../lib/actionHelperFunctions";
import { isCalAIAction, isFormTrigger, isSMSAction } from "../lib/actionHelperFunctions";
import { ALLOWED_FORM_WORKFLOW_ACTIONS } from "../lib/constants";
import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate";
import type { FormValues } from "../pages/workflow";
import { AddActionDialog } from "./AddActionDialog";
import WorkflowStepContainer from "./WorkflowStepContainer";
@@ -38,16 +42,56 @@ interface Props {
export default function WorkflowDetailsPage(props: Props) {
const { form, workflowId, selectedOptions, setSelectedOptions, teamId, isOrg, allOptions, permissions } =
props;
const { t } = useLocale();
const { t, i18n } = useLocale();
const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false);
const [isDeleteStepDialogOpen, setIsDeleteStepDialogOpen] = useState(false);
const [reload, setReload] = useState(false);
const [updateTemplate, setUpdateTemplate] = useState(false);
const searchParams = useSearchParams();
const eventTypeId = searchParams?.get("eventTypeId");
// Get base action options and transform them for form triggers
const { data: baseActionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery();
const transformedActionOptions = baseActionOptions
? baseActionOptions
.filter((option) => {
const isFormWorkflowWithInvalidSteps =
isFormTrigger(form.getValues("trigger")) &&
!ALLOWED_FORM_WORKFLOW_ACTIONS.some((action) => action === option.value);
const isSelectAllCalAiAction = isCalAIAction(option.value) && form.watch("selectAll");
const isOrgCalAiAction = isCalAIAction(option.value) && isOrg;
if (isFormWorkflowWithInvalidSteps || isSelectAllCalAiAction || isOrgCalAiAction) {
return false;
}
return true;
})
.map((option) => {
let label = option.label;
// Transform labels for form triggers
if (isFormTrigger(form.getValues("trigger"))) {
if (option.value === WorkflowActions.EMAIL_ATTENDEE) {
label = t("email_attendee_action_form");
}
}
return {
...option,
label,
creditsTeamId: teamId,
isOrganization: isOrg,
isCalAi: isCalAIAction(option.value),
};
})
: [];
useEffect(() => {
const matchingOption = allOptions.find((option) => option.value === eventTypeId);
if (matchingOption && !selectedOptions.find((option) => option.value === eventTypeId)) {
@@ -55,7 +99,6 @@ export default function WorkflowDetailsPage(props: Props) {
setSelectedOptions(newOptions);
form.setValue("activeOn", newOptions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventTypeId]);
const addAction = (
@@ -73,6 +116,23 @@ export default function WorkflowDetailsPage(props: Props) {
})[0].id - 1
: 0;
const timeFormat = getTimeFormatStringFromUserTimeFormat(props.user.timeFormat);
const template = isFormTrigger(form.getValues("trigger"))
? WorkflowTemplates.CUSTOM
: WorkflowTemplates.REMINDER;
const { emailBody: reminderBody, emailSubject } =
template !== WorkflowTemplates.CUSTOM
? emailReminderTemplate({
isEditingMode: true,
locale: i18n.language,
t,
action,
timeFormat,
})
: { emailBody: null, emailSubject: null };
const step = {
id: id > 0 ? 0 : id, //id of new steps always <= 0
action,
@@ -84,9 +144,9 @@ export default function WorkflowDetailsPage(props: Props) {
: 1,
sendTo: sendTo || null,
workflowId: workflowId,
reminderBody: null,
emailSubject: null,
template: WorkflowTemplates.REMINDER,
reminderBody,
emailSubject,
template,
numberRequired: numberRequired || false,
sender: isSMSAction(action) ? sender || SENDER_ID : SENDER_ID,
senderName: !isSMSAction(action) ? senderName || SENDER_NAME : SENDER_NAME,
@@ -128,6 +188,9 @@ export default function WorkflowDetailsPage(props: Props) {
isOrganization={isOrg}
allOptions={allOptions}
onSaveWorkflow={props.onSaveWorkflow}
actionOptions={transformedActionOptions}
updateTemplate={updateTemplate}
setUpdateTemplate={setUpdateTemplate}
/>
</FormCardBody>
</FormCard>
@@ -205,6 +268,9 @@ export default function WorkflowDetailsPage(props: Props) {
inboundAgentData={inboundAgentData}
isInboundAgentLoading={isInboundAgentLoading}
allOptions={allOptions}
actionOptions={transformedActionOptions}
updateTemplate={updateTemplate}
setUpdateTemplate={setUpdateTemplate}
/>
</FormCardBody>
</FormCard>
@@ -234,6 +300,7 @@ export default function WorkflowDetailsPage(props: Props) {
isOpenDialog={isAddActionDialogOpen}
setIsOpenDialog={setIsAddActionDialogOpen}
addAction={addAction}
actionOptions={transformedActionOptions}
/>
</>
);
@@ -3,7 +3,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useRef, useState, useCallback } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller } from "react-hook-form";
import { Controller, useWatch } from "react-hook-form";
import "react-phone-number-input/style.css";
import type { RetellAgentWithDetails } from "@calcom/features/calAIPhone/providers/retellAI";
@@ -60,6 +60,8 @@ import {
shouldScheduleEmailReminder,
isSMSOrWhatsappAction,
isCalAIAction,
isFormTrigger,
hasCalAIAction,
} from "../lib/actionHelperFunctions";
import { DYNAMIC_TEXT_VARIABLES } from "../lib/constants";
import { getWorkflowTemplateOptions, getWorkflowTriggerOptions } from "../lib/getOptions";
@@ -92,6 +94,16 @@ type WorkflowStepProps = {
isDeleteStepDialogOpen?: boolean;
agentData?: RetellAgentWithDetails;
isAgentLoading?: boolean;
actionOptions: {
label: string;
value: WorkflowActions;
needsCredits: boolean;
creditsTeamId?: number;
isOrganization: boolean;
isCalAi: boolean;
}[];
updateTemplate: boolean;
setUpdateTemplate: Dispatch<SetStateAction<boolean>>;
inboundAgentData?: RetellAgentWithDetails;
isInboundAgentLoading?: boolean;
};
@@ -103,26 +115,24 @@ const getTimeSectionText = (trigger: WorkflowTriggerEvents, t: TFunction) => {
[WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: "how_long_after_hosts_no_show",
[WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: "how_long_after_guests_no_show",
};
if (!triggerMap[trigger]) return null;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return t(triggerMap[trigger]!);
return triggerMap[trigger] ? t(triggerMap[trigger]) : null;
};
const CalAIAgentDataSkeleton = () => {
return (
<div className="bg-muted mt-4 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="p-4 mt-4 rounded-lg bg-muted">
<div className="flex justify-between items-center">
<div>
<SkeletonText className="h-5 w-28" />
<div className="mt-2 flex items-center gap-2">
<SkeletonText className="h-4 w-4" />
<SkeletonText className="h-4 w-32" />
<SkeletonText className="h-5 w-12" />
<SkeletonText className="w-28 h-5" />
<div className="flex gap-2 items-center mt-2">
<SkeletonText className="w-4 h-4" />
<SkeletonText className="w-32 h-4" />
<SkeletonText className="w-12 h-5" />
</div>
</div>
<div className="flex items-center gap-1">
<SkeletonText className="h-8 w-24" />
<SkeletonText className="h-8 w-8" />
<div className="flex gap-1 items-center">
<SkeletonText className="w-24 h-8" />
<SkeletonText className="w-8 h-8" />
</div>
</div>
</div>
@@ -164,6 +174,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
isDeleteStepDialogOpen,
setIsDeleteStepDialogOpen,
onSaveWorkflow,
actionOptions,
updateTemplate,
setUpdateTemplate,
} = props;
const { data: _verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery(
{ teamId },
@@ -258,7 +271,6 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
WorkflowActions.SMS_NUMBER === action || WorkflowActions.WHATSAPP_NUMBER === action;
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(requirePhoneNumber);
const [updateTemplate, setUpdateTemplate] = useState(false);
const [firstRender, setFirstRender] = useState(true);
const senderNeeded =
@@ -278,14 +290,18 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
: false
);
const [timeSectionText, setTimeSectionText] = useState(getTimeSectionText(form.getValues("trigger"), t));
const trigger = useWatch({
control: form.control,
name: "trigger",
});
const [timeSectionText, setTimeSectionText] = useState(getTimeSectionText(trigger, t));
const isCreatingAgent = useRef(false);
const hasAutoCreated = useRef(false);
const handleCreateAgent = useCallback(
async (templateWorkflowId?: string) => {
if (isCreatingAgent.current || createAgentMutation.isPending) {
console.log("Agent creation already in progress, skipping...");
return;
}
@@ -354,39 +370,24 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
handleCreateAgent,
]);
const { data: actionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery();
const triggerOptions = getWorkflowTriggerOptions(t);
const templateOptions = getWorkflowTemplateOptions(t, step?.action, hasActiveTeamPlan);
if (step && !form.getValues(`steps.${step.stepNumber - 1}.reminderBody`)) {
const action = form.getValues(`steps.${step.stepNumber - 1}.action`);
const templateOptions = getWorkflowTemplateOptions(t, step?.action, hasActiveTeamPlan, trigger);
// Skip setting reminderBody for CAL_AI actions since they don't need email templates
if (!isCalAIAction(action)) {
const template = getTemplateBodyForAction({
action,
locale: i18n.language,
t,
template: step.template ?? WorkflowTemplates.REMINDER,
timeFormat,
});
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, template);
}
}
const steps = useWatch({
control: form.control,
name: "steps",
});
if (step && !form.getValues(`steps.${step.stepNumber - 1}.emailSubject`)) {
const action = form.getValues(`steps.${step.stepNumber - 1}.action`);
// Skip setting emailSubject for CAL_AI actions since they don't need email subjects
if (!isCalAIAction(action)) {
const subjectTemplate = emailReminderTemplate({
isEditingMode: true,
locale: i18n.language,
t,
action: action,
timeFormat,
}).emailSubject;
form.setValue(`steps.${step.stepNumber - 1}.emailSubject`, subjectTemplate);
}
}
const hasAiAction = hasCalAIAction(steps);
const hasSMSAction = steps.some((s) => isSMSAction(s.action));
const hasWhatsappAction = steps.some((s) => isWhatsappAction(s.action));
const hasEmailToHostAction = steps.some((s) => s.action === WorkflowActions.EMAIL_HOST);
const disallowFormTriggers = hasAiAction || hasSMSAction || hasEmailToHostAction || hasWhatsappAction;
const filteredTriggerOptions = triggerOptions.filter(
(option) => !(isFormTrigger(option.value) && disallowFormTriggers)
);
const { ref: emailSubjectFormRef, ...restEmailSubjectForm } = step
? form.register(`steps.${step.stepNumber - 1}.emailSubject`)
@@ -407,9 +408,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
const [numberVerified, setNumberVerified] = useState(getNumberVerificationStatus());
const [emailVerified, setEmailVerified] = useState(getEmailVerificationStatus());
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => setNumberVerified(getNumberVerificationStatus()), [verifiedNumbers.length]);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => setEmailVerified(getEmailVerificationStatus()), [verifiedEmails.length]);
const addVariableEmailSubject = (variable: string) => {
@@ -465,11 +464,6 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
},
});
const hasCalAIAction = () => {
const steps = form.getValues("steps") || [];
return steps.some((step) => isCalAIAction(step.action));
};
const verifyEmailCodeMutation = trpc.viewer.workflows.verifyEmailCode.useMutation({
onSuccess: (isVerified) => {
showToast(isVerified ? t("verified_successfully") : t("wrong_code"), "success");
@@ -513,7 +507,6 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
//trigger
if (!step) {
const trigger = form.getValues("trigger");
const triggerString = t(`${trigger.toLowerCase()}_trigger`);
const selectedTrigger = {
@@ -524,7 +517,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
return (
<>
<div className="mb-3">
<Label className="text-default text-sm leading-none">{t("when")}</Label>
<Label className="text-sm leading-none text-default">{t("when")}</Label>
<Controller
name="trigger"
control={form.control}
@@ -538,7 +531,21 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
isDisabled={props.readOnly}
onChange={(val) => {
if (val) {
const currentTrigger = form.getValues("trigger");
const isCurrentFormTrigger = isFormTrigger(currentTrigger);
const isNewFormTrigger = isFormTrigger(val.value);
form.setValue("trigger", val.value);
// Reset activeOn when switching between form and non-form triggers
if (isCurrentFormTrigger !== isNewFormTrigger) {
form.setValue("activeOn", []);
if (setSelectedOptions) {
setSelectedOptions([]);
}
form.setValue("selectAll", false);
}
const newTimeSectionText = getTimeSectionText(val.value, t);
if (newTimeSectionText) {
setTimeSectionText(newTimeSectionText);
@@ -557,10 +564,27 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
form.unregister("time");
form.unregister("timeUnit");
}
if (isFormTrigger(val.value)) {
const steps = form.getValues("steps");
if (steps?.length) {
const updatedSteps = steps.map((step) =>
step.template === WorkflowTemplates.CUSTOM
? step
: {
...step,
reminderBody: " ",
emailSubject: " ",
template: WorkflowTemplates.CUSTOM,
}
);
form.setValue("steps", updatedSteps);
setUpdateTemplate(!updateTemplate);
}
}
}
}}
defaultValue={selectedTrigger}
options={triggerOptions}
options={filteredTriggerOptions}
/>
);
}}
@@ -568,8 +592,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
<div>
{!!timeSectionText && (
<div className="mb-3 mt-3">
<Label className="text-default mb-0">{timeSectionText}</Label>
<div className="mt-3 mb-3">
<Label className="mb-0 text-default">{timeSectionText}</Label>
<TimeTimeUnitInput disabled={props.readOnly} />
</div>
)}
@@ -577,14 +601,16 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
{selectedOptions && setSelectedOptions && allOptions && (
<div className={classNames(!!timeSectionText && "mb-3")}>
{isOrganization ? (
<div className="text-default flex items-center gap-2">
<div className="flex gap-2 items-center text-default">
<Label>{t("which_team_apply")}</Label>
<div className="mb-2">
<InfoBadge content={t("team_select_info")} />
</div>
</div>
) : (
<Label className="text-default mb-2 block">{t("which_event_type_apply")}</Label>
<Label>
{isFormTrigger(trigger) ? t("which_routing_form_apply") : t("which_event_type_apply")}
</Label>
)}
<Controller
name="activeOn"
@@ -600,18 +626,30 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
setValue={(s: Option[]) => {
form.setValue("activeOn", s);
}}
countText={isOrganization ? "count_team" : "nr_event_type"}
countText={
isOrganization
? "count_team"
: isFormTrigger(form.getValues("trigger"))
? "nr_routing_form"
: "nr_event_type"
}
/>
);
}}
/>
{!hasCalAIAction() && (
{!hasCalAIAction(steps) && (
<div className="mt-1">
<Controller
name="selectAll"
render={({ field: { value, onChange } }) => (
<CheckboxField
description={isOrganization ? t("apply_to_all_teams") : t("apply_to_all_event_types")}
description={
isOrganization
? t("apply_to_all_teams")
: isFormTrigger(form.getValues("trigger"))
? t("apply_to_all_routing_forms")
: t("apply_to_all_event_types")
}
disabled={props.readOnly}
descriptionClassName="ml-0"
onChange={(e) => {
@@ -630,7 +668,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
)}
{!!timeSectionText && (
<div className="mt-1 flex text-gray-500">
<div className="flex mt-1 text-gray-500">
<Icon name="info" className="mr-1 mt-0.5 h-4 w-4" />
<p className="text-sm">{t("testing_sms_workflow_info_message")}</p>
</div>
@@ -750,21 +788,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
}
}}
defaultValue={selectedAction}
options={actionOptions
?.filter((option) => {
if (
(isCalAIAction(option.value) && form.watch("selectAll")) ||
(isCalAIAction(option.value) && props.isOrganization)
) {
return false;
}
return true;
})
?.map((option) => ({
...option,
creditsTeamId: teamId ?? creditsTeamId,
isOrganization: props.isOrganization,
}))}
options={actionOptions.map((option) => ({
...option,
creditsTeamId: teamId ?? creditsTeamId,
}))}
/>
);
}}
@@ -788,12 +815,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
/>
<div className="mt-1.5 flex items-center gap-1">
<Icon name="info" size="10" className="text-gray-500" />
<div className="text-subtle text-xs">{t("sender_id_info")}</div>
<div className="text-xs text-subtle">{t("sender_id_info")}</div>
</div>
</div>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sender && (
<p className="text-error mt-1 text-xs">{t("sender_id_error_message")}</p>
<p className="mt-1 text-xs text-error">{t("sender_id_error_message")}</p>
)}
</>
) : (
@@ -812,23 +839,23 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
)}
{isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && !stepAgentId && (
<div className="bg-muted border-muted mt-2 rounded-2xl border p-3">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center sm:gap-0">
<div className="p-3 mt-2 rounded-2xl border bg-muted border-muted">
<div className="flex flex-col gap-4 justify-between sm:flex-row sm:items-center sm:gap-0">
<div>
<h2 className="text-emphasis text-sm font-medium leading-none">
<h2 className="text-sm font-medium leading-none text-emphasis">
{t("cal_ai_agent")}
<Badge startIcon="info" className="ms-2 rounded-md" variant="warning">
<Badge startIcon="info" className="rounded-md ms-2" variant="warning">
{t("set_up_required")}
</Badge>
</h2>
<p className="text-muted mt-1 text-sm font-medium leading-none">
<p className="mt-1 text-sm font-medium leading-none text-muted">
{t("no_phone_number_connected")}.
</p>
</div>
<Button
color="primary"
disabled={props.readOnly || isCreatingAgent.current || hasAutoCreated.current}
className="flex items-center justify-center"
className="flex justify-center items-center"
onClick={() => handleCreateAgent()}
loading={createAgentMutation.isPending || isCreatingAgent.current}>
{t("set_up_agent")}
@@ -839,9 +866,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
{stepAgentId && isAgentLoading && <CalAIAgentDataSkeleton />}
{stepAgentId && agentData && (
<div className="bg-muted mt-4 rounded-lg p-4">
<div className="p-4 mt-4 rounded-lg bg-muted">
<div
className="flex cursor-pointer items-center justify-between"
className="flex justify-between items-center cursor-pointer"
onClick={(e) => {
const target = e.target as HTMLElement;
if (
@@ -856,11 +883,11 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
}
}}>
<div>
<h3 className="text-emphasis text-base font-medium">{t("cal_ai_agent")}</h3>
<h3 className="text-base font-medium text-emphasis">{t("cal_ai_agent")}</h3>
{arePhoneNumbersActive.length > 0 ? (
<div className="flex items-center gap-2">
<Icon name="phone" className="text-emphasis h-4 w-4" />
<span className="text-emphasis text-sm">
<div className="flex gap-2 items-center">
<Icon name="phone" className="w-4 h-4 text-emphasis" />
<span className="text-sm text-emphasis">
{formatPhoneNumber(arePhoneNumbersActive[0].phoneNumber)}
</span>
<Badge variant="green" size="sm" withDot>
@@ -868,12 +895,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</Badge>
</div>
) : (
<div className="flex items-center gap-1">
<span className="text-subtle text-sm">{t("no_phone_number_connected")}</span>
<div className="flex gap-1 items-center">
<span className="text-sm text-subtle">{t("no_phone_number_connected")}</span>
</div>
)}
</div>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1 items-center" onClick={(e) => e.stopPropagation()}>
{arePhoneNumbersActive.length > 0 ? (
<Dropdown>
<DropdownMenuTrigger asChild>
@@ -983,7 +1010,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
)}
{isPhoneNumberNeeded && (
<div className="bg-muted mt-2 rounded-md p-4 pt-0">
<div className="p-4 pt-0 mt-2 rounded-md bg-muted">
<Label className="pt-4">{t("custom_phone_number")}</Label>
<div className="block sm:flex">
<Controller
@@ -1023,7 +1050,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="text-error mt-1 text-xs">
<p className="mt-1 text-xs text-error">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
@@ -1034,9 +1061,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
) : (
!props.readOnly && (
<>
<div className="mt-6 flex w-full flex-col">
<div className="flex flex-col mt-6 w-full">
<Label className="">{t("verification_code")}</Label>
<div className="flex w-full items-center">
<div className="flex items-center w-full">
<TextField
containerClassName="w-full"
className="h-8 rounded-xl"
@@ -1066,7 +1093,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="text-error mt-1 text-xs">
<p className="mt-1 text-xs text-error">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
@@ -1096,9 +1123,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
)}
{isEmailAddressNeeded && !isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && (
<div className="bg-muted border-muted mt-5 rounded-2xl border p-4">
<div className="p-4 mt-5 rounded-2xl border bg-muted border-muted">
<Label>{t("email_address")}</Label>
<div className="block items-center gap-2 sm:flex">
<div className="block gap-2 items-center sm:flex">
<Controller
name={`steps.${step.stepNumber - 1}.sendTo`}
render={({ field: { value, onChange } }) => (
@@ -1124,7 +1151,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
size="sm"
disabled={emailVerified || props.readOnly || false}
className={classNames(
"min-w-fit text-sm font-medium",
"text-sm font-medium min-w-fit",
emailVerified ? "hidden" : "mt-3 sm:mt-0"
)}
onClick={() => {
@@ -1138,7 +1165,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</Button>
</div>
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="text-error mt-1 text-xs">
<p className="mt-1 text-xs text-error">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
@@ -1149,9 +1176,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
) : (
!props.readOnly && (
<>
<div className="mt-3 flex w-full flex-col">
<div className="flex flex-col mt-3 w-full">
<Label className="">{t("verification_code")}</Label>
<div className="flex w-full items-center">
<div className="flex items-center w-full">
<TextField
containerClassName="w-full"
className="h-8 rounded-xl"
@@ -1181,7 +1208,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="text-error mt-1 text-xs">
<p className="mt-1 text-xs text-error">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
@@ -1205,19 +1232,20 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
onChange={(val) => {
if (val) {
const action = form.getValues(`steps.${step.stepNumber - 1}.action`);
const value = val.value as WorkflowTemplates;
const template = getTemplateBodyForAction({
action,
locale: i18n.language,
t,
template: val.value ?? WorkflowTemplates.REMINDER,
template: value ?? WorkflowTemplates.REMINDER,
timeFormat,
});
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, template);
if (shouldScheduleEmailReminder(action)) {
if (val.value === WorkflowTemplates.REMINDER) {
if (value === WorkflowTemplates.REMINDER) {
form.setValue(
`steps.${step.stepNumber - 1}.emailSubject`,
emailReminderTemplate({
@@ -1228,7 +1256,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
timeFormat,
}).emailSubject
);
} else if (val.value === WorkflowTemplates.RATING) {
} else if (value === WorkflowTemplates.RATING) {
form.setValue(
`steps.${step.stepNumber - 1}.emailSubject`,
emailRatingTemplate({
@@ -1241,8 +1269,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
);
}
}
field.onChange(val.value);
form.setValue(`steps.${step.stepNumber - 1}.template`, val.value);
field.onChange(value);
form.setValue(`steps.${step.stepNumber - 1}.template`, value);
setUpdateTemplate(!updateTemplate);
}
}}
@@ -1255,10 +1283,11 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
option.needsTeamsUpgrade &&
!isSMSAction(form.getValues(`steps.${step.stepNumber - 1}.action`)),
}))}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
isOptionDisabled={(option: { label: string; value: any; needsTeamsUpgrade: boolean }) =>
option.needsTeamsUpgrade
}
isOptionDisabled={(option: {
label: string;
value: string;
needsTeamsUpgrade: boolean;
}) => option.needsTeamsUpgrade}
/>
);
}}
@@ -1266,14 +1295,18 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
)}
{!isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && (
<div className="bg-muted border-muted mt-3 rounded-2xl border p-3">
<div className="p-3 mt-3 rounded-2xl border bg-muted border-muted">
{isEmailSubjectNeeded && (
<div className="mb-6">
<div className="flex items-center">
<Label className={classNames("flex-none", props.readOnly ? "mb-2" : "mb-0")}>
<Label
className={classNames(
"flex-none",
props.readOnly || isFormTrigger(trigger) ? "mb-2" : "mb-0"
)}>
{t("email_subject")}
</Label>
{!props.readOnly && (
{!props.readOnly && !isFormTrigger(trigger) && (
<div className="flex-grow text-right">
<AddVariablesDropdown
addVariable={addVariableEmailSubject}
@@ -1295,26 +1328,24 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
/>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject && (
<p className="text-error mt-1 text-xs">
<p className="mt-1 text-xs text-error">
{form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject?.message || ""}
</p>
)}
</div>
)}
<div className="mb-2 flex items-center pb-1">
<Label className="mb-0 flex-none">
<div className="flex items-center pb-1 mb-2">
<Label className="flex-none mb-0">
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
</Label>
</div>
<Editor
getText={() => {
return props.form.getValues(`steps.${step.stepNumber - 1}.reminderBody`) || "";
}}
getText={() => props.form.getValues(`steps.${step.stepNumber - 1}.reminderBody`) || ""}
setText={(text: string) => {
props.form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, text);
props.form.clearErrors();
}}
variables={DYNAMIC_TEXT_VARIABLES}
variables={!isFormTrigger(trigger) ? DYNAMIC_TEXT_VARIABLES : undefined}
addVariableButtonTop={isSMSAction(step.action)}
height="200px"
updateTemplate={updateTemplate}
@@ -1333,7 +1364,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody && (
<p className="text-error mt-1 text-sm">
<p className="mt-1 text-sm text-error">
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
</p>
)}
@@ -1358,12 +1389,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
/>
</div>
)}
{!props.readOnly && (
<div className="ml-1 mt-2">
{!props.readOnly && !isFormTrigger(trigger) && (
<div className="mt-2 ml-1">
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>
<div className="text-subtle ml-1 flex items-center gap-2">
<Icon name="circle-help" className="h-3 w-3" />
<p className="text-left text-xs">{t("using_booking_questions_as_variables")}</p>
<div className="flex gap-2 items-center ml-1 text-subtle">
<Icon name="circle-help" className="w-3 h-3" />
<p className="text-xs text-left">{t("using_booking_questions_as_variables")}</p>
</div>
</button>
</div>
@@ -1472,23 +1503,23 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
<DialogContent enableOverflow type="creation" className="sm:max-w-[610px]">
<div>
<h1 className="w-full text-xl font-semibold">{t("how_booking_questions_as_variables")}</h1>
<div className="bg-muted-3 mb-6 rounded-md sm:p-4">
<p className="test-sm font-medium">{t("format")}</p>
<ul className="text-emphasis ml-5 mt-2 list-disc">
<div className="mb-6 rounded-md bg-muted-3 sm:p-4">
<p className="font-medium test-sm">{t("format")}</p>
<ul className="mt-2 ml-5 list-disc text-emphasis">
<li>{t("uppercase_for_letters")}</li>
<li>{t("replace_whitespaces_underscores")}</li>
<li>{t("ignore_special_characters_booking_questions")}</li>
</ul>
<div className="mt-4">
<p className="test-sm w-full font-medium">{t("example_1")}</p>
<div className="mt-2 grid grid-cols-12">
<div className="test-sm text-default col-span-5 ltr:mr-2 rtl:ml-2">
<p className="w-full font-medium test-sm">{t("example_1")}</p>
<div className="grid grid-cols-12 mt-2">
<div className="col-span-5 test-sm text-default ltr:mr-2 rtl:ml-2">
{t("booking_question_identifier")}
</div>
<div className="test-sm text-emphasis col-span-7">{t("company_size")}</div>
<div className="test-sm text-default col-span-5 w-full">{t("variable")}</div>
<div className="col-span-7 test-sm text-emphasis">{t("company_size")}</div>
<div className="col-span-5 w-full test-sm text-default">{t("variable")}</div>
<div className="test-sm text-emphasis col-span-7 break-words">
<div className="col-span-7 break-words test-sm text-emphasis">
{" "}
{`{${t("company_size")
.replace(/[^a-zA-Z0-9 ]/g, "")
@@ -1499,14 +1530,14 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
</div>
<div className="mt-4">
<p className="test-sm w-full font-medium">{t("example_2")}</p>
<div className="mt-2 grid grid-cols-12">
<div className="test-sm text-default col-span-5 ltr:mr-2 rtl:ml-2">
<p className="w-full font-medium test-sm">{t("example_2")}</p>
<div className="grid grid-cols-12 mt-2">
<div className="col-span-5 test-sm text-default ltr:mr-2 rtl:ml-2">
{t("booking_question_identifier")}
</div>
<div className="test-sm text-emphasis col-span-7">{t("what_help_needed")}</div>
<div className="test-sm text-default col-span-5">{t("variable")}</div>
<div className="test-sm text-emphasis col-span-7 break-words">
<div className="col-span-7 test-sm text-emphasis">{t("what_help_needed")}</div>
<div className="col-span-5 test-sm text-default">{t("variable")}</div>
<div className="col-span-7 break-words test-sm text-emphasis">
{" "}
{`{${t("what_help_needed")
.replace(/[^a-zA-Z0-9 ]/g, "")
@@ -1576,17 +1607,17 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
<Dialog open={isUnsubscribeDialogOpen} onOpenChange={setIsUnsubscribeDialogOpen}>
<DialogContent type="creation" title={t("unsubscribe_phone_number")}>
<div className="space-y-4">
<p className="text-default text-sm">{t("do_you_still_want_to_unsubscribe")}</p>
<p className="text-sm text-default">{t("do_you_still_want_to_unsubscribe")}</p>
{getActivePhoneNumbers(
agentData?.outboundPhoneNumbers?.map((phone) => ({
...phone,
subscriptionStatus: phone.subscriptionStatus ?? undefined,
}))
).length > 0 && (
<div className="bg-muted rounded-lg p-3">
<div className="flex items-center gap-2">
<Icon name="phone" className="text-emphasis h-4 w-4" />
<span className="text-emphasis text-sm font-medium">
<div className="p-3 rounded-lg bg-muted">
<div className="flex gap-2 items-center">
<Icon name="phone" className="w-4 h-4 text-emphasis" />
<span className="text-sm font-medium text-emphasis">
{formatPhoneNumber(
getActivePhoneNumbers(
agentData?.outboundPhoneNumbers?.map((phone) => ({
@@ -1599,7 +1630,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
</div>
)}
<p className="text-subtle text-sm">{t("the_action_will_disconnect_phone_number")}</p>
<p className="text-sm text-subtle">{t("the_action_will_disconnect_phone_number")}</p>
</div>
<DialogFooter showDivider>
<Button type="button" color="secondary" onClick={() => setIsUnsubscribeDialogOpen(false)}>
@@ -1634,7 +1665,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
<Dialog open={isDeleteStepDialogOpen} onOpenChange={setIsDeleteStepDialogOpen}>
<DialogContent type="confirmation" title={t("delete_workflow_step")}>
<div className="space-y-4">
<p className="text-default text-sm">{t("are_you_sure_you_want_to_delete_workflow_step")}</p>
<p className="text-sm text-default">{t("are_you_sure_you_want_to_delete_workflow_step")}</p>
{(() => {
const relevantPhoneNumbers =
agentData?.outboundPhoneNumbers?.filter(
@@ -1644,12 +1675,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
return (
relevantPhoneNumbers.length > 0 && (
<>
<div className="bg-attention rounded-lg p-3">
<div className="flex items-start gap-2">
<div className="p-3 rounded-lg bg-attention">
<div className="flex gap-2 items-start">
<Icon name="info" className="text-attention mt-0.5 h-4 w-4" />
<div className="space-y-2">
<p className="text-attention text-sm font-medium">{t("this_action_will_also")}</p>
<ul className="text-attention list-inside list-disc space-y-1 text-sm">
<p className="text-sm font-medium text-attention">{t("this_action_will_also")}</p>
<ul className="space-y-1 text-sm list-disc list-inside text-attention">
{relevantPhoneNumbers.some(
(phone) => phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE
) && <li>{t("cancel_your_phone_number_subscription")}</li>}
@@ -1659,10 +1690,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
</div>
{relevantPhoneNumbers.map((phone) => (
<div key={phone.phoneNumber} className="bg-muted rounded-lg p-3">
<div className="flex items-center gap-2">
<Icon name="phone" className="text-emphasis h-4 w-4" />
<span className="text-emphasis text-sm font-medium">
<div key={phone.phoneNumber} className="p-3 rounded-lg bg-muted">
<div className="flex gap-2 items-center">
<Icon name="phone" className="w-4 h-4 text-emphasis" />
<span className="text-sm font-medium text-emphasis">
{formatPhoneNumber(phone.phoneNumber)}
</span>
{phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE && (
@@ -1,8 +1,7 @@
import type { TFunction } from "i18next";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import type { WorkflowTriggerEvents } from "@calcom/prisma/client";
import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import {
whatsappEventCancelledTemplate,
@@ -10,9 +9,11 @@ import {
whatsappEventRescheduledTemplate,
whatsappReminderTemplate,
} from "../lib/reminders/templates/whatsapp";
import { FORM_TRIGGER_WORKFLOW_EVENTS } from "./constants";
import emailRatingTemplate from "./reminders/templates/emailRatingTemplate";
import emailReminderTemplate from "./reminders/templates/emailReminderTemplate";
import smsReminderTemplate from "./reminders/templates/smsReminderTemplate";
import type { WorkflowStep } from "./types";
export function shouldScheduleEmailReminder(action: WorkflowActions) {
return action === WorkflowActions.EMAIL_ATTENDEE || action === WorkflowActions.EMAIL_HOST;
@@ -141,3 +142,11 @@ export function getTemplateBodyForAction({
const templateFunction = getEmailTemplateFunction(template);
return templateFunction({ isEditingMode: true, locale, t, action, timeFormat }).emailBody;
}
export function isFormTrigger(trigger: WorkflowTriggerEvents) {
return FORM_TRIGGER_WORKFLOW_EVENTS.includes(trigger);
}
export function hasCalAIAction(steps: WorkflowStep[]) {
return steps.some((step) => isCalAIAction(step.action));
}
@@ -8,6 +8,7 @@ export const WORKFLOW_TRIGGER_EVENTS = [
WorkflowTriggerEvents.RESCHEDULE_EVENT,
WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
WorkflowTriggerEvents.FORM_SUBMITTED,
WorkflowTriggerEvents.BOOKING_REJECTED,
WorkflowTriggerEvents.BOOKING_REQUESTED,
WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED,
@@ -94,4 +95,12 @@ export const IMMEDIATE_WORKFLOW_TRIGGER_EVENTS: WorkflowTriggerEvents[] = [
WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED,
WorkflowTriggerEvents.BOOKING_REJECTED,
WorkflowTriggerEvents.BOOKING_REQUESTED,
WorkflowTriggerEvents.FORM_SUBMITTED,
];
export const FORM_TRIGGER_WORKFLOW_EVENTS: WorkflowTriggerEvents[] = [WorkflowTriggerEvents.FORM_SUBMITTED];
export const ALLOWED_FORM_WORKFLOW_ACTIONS = [
WorkflowActions.EMAIL_ATTENDEE,
WorkflowActions.EMAIL_ADDRESS,
] as const;
@@ -1,4 +1,5 @@
import prisma from "@calcom/prisma";
import type { WorkflowType } from "@calcom/prisma/client";
import type { Workflow } from "./types";
@@ -27,20 +28,31 @@ export const workflowSelect = {
},
};
export const getAllWorkflows = async (
eventTypeWorkflows: Workflow[],
userId?: number | null,
teamId?: number | null,
orgId?: number | null,
workflowsLockedForUser = true
) => {
const allWorkflows = eventTypeWorkflows;
export const getAllWorkflows = async ({
entityWorkflows,
userId,
teamId,
orgId,
workflowsLockedForUser = true,
type,
}: {
entityWorkflows: Workflow[];
userId?: number | null;
teamId?: number | null;
orgId?: number | null;
workflowsLockedForUser?: boolean;
type: WorkflowType;
}) => {
const allWorkflows = entityWorkflows;
if (orgId) {
if (teamId) {
const orgTeamWorkflowsRel = await prisma.workflowsOnTeams.findMany({
where: {
teamId: teamId,
workflow: {
type,
},
},
select: {
workflow: {
@@ -54,6 +66,9 @@ export const getAllWorkflows = async (
} else if (userId) {
const orgUserWorkflowsRel = await prisma.workflowsOnTeams.findMany({
where: {
workflow: {
type,
},
team: {
members: {
some: {
@@ -79,6 +94,7 @@ export const getAllWorkflows = async (
where: {
teamId: orgId,
isActiveOnAll: true,
type,
},
select: workflowSelect,
});
@@ -90,6 +106,7 @@ export const getAllWorkflows = async (
where: {
teamId,
isActiveOnAll: true,
type,
},
select: workflowSelect,
});
@@ -102,6 +119,7 @@ export const getAllWorkflows = async (
userId,
teamId: null,
isActiveOnAll: true,
type,
},
select: workflowSelect,
});
@@ -8,6 +8,7 @@ import {
isWhatsappAction,
isEmailToAttendeeAction,
isCalAIAction,
isFormTrigger,
} from "./actionHelperFunctions";
import {
WHATSAPP_WORKFLOW_TEMPLATES,
@@ -45,22 +46,36 @@ export function getWorkflowTriggerOptions(t: TFunction) {
});
}
function convertToTemplateOptions(
t: TFunction,
hasPaidPlan: boolean,
templates: readonly WorkflowTemplates[]
) {
return templates.map((template) => {
return {
label: t(`${template.toLowerCase()}`),
value: template,
needsTeamsUpgrade: !hasPaidPlan,
} as { label: string; value: any; needsTeamsUpgrade: boolean };
});
}
export function getWorkflowTemplateOptions(
t: TFunction,
action: WorkflowActions | undefined,
hasPaidPlan: boolean
hasPaidPlan: boolean,
trigger: WorkflowTriggerEvents
) {
if (isFormTrigger(trigger)) {
return convertToTemplateOptions(t, hasPaidPlan, [WorkflowTemplates.CUSTOM]);
}
const TEMPLATES =
action && isWhatsappAction(action)
? WHATSAPP_WORKFLOW_TEMPLATES
: action && isEmailToAttendeeAction(action)
? ATTENDEE_WORKFLOW_TEMPLATES
: BASIC_WORKFLOW_TEMPLATES;
return TEMPLATES.map((template) => {
return {
label: t(`${template.toLowerCase()}`),
value: template,
needsTeamsUpgrade: !hasPaidPlan && template == WorkflowTemplates.CUSTOM,
};
}) as { label: string; value: any; needsTeamsUpgrade: boolean }[];
return convertToTemplateOptions(t, hasPaidPlan, TEMPLATES);
}
@@ -8,6 +8,7 @@ import tasker from "@calcom/features/tasker";
import { WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { TimeUnit } from "@calcom/prisma/enums";
import {
@@ -20,6 +21,7 @@ import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { getWorkflowRecipientEmail } from "../getWorkflowReminders";
import { sendOrScheduleWorkflowEmails } from "./providers/emailProvider";
import type { FormSubmissionData } from "./reminderScheduler";
import type { AttendeeInBookingInfo, BookingInfo, timeUnitLowerCase } from "./smsReminderManager";
import type { VariablesType } from "./templates/customTemplate";
import customTemplate from "./templates/customTemplate";
@@ -33,8 +35,7 @@ type ScheduleEmailReminderAction = Extract<
"EMAIL_HOST" | "EMAIL_ATTENDEE" | "EMAIL_ADDRESS"
>;
export interface ScheduleReminderArgs {
evt: BookingInfo;
export type ScheduleReminderArgs = {
triggerEvent: WorkflowTriggerEvents;
timeSpan: {
time: number | null;
@@ -44,10 +45,15 @@ export interface ScheduleReminderArgs {
sender?: string | null;
workflowStepId?: number;
seatReferenceUid?: string;
}
} & (
| { evt: BookingInfo; formData?: never }
| {
evt?: never;
formData: FormSubmissionData;
}
);
interface scheduleEmailReminderArgs extends ScheduleReminderArgs {
evt: BookingInfo;
type scheduleEmailReminderArgs = ScheduleReminderArgs & {
sendTo: string[];
action: ScheduleEmailReminderAction;
emailSubject?: string;
@@ -55,9 +61,64 @@ interface scheduleEmailReminderArgs extends ScheduleReminderArgs {
hideBranding?: boolean;
includeCalendarEvent?: boolean;
verifiedAt: Date | null;
}
};
type SendEmailReminderParams = {
mailData: {
subject: string;
html: string;
replyTo?: string;
attachments?: any[];
sender?: string | null;
};
sendTo: string[];
triggerEvent: WorkflowTriggerEvents;
scheduledDate?: dayjs.Dayjs | null;
uid?: string;
workflowStepId?: number;
seatReferenceUid?: string;
};
const sendOrScheduleWorkflowEmailWithReminder = async (params: SendEmailReminderParams) => {
const { mailData, sendTo, scheduledDate, uid, workflowStepId } = params;
let reminderUid = undefined;
if (scheduledDate) {
const reminder = await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId,
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
},
});
reminderUid = reminder.uuid;
}
await sendOrScheduleWorkflowEmails({
...mailData,
to: sendTo,
sendAt: scheduledDate?.toDate(),
referenceUid: reminderUid ?? undefined,
});
};
export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) => {
const { verifiedAt, workflowStepId } = args;
if (!verifiedAt) {
log.warn(`Workflow step ${workflowStepId} not yet verified`);
return;
}
if (args.evt) {
await scheduleEmailReminderForEvt(args);
} else {
await scheduleEmailReminderForForm(args);
}
};
const scheduleEmailReminderForEvt = async (args: scheduleEmailReminderArgs & { evt: BookingInfo }) => {
const {
evt,
triggerEvent,
@@ -72,14 +133,8 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
hideBranding,
includeCalendarEvent,
action,
verifiedAt,
} = args;
if (!verifiedAt) {
log.warn(`Workflow step ${workflowStepId} not yet verified`);
return;
}
const { startTime, endTime } = evt;
const uid = evt.uid as string;
const timeUnit: timeUnitLowerCase | undefined = timeSpan.timeUnit?.toLocaleLowerCase() as timeUnitLowerCase;
@@ -274,25 +329,67 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
const mailData = await prepareEmailData();
let reminderUid = undefined;
if (scheduledDate) {
const reminder = await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId,
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
},
});
reminderUid = reminder.uuid;
await sendOrScheduleWorkflowEmailWithReminder({
mailData,
sendTo,
triggerEvent,
scheduledDate,
uid,
workflowStepId,
seatReferenceUid,
});
};
const scheduleEmailReminderForForm = async (
args: scheduleEmailReminderArgs & {
formData: FormSubmissionData;
}
) => {
const {
formData,
triggerEvent,
sender,
workflowStepId,
sendTo,
emailSubject = "",
emailBody = "",
hideBranding,
} = args;
const emailContent = {
emailSubject,
emailBody: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
};
if (emailBody) {
const timeFormat = getTimeFormatStringFromUserTimeFormat(formData.user.timeFormat);
//todo: add variables
const emailSubjectTemplate = customTemplate(emailSubject, {}, formData.user.locale, timeFormat);
emailContent.emailSubject = emailSubjectTemplate.text;
emailContent.emailBody = customTemplate(
emailBody,
{},
formData.user.locale,
timeFormat,
hideBranding
).html;
}
await sendOrScheduleWorkflowEmails({
...mailData,
to: sendTo,
sendAt: scheduledDate?.toDate(),
referenceUid: reminderUid ?? undefined,
// Allows debugging generated email content without waiting for sendgrid to send emails
log.debug(`Sending Email for trigger ${triggerEvent}`, JSON.stringify(emailContent));
const mailData = {
subject: emailContent.emailSubject,
html: emailContent.emailBody,
sender,
};
await sendOrScheduleWorkflowEmailWithReminder({
mailData,
sendTo,
triggerEvent,
workflowStepId,
scheduledDate: null,
});
};
@@ -1,3 +1,4 @@
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import {
isAttendeeAction,
isSMSAction,
@@ -8,11 +9,13 @@ import {
import { sendOrScheduleWorkflowEmails } from "@calcom/features/ee/workflows/lib/reminders/providers/emailProvider";
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { SENDER_NAME } from "@calcom/lib/constants";
import { formatCalEventExtended } from "@calcom/lib/formatCalendarEvent";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { getTranslation } from "@calcom/lib/server/i18n";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
@@ -20,10 +23,19 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
import { scheduleAIPhoneCall } from "./aiPhoneCallManager";
import { scheduleEmailReminder } from "./emailReminderManager";
import { scheduleSMSReminder } from "./smsReminderManager";
import type { ScheduleTextReminderAction } from "./smsReminderManager";
import type { BookingInfo } from "./smsReminderManager";
import { scheduleSMSReminder, type ScheduleTextReminderAction } from "./smsReminderManager";
import { scheduleWhatsappReminder } from "./whatsappReminderManager";
export type FormSubmissionData = {
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
user: {
email: string;
timeFormat: number | null;
locale: string;
};
};
export type ExtendedCalendarEvent = Omit<CalendarEvent, "bookerUrl"> & {
metadata?: { videoCallUrl: string | undefined };
eventType: {
@@ -36,18 +48,23 @@ export type ExtendedCalendarEvent = Omit<CalendarEvent, "bookerUrl"> & {
bookerUrl: string;
};
type ProcessWorkflowStepParams = {
type ProcessWorkflowStepParams = (
| { calendarEvent: ExtendedCalendarEvent; formData?: never }
| {
calendarEvent?: never;
formData: FormSubmissionData;
}
) & {
smsReminderNumber: string | null;
calendarEvent: ExtendedCalendarEvent;
emailAttendeeSendToOverride?: string;
hideBranding?: boolean;
seatReferenceUid?: string;
};
export interface ScheduleWorkflowRemindersArgs extends ProcessWorkflowStepParams {
export type ScheduleWorkflowRemindersArgs = ProcessWorkflowStepParams & {
workflows: Workflow[];
isDryRun?: boolean;
}
};
const processWorkflowStep = async (
workflow: Workflow,
@@ -58,11 +75,21 @@ const processWorkflowStep = async (
emailAttendeeSendToOverride,
hideBranding,
seatReferenceUid,
formData,
}: ProcessWorkflowStepParams
) => {
if (!step?.verifiedAt) return;
const evt = formatCalEventExtended(calendarEvent);
const evt = calendarEvent ? formatCalEventExtended(calendarEvent) : undefined;
if (!evt && !formData) return;
const contextData:
| { evt: BookingInfo; formData?: never }
| {
evt?: never;
formData: FormSubmissionData;
} = evt ? { evt } : { formData: formData as FormSubmissionData };
if (isSMSOrWhatsappAction(step.action)) {
await checkSMSRateLimit({
@@ -71,26 +98,36 @@ const processWorkflowStep = async (
});
}
// Common parameters for all scheduling functions
const scheduleFunctionParams = {
triggerEvent: workflow.trigger,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
workflowStepId: step.id,
template: step.template,
userId: workflow.userId,
teamId: workflow.teamId,
seatReferenceUid,
verifiedAt: step.verifiedAt,
};
if (isSMSAction(step.action)) {
if (!evt) {
// SMS action not not yet supported for form triggers
return;
}
const sendTo = step.action === WorkflowActions.SMS_ATTENDEE ? smsReminderNumber : step.sendTo;
await scheduleSMSReminder({
evt,
...scheduleFunctionParams,
reminderPhone: sendTo,
triggerEvent: workflow.trigger,
action: step.action as ScheduleTextReminderAction,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
message: step.reminderBody || "",
workflowStepId: step.id,
template: step.template,
sender: step.sender,
userId: workflow.userId,
teamId: workflow.teamId,
isVerificationPending: step.numberVerificationPending,
seatReferenceUid,
verifiedAt: step.verifiedAt,
evt,
});
} else if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||
@@ -103,7 +140,12 @@ const processWorkflowStep = async (
case WorkflowActions.EMAIL_ADDRESS:
sendTo = [step.sendTo || ""];
break;
case WorkflowActions.EMAIL_HOST:
case WorkflowActions.EMAIL_HOST: {
if (!evt) {
// EMAIL_HOST is not supported for form triggers
return;
}
sendTo = [evt.organizer?.email || ""];
const schedulingType = evt.eventType.schedulingType;
@@ -113,74 +155,71 @@ const processWorkflowStep = async (
sendTo = sendTo.concat(evt.team.members.map((member) => member.email));
}
break;
}
case WorkflowActions.EMAIL_ATTENDEE:
const attendees = !!emailAttendeeSendToOverride
? [emailAttendeeSendToOverride]
: evt.attendees?.map((attendee) => attendee.email);
if (evt) {
const attendees = emailAttendeeSendToOverride
? [emailAttendeeSendToOverride]
: evt.attendees?.map((attendee) => attendee.email);
const limitGuestsDate = new Date("2025-01-13");
const limitGuestsDate = new Date("2025-01-13");
if (workflow.userId) {
const user = await prisma.user.findUnique({
where: {
id: workflow.userId,
},
select: {
createdDate: true,
},
});
if (user?.createdDate && user.createdDate > limitGuestsDate) {
sendTo = attendees.slice(0, 1);
if (workflow.userId) {
const userRepository = new UserRepository(prisma);
const user = await userRepository.findById({ id: workflow.userId });
if (user?.createdDate && user.createdDate > limitGuestsDate) {
sendTo = attendees.slice(0, 1);
} else {
sendTo = attendees;
}
} else {
sendTo = attendees;
}
} else {
sendTo = attendees;
}
break;
if (formData) {
const submitterEmail = getSubmitterEmail(formData.responses);
if (submitterEmail) {
sendTo = [submitterEmail];
}
}
}
await scheduleEmailReminder({
evt,
triggerEvent: workflow.trigger,
const emailParams = {
...scheduleFunctionParams,
action: step.action,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
sendTo,
emailSubject: step.emailSubject || "",
emailBody: step.reminderBody || "",
template: step.template,
sender: step.sender || SENDER_NAME,
workflowStepId: step.id,
hideBranding,
seatReferenceUid,
includeCalendarEvent: step.includeCalendarEvent,
...contextData,
verifiedAt: step.verifiedAt,
});
} as const;
await scheduleEmailReminder(emailParams);
} else if (isWhatsappAction(step.action)) {
if (!evt) {
// Whatsapp action not not yet supported for form triggers
return;
}
const sendTo = step.action === WorkflowActions.WHATSAPP_ATTENDEE ? smsReminderNumber : step.sendTo;
await scheduleWhatsappReminder({
evt,
...scheduleFunctionParams,
reminderPhone: sendTo,
triggerEvent: workflow.trigger,
action: step.action as ScheduleTextReminderAction,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
message: step.reminderBody || "",
workflowStepId: step.id,
template: step.template,
userId: workflow.userId,
teamId: workflow.teamId,
isVerificationPending: step.numberVerificationPending,
seatReferenceUid,
verifiedAt: step.verifiedAt,
evt,
});
} else if (isCalAIAction(step.action)) {
if (!evt) {
// cal.ai not yet supported for form triggers
return;
}
await scheduleAIPhoneCall({
evt,
triggerEvent: workflow.trigger,
@@ -206,6 +245,7 @@ const _scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersArgs) =
hideBranding,
seatReferenceUid,
isDryRun = false,
formData,
} = args;
if (isDryRun || !workflows.length) return;
@@ -214,11 +254,11 @@ const _scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersArgs) =
for (const step of workflow.steps) {
await processWorkflowStep(workflow, step, {
calendarEvent: evt,
emailAttendeeSendToOverride,
smsReminderNumber,
hideBranding,
seatReferenceUid,
...(evt ? { calendarEvent: evt } : { formData }),
});
}
}
@@ -76,7 +76,8 @@ export type ScheduleTextReminderAction = Extract<
WorkflowActions,
"SMS_ATTENDEE" | "SMS_NUMBER" | "WHATSAPP_ATTENDEE" | "WHATSAPP_NUMBER"
>;
export interface ScheduleTextReminderArgs extends ScheduleReminderArgs {
export type ScheduleTextReminderArgs = ScheduleReminderArgs & {
reminderPhone: string | null;
message: string;
action: ScheduleTextReminderAction;
@@ -85,9 +86,8 @@ export interface ScheduleTextReminderArgs extends ScheduleReminderArgs {
isVerificationPending?: boolean;
prisma?: PrismaClient;
verifiedAt: Date | null;
}
export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
};
export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs & { evt: BookingInfo }) => {
const {
evt,
reminderPhone,
@@ -16,7 +16,7 @@ import {
getContentVariablesForTemplate,
} from "../reminders/templates/whatsapp/ContentSidMapping";
import { scheduleSmsOrFallbackEmail, sendSmsOrFallbackEmail } from "./messageDispatcher";
import type { ScheduleTextReminderArgs, timeUnitLowerCase } from "./smsReminderManager";
import type { BookingInfo, ScheduleTextReminderArgs, timeUnitLowerCase } from "./smsReminderManager";
import {
whatsappEventCancelledTemplate,
whatsappEventCompletedTemplate,
@@ -26,7 +26,7 @@ import {
const log = logger.getSubLogger({ prefix: ["[whatsappReminderManager]"] });
export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) => {
export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs & { evt: BookingInfo }) => {
const {
evt,
reminderPhone,
@@ -31,4 +31,20 @@ export class WorkflowReminderRepository {
},
});
}
static async findWorkflowRemindersByStepId(workflowStepId: number) {
return await prisma.workflowReminder.findMany({
where: { workflowStepId },
select: {
id: true,
referenceId: true,
method: true,
booking: {
select: {
eventTypeId: true,
},
},
},
});
}
}
@@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useForm, useWatch } from "react-hook-form";
import { Toaster } from "sonner";
import { SENDER_ID } from "@calcom/lib/constants";
@@ -31,7 +31,12 @@ import LicenseRequired from "../../common/components/LicenseRequired";
import { DeleteDialog } from "../components/DeleteDialog";
import SkeletonLoader from "../components/SkeletonLoaderEdit";
import WorkflowDetailsPage from "../components/WorkflowDetailsPage";
import { isSMSAction, isSMSOrWhatsappAction, isCalAIAction } from "../lib/actionHelperFunctions";
import {
isSMSAction,
isSMSOrWhatsappAction,
isCalAIAction,
isFormTrigger,
} from "../lib/actionHelperFunctions";
import { formSchema } from "../lib/schema";
import { getTranslatedText, translateVariablesToEnglish } from "../lib/variableTranslations";
@@ -80,6 +85,11 @@ function WorkflowPage({
const utils = trpc.useUtils();
const watchedTrigger = useWatch({
control: form.control,
name: "trigger",
});
const userQuery = useMeQuery();
const user = userQuery.data;
@@ -119,12 +129,13 @@ function WorkflowPage({
const teamId = workflow?.teamId ?? undefined;
const { data, isPending: isPendingEventTypes } = trpc.viewer.eventTypes.getTeamAndEventTypeOptions.useQuery(
const { data, isPending: isPendingEventTypes } = trpc.viewer.eventTypes.getActiveOnOptions.useQuery(
{ teamId, isOrg },
{ enabled: !isPendingWorkflow }
);
const teamOptions = data?.teamOptions ?? [];
const routingFormOptions = data?.routingFormOptions ?? [];
let allEventTypeOptions = data?.eventTypeOptions ?? [];
const distinctEventTypes = new Set();
@@ -223,7 +234,11 @@ function WorkflowPage({
let activeOn;
if (workflowData.isActiveOnAll) {
activeOn = isOrg ? teamOptions : allEventTypeOptions;
activeOn = isOrg
? teamOptions
: isFormTrigger(workflowData.trigger)
? routingFormOptions
: allEventTypeOptions;
} else {
if (isOrg) {
activeOn = workflowData.activeOnTeams.flatMap((active) => {
@@ -233,6 +248,14 @@ function WorkflowPage({
};
});
setSelectedOptions(activeOn || []);
} else if (isFormTrigger(workflowData.trigger)) {
activeOn = workflowData.activeOnRoutingForms?.flatMap((active) => {
return {
value: String(active.routingForm.id) || "",
label: active.routingForm.name || "",
};
});
setSelectedOptions(activeOn || []);
} else {
setSelectedOptions(
workflowData.activeOn?.flatMap((active) => {
@@ -309,7 +332,6 @@ function WorkflowPage({
});
const validateAndSubmitWorkflow = async (values: FormValues): Promise<void> => {
let activeOnIds: number[] = [];
let isEmpty = false;
let isVerified = true;
@@ -367,18 +389,26 @@ function WorkflowPage({
});
if (!isEmpty && isVerified) {
if (values.activeOn) {
activeOnIds = values.activeOn
let activeOnEventTypeOrTeamIds: number[] = [];
let activeOnRoutingFormIds: string[] = [];
if (isOrg || !isFormTrigger(values.trigger)) {
activeOnEventTypeOrTeamIds = values.activeOn
.filter((option) => option.value !== "all")
.map((option) => {
return parseInt(option.value, 10);
});
} else {
// Form triggers, activeOn contains routing form IDs (strings)
activeOnRoutingFormIds = values.activeOn
.filter((option) => option.value !== "all")
.map((option) => option.value);
}
await updateMutation.mutateAsync({
id: workflowId,
name: values.name,
activeOn: activeOnIds,
activeOnEventTypeIds: activeOnEventTypeOrTeamIds,
activeOnRoutingFormIds,
steps: values.steps,
trigger: values.trigger,
time: values.time || null,
@@ -497,7 +527,7 @@ function WorkflowPage({
</div>
</div>
<div className="bg-default min-h-screen w-full px-2 sm:p-0">
<div className="mx-auto my-8 max-w-4xl ">
<div className="mx-auto my-8 max-w-4xl">
{!isError ? (
<>
{isAllDataLoaded && user ? (
@@ -510,7 +540,13 @@ function WorkflowPage({
setSelectedOptions={setSelectedOptions}
teamId={workflow ? workflow.teamId || undefined : undefined}
isOrg={isOrg}
allOptions={isOrg ? teamOptions : allEventTypeOptions}
allOptions={
isOrg
? teamOptions
: isFormTrigger(watchedTrigger)
? routingFormOptions
: allEventTypeOptions
}
onSaveWorkflow={handleSaveWorkflow}
permissions={permissions}
/>
@@ -100,14 +100,14 @@ const WorkflowListItem = (props: ItemProps) => {
return (
<div className="border-subtle w-full overflow-hidden rounded-md border p-6 px-3 md:p-6">
<div className="flex items-center ">
<div className="flex items-center">
<div className="bg-subtle mr-4 flex h-10 w-10 items-center justify-center rounded-full text-xs font-medium">
{getActionIcon(
workflow.steps,
isActive ? "h-6 w-6 stroke-[1.5px] text-default" : "h-6 w-6 stroke-[1.5px] text-muted"
)}
</div>
<div className=" grow">
<div className="grow">
<div
className={classNames(
"text-emphasis mb-1 w-full truncate text-base font-medium leading-4 md:max-w-max",
@@ -122,7 +122,7 @@ const WorkflowListItem = (props: ItemProps) => {
<>
<div
className={classNames(
" flex w-fit items-center whitespace-nowrap rounded-sm text-sm leading-4",
"flex w-fit items-center whitespace-nowrap rounded-sm text-sm leading-4",
isActive ? "text-default" : "text-muted"
)}>
<span className="mr-1">{t("to")}:</span>
@@ -191,6 +191,7 @@ function EventWorkflowsTab(props: Props) {
const { data, isPending } = trpc.viewer.workflows.list.useQuery({
teamId: eventType.team?.id,
userId: !isChildrenManagedEventType ? eventType.userId || undefined : undefined,
includeOnlyEventTypeWorkflows: true,
});
const router = useRouter();
const [sortedWorkflows, setSortedWorkflows] = useState<Array<WorkflowType>>([]);
@@ -15,6 +15,13 @@ export type ResponseData = {
};
};
export function getSubmitterEmail(responses: any) {
return Object.values(responses).find((response): response is { value: string; label: string } => {
const value = typeof response === "object" && response && "value" in response ? response.value : response;
return typeof value === "string" && value.includes("@");
})?.value;
}
export const ZTriggerFormSubmittedNoEventWebhookPayloadSchema = z.object({
webhook: z.object({
subscriberUrl: z.string().url(),
@@ -70,13 +77,7 @@ export async function triggerFormSubmittedNoEventWebhook(payload: string): Promi
},
})) ?? [];
const emailValue = Object.values(responses).find(
(response): response is { value: string; label: string } => {
const value =
typeof response === "object" && response && "value" in response ? response.value : response;
return typeof value === "string" && value.includes("@");
}
)?.value;
const emailValue = getSubmitterEmail(responses);
// Check for duplicate email in recent responses
const hasDuplicate =
emailValue &&
+41
View File
@@ -1,7 +1,13 @@
import { prisma } from "@calcom/prisma";
import logger from "./logger";
import { ProfileRepository } from "./server/repository/profile";
import { TeamRepository } from "./server/repository/team";
import { UserRepository } from "./server/repository/user";
const log = logger.getSubLogger({ name: "hideBranding" });
const teamRepository = new TeamRepository(prisma);
const userRepository = new UserRepository(prisma);
type Team = {
hideBranding: boolean | null;
parent: {
@@ -39,6 +45,41 @@ function resolveHideBranding(options: {
return options.entityHideBranding ?? false;
}
/**
* Get hideBranding value for a user or team by their IDs
*/
export async function getHideBranding({
userId,
teamId,
}: {
userId?: number;
teamId?: number;
}): Promise<boolean> {
if (teamId) {
// Get team data with parent organization
const team = await teamRepository.findTeamWithParentHideBranding({ teamId });
if (!team) return false;
return resolveHideBranding({
entityHideBranding: team.hideBranding,
organizationHideBranding: team.parent?.hideBranding ?? null,
});
} else if (userId) {
// Get user data with profile and organization
const user = await userRepository.findUserWithHideBranding({ userId });
if (!user) return false;
return resolveHideBranding({
entityHideBranding: user.hideBranding,
organizationHideBranding: user.profiles?.[0]?.organization?.hideBranding,
});
}
return false;
}
/**
* Determines if branding should be hidden for an event that could be a team event or user event
*/
@@ -454,15 +454,7 @@ export class PrismaAgentRepository {
});
}
async findByIdWithAdminAccess({
id,
userId,
teamId,
}: {
id: string;
userId: number;
teamId?: number;
}) {
async findByIdWithAdminAccess({ id, userId, teamId }: { id: string; userId: number; teamId?: number }) {
const adminTeamIds = await this.getUserAdminTeamIds(userId);
let whereCondition: Prisma.Sql;
@@ -570,13 +562,7 @@ export class PrismaAgentRepository {
});
}
async linkInboundAgentToWorkflow({
workflowStepId,
agentId,
}: {
workflowStepId: number;
agentId: string;
}) {
async linkInboundAgentToWorkflow({ workflowStepId, agentId }: { workflowStepId: number; agentId: string }) {
return await this.prismaClient.workflowStep.update({
where: {
id: workflowStepId,
@@ -598,13 +584,7 @@ export class PrismaAgentRepository {
});
}
async canManageTeamResources({
userId,
teamId,
}: {
userId: number;
teamId: number;
}): Promise<boolean> {
async canManageTeamResources({ userId, teamId }: { userId: number; teamId: number }): Promise<boolean> {
const result = await this.prismaClient.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count
FROM "Membership"
@@ -616,4 +596,27 @@ export class PrismaAgentRepository {
return Number(result[0].count) > 0;
}
async findAgentWithPhoneNumbers(agentId: string) {
return await this.prismaClient.agent.findUnique({
where: { id: agentId },
select: {
id: true,
outboundPhoneNumbers: {
select: {
id: true,
phoneNumber: true,
subscriptionStatus: true,
},
},
},
});
}
async findProviderAgentIdById(agentId: string) {
return await this.prismaClient.agent.findUnique({
where: { id: agentId },
select: { providerAgentId: true },
});
}
}
@@ -35,6 +35,49 @@ export class PrismaRoutingFormRepository {
})) as SelectedFields<T> | null;
}
static async findActiveFormsForUserOrTeam({ userId, teamId }: { userId?: number; teamId?: number }) {
if (!userId && !teamId) return [];
const routingFormQuery = {
select: {
id: true,
name: true,
},
orderBy: [
{
name: "asc" as const,
},
],
};
if (teamId) {
return await prisma.app_RoutingForms_Form.findMany({
where: {
teamId: teamId,
disabled: false,
team: {
members: {
some: {
userId: userId,
accepted: true,
},
},
},
},
...routingFormQuery,
});
}
return await prisma.app_RoutingForms_Form.findMany({
where: {
userId: userId,
teamId: null, // Only personal forms, not team forms
disabled: false,
},
...routingFormQuery,
});
}
static async findFormByIdIncludeUserTeamAndOrg(
formId: string
): Promise<RoutingFormWithUserTeamAndOrg | null> {
@@ -50,6 +93,8 @@ export class PrismaRoutingFormRepository {
email: true,
movedToProfileId: true,
metadata: true,
timeFormat: true,
locale: true,
organization: {
select: {
slug: true,
@@ -56,6 +56,8 @@ export type RoutingFormWithUserTeamAndOrg = {
username: string | null;
email: string;
movedToProfileId: number | null;
timeFormat: number | null;
locale: string | null;
};
team: {
metadata: any;
@@ -1423,6 +1423,59 @@ export class EventTypeRepository {
});
}
async findEventTypesWithoutChildren(eventTypeIds: number[], teamId?: number | null) {
return await this.prismaClient.eventType.findMany({
where: {
id: {
in: eventTypeIds,
},
...(teamId && { parentId: null }),
},
select: {
id: true,
children: {
select: {
id: true,
},
},
},
});
}
async findAllIncludingChildrenByUserId({ userId }: { userId: number | null }) {
if (userId === null) {
return [];
}
return await this.prismaClient.eventType.findMany({
where: {
userId,
},
select: {
id: true,
children: {
select: {
id: true,
},
},
},
});
}
async findAllIncludingChildrenByTeamId({ teamId }: { teamId: number }) {
return await this.prismaClient.eventType.findMany({
where: {
teamId,
},
select: {
id: true,
children: {
select: {
id: true,
},
},
},
});
}
async getTeamIdByEventTypeId({ id }: { id: number }) {
return await this.prismaClient.eventType.findFirst({
where: {
@@ -105,6 +105,8 @@ export class RoutingFormResponseRepository {
select: {
id: true,
email: true,
timeFormat: true,
locale: true,
},
},
id: true,
+48
View File
@@ -412,6 +412,42 @@ export class TeamRepository {
});
}
async findTeamWithParentHideBranding({ teamId }: { teamId: number }) {
return await this.prismaClient.team.findUnique({
where: { id: teamId },
select: {
hideBranding: true,
parent: {
select: {
hideBranding: true,
},
},
},
});
}
async findOrganization({ teamId, userId }: { teamId?: number; userId: number }) {
return await this.prismaClient.team.findFirst({
where: {
isOrganization: true,
children: {
some: {
id: teamId,
},
},
members: {
some: {
userId,
accepted: true,
},
},
},
select: {
id: true,
},
});
}
async isSlugAvailableForUpdate({
slug,
teamId,
@@ -437,4 +473,16 @@ export class TeamRepository {
return !conflictingTeam;
}
async findOrgTeamsExcludingTeam({ parentId, excludeTeamId }: { parentId: number; excludeTeamId: number }) {
return await this.prismaClient.team.findMany({
where: {
parentId,
id: {
not: excludeTeamId,
},
},
select: { id: true },
});
}
}
+18
View File
@@ -1068,4 +1068,22 @@ export class UserRepository {
},
});
}
async findUserWithHideBranding({ userId }: { userId: number }) {
return this.prismaClient.user.findUnique({
where: { id: userId },
select: {
hideBranding: true,
profiles: {
select: {
organization: {
select: {
hideBranding: true,
},
},
},
},
},
});
}
}
+409 -4
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { WorkflowType } from "@calcom/ee/workflows/components/WorkflowListPage";
import { FORM_TRIGGER_WORKFLOW_EVENTS } from "@calcom/ee/workflows/lib/constants";
import { deleteScheduledAIPhoneCall } from "@calcom/ee/workflows/lib/reminders/aiPhoneCallManager";
import { deleteScheduledEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager";
import { deleteScheduledSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager";
@@ -9,7 +10,12 @@ import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import {
MembershipRole,
TimeUnit,
WorkflowTriggerEvents,
WorkflowType as PrismaWorkflowType,
} from "@calcom/prisma/enums";
import { WorkflowMethods } from "@calcom/prisma/enums";
import type { TFilteredListInputSchema } from "@calcom/trpc/server/routers/viewer/workflows/filteredList.schema";
import type { TGetVerifiedEmailsInputSchema } from "@calcom/trpc/server/routers/viewer/workflows/getVerifiedEmails.schema";
@@ -21,6 +27,21 @@ export const ZGetInputSchema = z.object({
id: z.number(),
});
const excludeFormTriggersWhereClause = {
trigger: {
not: {
in: FORM_TRIGGER_WORKFLOW_EVENTS,
},
},
};
const getWorkflowType = (trigger: WorkflowTriggerEvents): PrismaWorkflowType => {
if (FORM_TRIGGER_WORKFLOW_EVENTS.includes(trigger)) {
return PrismaWorkflowType.ROUTING_FORM;
}
return PrismaWorkflowType.EVENT_TYPE;
};
export type TGetInputSchema = z.infer<typeof ZGetInputSchema>;
const deleteScheduledWhatsappReminder = deleteScheduledSMSReminder;
@@ -100,6 +121,16 @@ export class WorkflowRepository {
team: true,
},
},
activeOnRoutingForms: {
select: {
routingForm: {
select: {
id: true,
name: true,
},
},
},
},
trigger: true,
steps: {
orderBy: {
@@ -261,7 +292,7 @@ export class WorkflowRepository {
};
if (filtered) {
if (!!filters.teamIds) {
if (filters.teamIds) {
where.OR.push({
team: {
id: {
@@ -277,7 +308,7 @@ export class WorkflowRepository {
});
}
if (!!filters.userIds) {
if (filters.userIds) {
where.OR.push({
userId: {
in: filters.userIds,
@@ -430,7 +461,7 @@ export class WorkflowRepository {
const reminderMethods: {
[x: string]: (id: number, referenceId: string | null) => void;
} = {
[WorkflowMethods.EMAIL]: (id, referenceId) => deleteScheduledEmailReminder(id),
[WorkflowMethods.EMAIL]: (id) => deleteScheduledEmailReminder(id),
[WorkflowMethods.SMS]: (id, referenceId) => deleteScheduledSMSReminder(id, referenceId),
[WorkflowMethods.WHATSAPP]: (id, referenceId) => deleteScheduledWhatsappReminder(id, referenceId),
[WorkflowMethods.AI_PHONE_CALL]: (id, referenceId) => deleteScheduledAIPhoneCall(id, referenceId),
@@ -453,4 +484,378 @@ export class WorkflowRepository {
}
});
}
static async findUniqueForUpdate(id: number) {
return await prisma.workflow.findUnique({
where: { id },
select: {
id: true,
userId: true,
isActiveOnAll: true,
trigger: true,
time: true,
timeUnit: true,
team: {
select: {
isOrganization: true,
},
},
teamId: true,
user: {
select: {
teams: true,
},
},
steps: true,
activeOn: true,
activeOnTeams: true,
activeOnRoutingForms: true,
},
});
}
static async updateWorkflow(
id: number,
data: {
name: string;
trigger: WorkflowTriggerEvents;
time: number | null;
timeUnit: TimeUnit | null;
isActiveOnAll?: boolean;
}
) {
const type = getWorkflowType(data.trigger);
return await prisma.workflow.update({
where: { id },
data: {
...data,
type,
},
});
}
static async findUniqueWithRelations(id: number) {
return await prisma.workflow.findUnique({
where: { id },
include: {
activeOn: {
select: {
eventType: true,
},
},
activeOnTeams: {
select: {
team: true,
},
},
activeOnRoutingForms: {
select: {
routingForm: {
select: {
id: true,
name: true,
},
},
},
},
team: {
select: {
id: true,
slug: true,
members: true,
name: true,
isOrganization: true,
},
},
steps: {
orderBy: {
stepNumber: "asc",
},
},
},
});
}
static async findActiveOrgWorkflows({
orgId,
userId,
teamId,
excludeFormTriggers,
}: {
orgId: number;
userId: number;
teamId: number;
excludeFormTriggers: boolean;
}) {
return await prisma.workflow.findMany({
where: {
...(excludeFormTriggers ? excludeFormTriggersWhereClause : {}),
team: {
id: orgId,
members: {
some: {
userId,
accepted: true,
},
},
},
OR: [
{
isActiveOnAll: true,
},
{
activeOnTeams: {
some: {
team: {
OR: [
{ id: teamId },
{
members: {
some: {
userId,
accepted: true,
},
},
},
],
},
},
},
},
],
},
include: {
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
activeOnTeams: {
select: {
team: {
select: {
id: true,
name: true,
},
},
},
},
steps: true,
},
});
}
static async findTeamWorkflows({
teamId,
userId,
excludeFormTriggers,
}: {
teamId: number;
userId: number;
excludeFormTriggers: boolean;
}) {
return await prisma.workflow.findMany({
where: {
...(excludeFormTriggers ? excludeFormTriggersWhereClause : {}),
team: {
id: teamId,
members: {
some: {
userId,
accepted: true,
},
},
},
},
include: {
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
parentId: true,
_count: {
select: {
children: true,
},
},
},
},
},
},
steps: true,
},
orderBy: {
id: "asc",
},
});
}
static async findUserWorkflows({
userId,
excludeFormTriggers,
}: {
userId: number;
excludeFormTriggers: boolean;
}) {
return await prisma.workflow.findMany({
where: {
...(excludeFormTriggers ? excludeFormTriggersWhereClause : {}),
userId,
},
include: {
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
parentId: true,
_count: {
select: {
children: true,
},
},
},
},
},
},
steps: true,
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
},
orderBy: {
id: "asc",
},
});
}
static async findAllWorkflows({
userId,
excludeFormTriggers,
}: {
userId: number;
excludeFormTriggers: boolean;
}) {
return await prisma.workflow.findMany({
where: {
...(excludeFormTriggers ? excludeFormTriggersWhereClause : {}),
OR: [
{ userId },
{
team: {
members: {
some: {
userId,
accepted: true,
},
},
},
},
],
},
include: {
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
parentId: true,
_count: {
select: {
children: true,
},
},
},
},
},
},
steps: true,
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
},
orderBy: {
id: "asc",
},
});
}
static async findWorkflowsActiveOnRoutingForm({ routingFormId }: { routingFormId: string }) {
return await prisma.workflow.findMany({
where: {
activeOnRoutingForms: {
some: {
routingFormId,
},
},
},
select: {
id: true,
name: true,
trigger: true,
time: true,
timeUnit: true,
userId: true,
teamId: true,
steps: true,
team: {
select: {
id: true,
name: true,
slug: true,
},
},
},
});
}
static async findActiveWorkflowsOnTeam({ parentTeamId, teamId }: { parentTeamId: number; teamId: number }) {
return await prisma.workflow.findMany({
where: {
teamId: parentTeamId,
OR: [
{
activeOnTeams: {
some: {
teamId: teamId,
},
},
},
{
isActiveOnAll: true,
},
],
},
select: {
steps: true,
activeOnTeams: true,
isActiveOnAll: true,
},
});
}
}
@@ -0,0 +1,94 @@
import type { PrismaClient } from "@calcom/prisma";
export class WorkflowRelationsRepository {
constructor(private prismaClient: PrismaClient) {}
// Team relationship methods
async deleteAllActiveOnTeams(workflowId: number) {
return await this.prismaClient.workflowsOnTeams.deleteMany({
where: { workflowId },
});
}
async createActiveOnTeams(workflowId: number, teamIds: number[]) {
if (teamIds.length === 0) return;
return await this.prismaClient.workflowsOnTeams.createMany({
data: teamIds.map((teamId) => ({
workflowId,
teamId,
})),
});
}
async findActiveOnTeams(workflowId: number) {
return await this.prismaClient.workflowsOnTeams.findMany({
where: { workflowId },
select: {
teamId: true,
},
});
}
// Routing Forms relationship methods
async deleteAllActiveOnRoutingForms(workflowId: number) {
return await this.prismaClient.workflowsOnRoutingForms.deleteMany({
where: { workflowId },
});
}
async createActiveOnRoutingForms(workflowId: number, routingFormIds: string[]) {
if (routingFormIds.length === 0) return;
return await this.prismaClient.workflowsOnRoutingForms.createMany({
data: routingFormIds.map((routingFormId) => ({
workflowId,
routingFormId,
})),
});
}
// Event Types relationship methods
async deleteAllActiveOnEventTypes(workflowId: number) {
return await this.prismaClient.workflowsOnEventTypes.deleteMany({
where: { workflowId },
});
}
async createActiveOnEventTypes(workflowId: number, eventTypeIds: number[]) {
if (eventTypeIds.length === 0) return;
return await this.prismaClient.workflowsOnEventTypes.createMany({
data: eventTypeIds.map((eventTypeId) => ({
workflowId,
eventTypeId,
})),
});
}
async findActiveOnEventTypes(workflowId: number) {
return await this.prismaClient.workflowsOnEventTypes.findMany({
where: { workflowId },
select: {
eventTypeId: true,
eventType: {
select: {
children: {
select: {
id: true,
},
},
},
},
},
});
}
async deleteAllActiveOnRelations(workflowId: number) {
return await Promise.all([
this.deleteAllActiveOnTeams(workflowId),
this.deleteAllActiveOnRoutingForms(workflowId),
this.deleteAllActiveOnEventTypes(workflowId),
]);
}
}
@@ -0,0 +1,22 @@
import type { Prisma, PrismaClient } from "@calcom/prisma/client";
export class WorkflowStepRepository {
constructor(private prismaClient: PrismaClient) {}
async deleteById(id: number) {
return await this.prismaClient.workflowStep.delete({
where: { id },
});
}
async createWorkflowStep(data: Prisma.WorkflowStepUncheckedCreateInput) {
return await this.prismaClient.workflowStep.create({ data });
}
async updateWorkflowStep(id: number, data: Prisma.WorkflowStepUncheckedUpdateInput) {
return await this.prismaClient.workflowStep.update({
where: { id },
data,
});
}
}
@@ -0,0 +1,148 @@
import { describe, expect, vi, beforeEach } from "vitest";
import { tasker } from "@calcom/features/tasker";
import { WorkflowTriggerEvents, WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { scheduleWorkflowReminders } from "../../../features/ee/workflows/lib/reminders/reminderScheduler";
import { WorkflowService } from "./workflows";
vi.mock("../../../features/ee/workflows/lib/reminders/reminderScheduler");
vi.mock("@calcom/features/tasker");
const mockScheduleWorkflowReminders = vi.mocked(scheduleWorkflowReminders);
const mockTasker = vi.mocked(tasker);
// Mock the getHideBranding function to return false
vi.mock("../../hideBranding", () => ({
getHideBranding: vi.fn().mockResolvedValue(false),
}));
describe("WorkflowService.scheduleFormWorkflows", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockForm = {
id: "form-123",
userId: 101,
teamId: null,
fields: [
{ type: "email", identifier: "email" },
{ type: "phone", identifier: "phone" },
],
user: {
email: "formowner@example.com",
timeFormat: 12,
locale: "en",
},
};
const mockResponses = {
email: {
value: "submitter@example.com",
response: "submitter@example.com",
},
phone: {
value: "+1234567890",
response: "+1234567890",
},
};
test("should call scheduleWorkflowReminders for FORM_SUBMITTED triggers with correct phone number", async () => {
const workflows = [
{
id: 1,
name: "Form Submitted",
userId: 101,
teamId: null,
trigger: WorkflowTriggerEvents.FORM_SUBMITTED,
time: null,
timeUnit: null,
steps: [
{
id: 1,
action: WorkflowActions.SMS_ATTENDEE,
sendTo: null,
reminderBody: "Thank you!",
emailSubject: "Form Received",
template: WorkflowTemplates.CUSTOM,
verifiedAt: new Date(),
includeCalendarEvent: false,
numberVerificationPending: false,
numberRequired: false,
},
],
},
];
await WorkflowService.scheduleFormWorkflows({
workflows,
responses: mockResponses,
form: mockForm,
});
expect(mockScheduleWorkflowReminders).toHaveBeenCalledWith({
smsReminderNumber: "+1234567890",
formData: {
responses: mockResponses,
user: { email: "formowner@example.com", timeFormat: 12, locale: "en" },
},
hideBranding: false,
workflows: [workflows[0]],
});
});
test("should handle forms without phone fields by passing null smsReminderNumber", async () => {
const formWithoutPhone = {
...mockForm,
fields: [
{ type: "email", identifier: "email" },
{ type: "text", identifier: "name" },
],
};
const workflows = [
{
id: 1,
name: "Form Submitted",
userId: 101,
teamId: null,
trigger: WorkflowTriggerEvents.FORM_SUBMITTED,
time: null,
timeUnit: null,
steps: [
{
id: 1,
action: WorkflowActions.EMAIL_ATTENDEE,
sendTo: null,
reminderBody: "Thank you!",
emailSubject: "Form Received",
template: WorkflowTemplates.CUSTOM,
verifiedAt: new Date(),
includeCalendarEvent: false,
numberVerificationPending: false,
numberRequired: false,
sender: null,
},
],
},
];
await WorkflowService.scheduleFormWorkflows({
workflows,
responses: mockResponses,
form: formWithoutPhone,
});
expect(mockScheduleWorkflowReminders).toHaveBeenCalledWith({
smsReminderNumber: null,
formData: {
responses: mockResponses,
user: { email: "formowner@example.com", timeFormat: 12, locale: "en" },
},
hideBranding: false,
workflows: [workflows[0]],
});
});
});
+95 -34
View File
@@ -1,9 +1,14 @@
import { getAllWorkflows } from "@calcom/ee/workflows/lib/getAllWorkflows";
import type { ScheduleWorkflowRemindersArgs } from "@calcom/ee/workflows/lib/reminders/reminderScheduler";
import { scheduleWorkflowReminders } from "@calcom/ee/workflows/lib/reminders/reminderScheduler";
import type { Workflow } from "@calcom/ee/workflows/lib/types";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { prisma } from "@calcom/prisma";
import { WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { WorkflowTriggerEvents, WorkflowType } from "@calcom/prisma/enums";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/routing-forms/lib/formSubmissionUtils";
import { getHideBranding } from "../../hideBranding";
import { TeamRepository } from "../repository/team";
import { WorkflowRepository } from "../repository/workflow";
// TODO (Sean): Move most of the logic migrated in 16861 to this service
@@ -12,35 +17,40 @@ export class WorkflowService {
WorkflowTriggerEvents.AFTER_EVENT,
WorkflowTriggerEvents.BEFORE_EVENT,
];
static async deleteWorkflowRemindersOfRemovedTeam(teamId: number) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
static async getAllWorkflowsFromRoutingForm(routingForm: {
id: string;
userId: number | null;
teamId: number | null;
}) {
const routingFormWorkflows = await WorkflowRepository.findWorkflowsActiveOnRoutingForm({
routingFormId: routingForm.id,
});
const teamId = routingForm.teamId;
const userId = routingForm.userId;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId });
const allWorkflows = await getAllWorkflows({
entityWorkflows: routingFormWorkflows,
userId,
teamId,
orgId,
workflowsLockedForUser: false,
type: WorkflowType.ROUTING_FORM,
});
return allWorkflows;
}
static async deleteWorkflowRemindersOfRemovedTeam(teamId: number) {
const teamRepository = new TeamRepository(prisma);
const team = await teamRepository.findById({ id: teamId });
if (team?.parentId) {
const activeWorkflowsOnTeam = await prisma.workflow.findMany({
where: {
teamId: team.parentId,
OR: [
{
activeOnTeams: {
some: {
teamId: team.id,
},
},
},
{
isActiveOnAll: true,
},
],
},
select: {
steps: true,
activeOnTeams: true,
isActiveOnAll: true,
},
const activeWorkflowsOnTeam = await WorkflowRepository.findActiveWorkflowsOnTeam({
parentTeamId: team.parentId,
teamId: team.id,
});
for (const workflow of activeWorkflowsOnTeam) {
@@ -48,13 +58,10 @@ export class WorkflowService {
let remainingActiveOnIds = [];
if (workflow.isActiveOnAll) {
const allRemainingOrgTeams = await prisma.team.findMany({
where: {
parentId: team.parentId,
id: {
not: team.id,
},
},
const teamRepository = new TeamRepository(prisma);
const allRemainingOrgTeams = await teamRepository.findOrgTeamsExcludingTeam({
parentId: team.parentId,
excludeTeamId: team.id,
});
remainingActiveOnIds = allRemainingOrgTeams.map((team) => team.id);
} else {
@@ -72,6 +79,60 @@ export class WorkflowService {
}
}
static async scheduleFormWorkflows({
workflows,
responses,
form,
}: {
workflows: Workflow[];
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
form: {
id: string;
userId: number;
teamId?: number | null;
fields?: { type: string; identifier?: string }[];
user: {
email: string;
timeFormat: number | null;
locale: string | null;
};
};
}) {
if (workflows.length <= 0) return;
const workflowsToTrigger: Workflow[] = [];
workflowsToTrigger.push(
...workflows.filter((workflow) => workflow.trigger === WorkflowTriggerEvents.FORM_SUBMITTED)
);
let smsReminderNumber: string | null = null;
if (form.fields) {
const phoneField = form.fields.find((field) => field.type === "phone");
if (phoneField && phoneField.identifier) {
const phoneResponse = responses[phoneField.identifier];
if (phoneResponse?.response && typeof phoneResponse.response === "string") {
smsReminderNumber = phoneResponse.response as string;
}
}
}
const hideBranding = await getHideBranding({
userId: form.userId,
teamId: form.teamId ?? undefined,
});
await scheduleWorkflowReminders({
smsReminderNumber,
formData: {
responses,
user: { email: form.user.email, timeFormat: form.user.timeFormat, locale: form.user.locale ?? "en" },
},
hideBranding,
workflows: workflowsToTrigger,
});
}
static async scheduleWorkflowsForNewBooking({
isNormalBookingOrFirstRecurringSlot,
isConfirmedByDefault,
@@ -0,0 +1,26 @@
-- AlterEnum
ALTER TYPE "WorkflowTriggerEvents" ADD VALUE 'FORM_SUBMITTED';
-- CreateTable
CREATE TABLE "WorkflowsOnRoutingForms" (
"id" SERIAL NOT NULL,
"workflowId" INTEGER NOT NULL,
"routingFormId" TEXT NOT NULL,
CONSTRAINT "WorkflowsOnRoutingForms_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WorkflowsOnRoutingForms_workflowId_idx" ON "WorkflowsOnRoutingForms"("workflowId");
-- CreateIndex
CREATE INDEX "WorkflowsOnRoutingForms_routingFormId_idx" ON "WorkflowsOnRoutingForms"("routingFormId");
-- CreateIndex
CREATE UNIQUE INDEX "WorkflowsOnRoutingForms_workflowId_routingFormId_key" ON "WorkflowsOnRoutingForms"("workflowId", "routingFormId");
-- AddForeignKey
ALTER TABLE "WorkflowsOnRoutingForms" ADD CONSTRAINT "WorkflowsOnRoutingForms_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkflowsOnRoutingForms" ADD CONSTRAINT "WorkflowsOnRoutingForms_routingFormId_fkey" FOREIGN KEY ("routingFormId") REFERENCES "App_RoutingForms_Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "WorkflowType" AS ENUM ('EVENT_TYPE', 'ROUTING_FORM');
-- AlterTable
ALTER TABLE "Workflow" ADD COLUMN "type" "WorkflowType" NOT NULL DEFAULT 'EVENT_TYPE';
+35 -14
View File
@@ -1242,6 +1242,7 @@ model App_RoutingForms_Form {
/// @zod.import(["import { RoutingFormSettings } from '../../zod-utils'"]).custom.use(RoutingFormSettings)
settings Json?
incompleteBookingActions App_RoutingForms_IncompleteBookingActions[]
workflows WorkflowsOnRoutingForms[]
@@index([userId])
@@index([disabled])
@@ -1389,6 +1390,7 @@ enum WorkflowTriggerEvents {
RESCHEDULE_EVENT
AFTER_HOSTS_CAL_VIDEO_NO_SHOW
AFTER_GUESTS_CAL_VIDEO_NO_SHOW
FORM_SUBMITTED
BOOKING_REJECTED
BOOKING_REQUESTED
BOOKING_PAYMENT_INITIATED
@@ -1407,6 +1409,11 @@ enum WorkflowActions {
CAL_AI_PHONE_CALL
}
enum WorkflowType {
EVENT_TYPE
ROUTING_FORM
}
model WorkflowStep {
id Int @id @default(autoincrement())
stepNumber Int
@@ -1433,20 +1440,22 @@ model WorkflowStep {
}
model Workflow {
id Int @id @default(autoincrement())
position Int @default(0)
name String
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int?
activeOn WorkflowsOnEventTypes[]
activeOnTeams WorkflowsOnTeams[]
isActiveOnAll Boolean @default(false)
trigger WorkflowTriggerEvents
time Int?
timeUnit TimeUnit?
steps WorkflowStep[]
id Int @id @default(autoincrement())
position Int @default(0)
name String
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int?
activeOn WorkflowsOnEventTypes[]
activeOnTeams WorkflowsOnTeams[]
activeOnRoutingForms WorkflowsOnRoutingForms[]
isActiveOnAll Boolean @default(false)
trigger WorkflowTriggerEvents
time Int?
timeUnit TimeUnit?
steps WorkflowStep[]
type WorkflowType @default(EVENT_TYPE)
@@index([userId])
@@index([teamId])
@@ -1484,6 +1493,18 @@ model WorkflowsOnEventTypes {
@@index([eventTypeId])
}
model WorkflowsOnRoutingForms {
id Int @id @default(autoincrement())
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
workflowId Int
routingForm App_RoutingForms_Form @relation(fields: [routingFormId], references: [id], onDelete: Cascade)
routingFormId String
@@unique([workflowId, routingFormId])
@@index([workflowId])
@@index([routingFormId])
}
model WorkflowsOnTeams {
id Int @id @default(autoincrement())
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
@@ -6,10 +6,10 @@ import { MembershipRole } from "@calcom/prisma/enums";
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZGetActiveOnOptionsSchema } from "./getActiveOnOptions.schema";
import { ZEventTypeInputSchema, ZGetEventTypesFromGroupSchema } from "./getByViewer.schema";
import { ZGetHashedLinkInputSchema } from "./getHashedLink.schema";
import { ZGetHashedLinksInputSchema } from "./getHashedLinks.schema";
import { ZGetTeamAndEventTypeOptionsSchema } from "./getTeamAndEventTypeOptions.schema";
import { get } from "./procedures/get";
import { createEventPbacProcedure } from "./util";
@@ -61,22 +61,20 @@ export const eventTypesRouter = router({
return result;
}),
getTeamAndEventTypeOptions: authedProcedure
.input(ZGetTeamAndEventTypeOptionsSchema)
.query(async ({ ctx, input }) => {
const { getTeamAndEventTypeOptions } = await import("./getTeamAndEventTypeOptions.handler");
getActiveOnOptions: authedProcedure.input(ZGetActiveOnOptionsSchema).query(async ({ ctx, input }) => {
const { getActiveOnOptions } = await import("./getActiveOnOptions.handler");
const timer = logP(`getTeamAndEventTypeOptions(${ctx.user.id})`);
const timer = logP(`getActiveOnOptions(${ctx.user.id})`);
const result = await getTeamAndEventTypeOptions({
ctx,
input,
});
const result = await getActiveOnOptions({
ctx,
input,
});
timer();
timer();
return result;
}),
return result;
}),
list: authedProcedure.query(async ({ ctx }) => {
const { listHandler } = await import("./list.handler");
@@ -0,0 +1,275 @@
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { PrismaRoutingFormRepository } from "@calcom/lib/server/repository/PrismaRoutingFormRepository";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import { listOtherTeamHandler } from "../organizations/listOtherTeams.handler";
import type { TGetActiveOnOptionsSchema } from "./getActiveOnOptions.schema";
type GetActiveOnOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TGetActiveOnOptionsSchema;
};
type Option = {
value: string;
label: string;
};
type MembershipEventType = Awaited<
ReturnType<typeof MembershipRepository.findAllByUpIdIncludeMinimalEventTypes>
>[number]["team"]["eventTypes"][number];
type EventType = Omit<MembershipEventType, "forwardParamsSuccessRedirect"> & {
children?: { id: number }[];
canSendCalVideoTranscriptionEmails?: boolean;
};
type EventTypeGroup = {
teamId?: number | null;
parentId?: number | null;
bookerUrl?: string;
profile: {
slug?: string | null;
name: string | null;
image?: string;
eventTypesLockedByOrg?: boolean;
};
eventTypes?: EventType[];
};
const fetchEventTypeGroups = async ({
ctx,
profile,
parentOrgHasLockedEventTypes,
skipEventTypes,
teamId,
}: {
ctx: { user: NonNullable<TrpcSessionUser>; prisma: PrismaClient };
profile: NonNullable<Awaited<ReturnType<typeof ProfileRepository.findByUpId>>>;
parentOrgHasLockedEventTypes: boolean | undefined;
skipEventTypes: boolean;
teamId?: number;
}): Promise<EventTypeGroup[]> => {
const user = ctx.user;
const userProfile = ctx.user.profile;
const eventTypeRepo = new EventTypeRepository(ctx.prisma);
const [profileMemberships, profileEventTypes] = await Promise.all([
MembershipRepository.findAllByUpIdIncludeMinimalEventTypes(
{
upId: userProfile.upId,
},
{
where: {
accepted: true,
},
skipEventTypes,
}
),
teamId
? []
: eventTypeRepo.findAllByUpIdWithMinimalData(
{
upId: userProfile.upId,
userId: user.id,
},
{
where: {
teamId: null,
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
}
),
]);
const memberships = profileMemberships.map((membership) => ({
...membership,
team: {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
},
}));
const eventTypeGroups: EventTypeGroup[] = [];
// Add user's personal event types
eventTypeGroups.push({
teamId: null,
profile: {
slug: profile.username,
name: profile.name,
eventTypesLockedByOrg: parentOrgHasLockedEventTypes,
},
eventTypes: profileEventTypes as EventType[],
});
// Add team event types
const teamGroups = await Promise.all(
memberships
.filter((membership) => !membership?.team?.isOrganization)
.map(async (membership) => {
const team = {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
};
const eventTypes = team.eventTypes
?.filter((evType) => evType.userId === null || evType.userId === user.id)
?.filter((evType) =>
membership.role === MembershipRole.MEMBER
? evType.schedulingType !== SchedulingType.MANAGED
: true
);
return {
teamId: team.id,
parentId: team.parentId,
profile: {
name: team.name,
},
eventTypes,
};
})
);
return eventTypeGroups.concat(teamGroups);
};
const fetchTeamOptions = async ({
ctx,
eventTypeGroups,
skipTeamOptions,
}: {
ctx: { user: NonNullable<TrpcSessionUser>; prisma: PrismaClient };
eventTypeGroups: EventTypeGroup[];
skipTeamOptions: boolean;
}): Promise<Option[]> => {
if (skipTeamOptions) {
return [];
}
const profileTeamsOptions = eventTypeGroups
.filter((group) => !!group.teamId)
.map((group) => ({
value: String(group.teamId),
label: group.profile.name || group.profile.slug || "",
}));
const otherTeams = await listOtherTeamHandler({ ctx });
const otherTeamsOptions = otherTeams
? otherTeams.map((team) => ({
value: String(team.id),
label: team.name || team.slug || "",
}))
: [];
return profileTeamsOptions.concat(otherTeamsOptions);
};
const fetchRoutingFormOptions = async ({
userId,
teamId,
}: {
userId: number;
teamId?: number;
}): Promise<Option[]> => {
const routingForms = await PrismaRoutingFormRepository.findActiveFormsForUserOrTeam({ userId, teamId });
return routingForms.map((form) => ({
value: form.id,
label: form.name,
}));
};
export const getActiveOnOptions = async ({ ctx, input }: GetActiveOnOptions) => {
await checkRateLimitAndThrowError({
identifier: `eventTypes:getActiveOnOptions.handler:${ctx.user.id}`,
rateLimitingType: "common",
});
const user = ctx.user;
const teamId = input.teamId;
const isOrg = input.isOrg;
const shouldIncludeTeamOptions = isOrg;
const shouldSkipEventTypes = isOrg;
const userProfile = ctx.user.profile;
const profile = await ProfileRepository.findByUpId(userProfile.upId);
const parentOrgHasLockedEventTypes =
profile?.organization?.organizationSettings?.lockEventTypeCreationForUsers;
if (!profile) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const eventTypeGroups = await fetchEventTypeGroups({
ctx,
profile,
parentOrgHasLockedEventTypes,
skipEventTypes: shouldSkipEventTypes,
teamId,
});
const teamOptions = await fetchTeamOptions({
ctx,
eventTypeGroups,
skipTeamOptions: !shouldIncludeTeamOptions,
});
const eventTypeOptions = eventTypeGroups.reduce((options, group) => {
// Don't show team event types for user workflow
if (!teamId && group.teamId) return options;
// Only show correct team event types for team workflows
if (teamId && teamId !== group.teamId) return options;
const groupEventTypes =
group?.eventTypes
?.filter((evType) => {
const metadata = EventTypeMetaDataSchema.parse(evType.metadata);
return (
!metadata?.managedEventConfig ||
!!metadata?.managedEventConfig.unlockedFields?.workflows ||
!!teamId
);
})
?.map((eventType) => ({
value: String(eventType.id),
label: `${eventType.title}${
eventType?.children && eventType.children.length ? ` (+${eventType.children.length})` : ""
}`,
})) ?? [];
return [...options, ...groupEventTypes];
}, [] as Option[]);
const routingFormOptions = await fetchRoutingFormOptions({
userId: user.id,
teamId,
});
return {
eventTypeOptions,
teamOptions,
routingFormOptions,
};
};
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZGetActiveOnOptionsSchema = z.object({
teamId: z.number().optional(),
isOrg: z.boolean().default(false),
});
export type TGetActiveOnOptionsSchema = z.infer<typeof ZGetActiveOnOptionsSchema>;
@@ -1,227 +0,0 @@
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import { listOtherTeamHandler } from "../organizations/listOtherTeams.handler";
import type { TGetTeamAndEventTypeOptionsSchema } from "./getTeamAndEventTypeOptions.schema";
type GetTeamAndEventTypeOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TGetTeamAndEventTypeOptionsSchema;
};
type Option = {
value: string;
label: string;
};
type res = Awaited<
ReturnType<typeof MembershipRepository.findAllByUpIdIncludeMinimalEventTypes>
>[number]["team"]["eventTypes"][number];
type EventType = Omit<res, "forwardParamsSuccessRedirect"> & {
children?: { id: number }[];
canSendCalVideoTranscriptionEmails?: boolean;
};
export const getTeamAndEventTypeOptions = async ({ ctx, input }: GetTeamAndEventTypeOptions) => {
await checkRateLimitAndThrowError({
identifier: `eventTypes:getTeamAndEventTypeOptions.handler:${ctx.user.id}`,
rateLimitingType: "common",
});
const user = ctx.user;
const teamId = input?.teamId;
const isOrg = input?.isOrg;
const skipTeamOptions = !isOrg;
const skipEventTypes = !!isOrg;
const userProfile = ctx.user.profile;
const profile = await ProfileRepository.findByUpId(userProfile.upId);
const parentOrgHasLockedEventTypes =
profile?.organization?.organizationSettings?.lockEventTypeCreationForUsers;
const eventTypeRepo = new EventTypeRepository(ctx.prisma);
const [profileMemberships, profileEventTypes] = await Promise.all([
MembershipRepository.findAllByUpIdIncludeMinimalEventTypes(
{
upId: userProfile.upId,
},
{
where: {
accepted: true,
},
skipEventTypes,
}
),
teamId
? []
: eventTypeRepo.findAllByUpIdWithMinimalData(
{
upId: userProfile.upId,
userId: user.id,
},
{
where: {
teamId: null,
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
}
),
]);
if (!profile) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const memberships = profileMemberships.map((membership) => ({
...membership,
team: {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
},
}));
type EventTypeGroup = {
teamId?: number | null;
parentId?: number | null;
bookerUrl?: string;
profile: {
slug?: (typeof profile)["username"] | null;
name: (typeof profile)["name"];
image?: string;
eventTypesLockedByOrg?: boolean;
};
eventTypes?: EventType[];
};
let eventTypeGroups: EventTypeGroup[] = [];
eventTypeGroups.push({
teamId: null,
profile: {
slug: profile.username,
name: profile.name,
eventTypesLockedByOrg: parentOrgHasLockedEventTypes,
},
eventTypes: profileEventTypes as EventType[],
});
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
await Promise.all(
memberships
.filter((mmship) => {
if (mmship?.team?.isOrganization) {
return false;
}
return true;
})
.map(async (membership) => {
const team = {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
};
const eventTypes = team.eventTypes;
return {
teamId: team.id,
parentId: team.parentId,
profile: {
name: team.name,
},
eventTypes: eventTypes
?.filter((evType) => {
const res = evType.userId === null || evType.userId === user.id;
return res;
})
?.filter((evType) =>
membership.role === MembershipRole.MEMBER
? evType.schedulingType !== SchedulingType.MANAGED
: true
),
};
})
)
);
let teamOptions: Option[] = [];
if (!skipTeamOptions) {
const profileTeamsOptions = eventTypeGroups
.map((group) => ({
...group.profile,
teamId: group.teamId,
}))
.filter((profile) => !!profile.teamId)
.map((profile) => {
return {
value: String(profile.teamId) || "",
label: profile.name || profile.slug || "",
};
});
const otherTeams = await listOtherTeamHandler({ ctx });
const otherTeamsOptions = otherTeams
? otherTeams.map((team) => {
return {
value: String(team.id) || "",
label: team.name || team.slug || "",
};
})
: [];
teamOptions = profileTeamsOptions.concat(otherTeamsOptions);
}
const eventTypeOptions =
eventTypeGroups.reduce((options, group) => {
// /** don't show team event types for user workflow */
if (!teamId && group.teamId) return options;
// /** only show correct team event types for team workflows */
if (teamId && teamId !== group.teamId) return options;
return [
...options,
...(group?.eventTypes
?.filter((evType) => {
const metadata = EventTypeMetaDataSchema.parse(evType.metadata);
return (
!metadata?.managedEventConfig ||
!!metadata?.managedEventConfig.unlockedFields?.workflows ||
!!teamId
);
})
?.map((eventType) => ({
value: String(eventType.id),
label: `${eventType.title} ${
eventType?.children && eventType.children.length ? `(+${eventType.children.length})` : ``
}`,
})) ?? []),
];
}, [] as Option[]) || [];
return {
eventTypeOptions,
teamOptions,
};
};
@@ -1,10 +0,0 @@
import { z } from "zod";
export const ZGetTeamAndEventTypeOptionsSchema = z
.object({
teamId: z.number().optional(),
isOrg: z.boolean().default(false),
})
.nullish();
export type TGetTeamAndEventTypeOptionsSchema = z.infer<typeof ZGetTeamAndEventTypeOptionsSchema>;
@@ -29,6 +29,8 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
select: {
id: true,
email: true,
timeFormat: true,
locale: true,
},
},
},
@@ -8,7 +8,7 @@ import {
} from "@calcom/ee/workflows/lib/constants";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
const ZWorkflow = z.object({
export const ZWorkflow = z.object({
id: z.number(),
name: z.string(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
@@ -28,6 +28,7 @@ const ZWorkflow = z.object({
sender: z.string().nullable(),
includeCalendarEvent: z.boolean(),
numberVerificationPending: z.boolean(),
verifiedAt: z.coerce.date().nullable().optional(),
})
.array(),
});
@@ -1,8 +1,9 @@
import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
// import dayjs from "@calcom/dayjs";
// import { getErrorFromUnknown } from "@calcom/lib/errors";
import { addPermissionsToWorkflows } from "@calcom/lib/server/repository/workflow-permissions";
import { prisma } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -19,85 +20,19 @@ type ListOptions = {
export const listHandler = async ({ ctx, input }: ListOptions) => {
const workflows: WorkflowType[] = [];
const teamRepository = new TeamRepository(ctx.prisma);
const org = await prisma.team.findFirst({
where: {
isOrganization: true,
children: {
some: {
id: input?.teamId,
},
},
members: {
some: {
userId: input?.userId || ctx.user.id,
accepted: true,
},
},
},
select: {
id: true,
},
const org = await teamRepository.findOrganization({
teamId: input.teamId,
userId: input.userId || ctx.user.id,
});
if (org) {
const activeOrgWorkflows = await prisma.workflow.findMany({
where: {
team: {
id: org.id,
members: {
some: {
userId: ctx.user.id,
accepted: true,
},
},
},
OR: [
{
isActiveOnAll: true,
},
{
activeOnTeams: {
some: {
team: {
OR: [
{ id: input?.teamId },
{
members: {
some: {
userId: ctx.user.id,
accepted: true,
},
},
},
],
},
},
},
},
],
},
include: {
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
activeOnTeams: {
select: {
team: {
select: {
id: true,
name: true,
},
},
},
},
steps: true,
},
const activeOrgWorkflows = await WorkflowRepository.findActiveOrgWorkflows({
orgId: org.id,
userId: ctx.user.id,
teamId: input.teamId!,
excludeFormTriggers: input.includeOnlyEventTypeWorkflows,
});
workflows.push(
...activeOrgWorkflows.map((workflow) => {
@@ -107,48 +42,10 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
}
if (input && input.teamId) {
const teamWorkflows: WorkflowType[] = await prisma.workflow.findMany({
where: {
team: {
id: input.teamId,
members: {
some: {
userId: ctx.user.id,
accepted: true,
},
},
},
},
include: {
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
parentId: true,
_count: {
select: {
children: true,
},
},
},
},
},
},
steps: true,
},
orderBy: {
id: "asc",
},
const teamWorkflows: WorkflowType[] = await WorkflowRepository.findTeamWorkflows({
teamId: input.teamId,
userId: ctx.user.id,
excludeFormTriggers: input.includeOnlyEventTypeWorkflows,
});
const workflowsWithReadOnly = teamWorkflows.map((workflow) => {
const readOnly = !!workflow.team?.members?.find(
@@ -169,40 +66,9 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
}
if (input && input.userId) {
const userWorkflows: WorkflowType[] = await prisma.workflow.findMany({
where: {
userId: ctx.user.id,
},
include: {
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
parentId: true,
_count: {
select: {
children: true,
},
},
},
},
},
},
steps: true,
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
},
orderBy: {
id: "asc",
},
const userWorkflows: WorkflowType[] = await WorkflowRepository.findUserWorkflows({
userId: ctx.user.id,
excludeFormTriggers: input.includeOnlyEventTypeWorkflows,
});
workflows.push(...userWorkflows);
@@ -216,52 +82,9 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
return { workflows: filteredWorkflows };
}
const allWorkflows = await prisma.workflow.findMany({
where: {
OR: [
{ userId: ctx.user.id },
{
team: {
members: {
some: {
userId: ctx.user.id,
accepted: true,
},
},
},
},
],
},
include: {
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
parentId: true,
_count: {
select: {
children: true,
},
},
},
},
},
},
steps: true,
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
},
orderBy: {
id: "asc",
},
const allWorkflows = await WorkflowRepository.findAllWorkflows({
userId: ctx.user.id,
excludeFormTriggers: input.includeOnlyEventTypeWorkflows,
});
const workflowsWithReadOnly: WorkflowType[] = allWorkflows.map((workflow) => {
@@ -1,10 +1,9 @@
import { z } from "zod";
export const ZListInputSchema = z
.object({
teamId: z.number().optional(),
userId: z.number().optional(),
})
.optional();
export const ZListInputSchema = z.object({
teamId: z.number().optional(),
userId: z.number().optional(),
includeOnlyEventTypeWorkflows: z.boolean(),
});
export type TListInputSchema = z.infer<typeof ZListInputSchema>;
@@ -1,12 +1,18 @@
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
import { isEmailAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { isEmailAction, isFormTrigger } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/lib/repository/workflowReminder";
import tasker from "@calcom/features/tasker";
import { IS_SELF_HOSTED, SCANNING_WORKFLOW_STEPS } from "@calcom/lib/constants";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import logger from "@calcom/lib/logger";
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import { addPermissionsToWorkflow } from "@calcom/lib/server/repository/workflow-permissions";
import type { PrismaClient } from "@calcom/prisma";
import { WorkflowRelationsRepository } from "@calcom/lib/server/repository/workflowRelations";
import { WorkflowStepRepository } from "@calcom/lib/server/repository/workflowStep";
import { prisma, type PrismaClient } from "@calcom/prisma";
import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -41,35 +47,19 @@ const log = logger.getSubLogger({ prefix: ["[Workflows.update] "] });
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const { user } = ctx;
const { id, name, activeOn, steps, trigger, time, timeUnit, isActiveOnAll } = input;
const {
id,
name,
activeOnEventTypeIds,
activeOnRoutingFormIds,
steps,
trigger,
time,
timeUnit,
isActiveOnAll,
} = input;
const userWorkflow = await ctx.prisma.workflow.findUnique({
where: {
id,
},
select: {
id: true,
userId: true,
isActiveOnAll: true,
trigger: true,
time: true,
timeUnit: true,
team: {
select: {
isOrganization: true,
},
},
teamId: true,
user: {
select: {
teams: true,
},
},
steps: true,
activeOn: true,
activeOnTeams: true,
},
});
const userWorkflow = await WorkflowRepository.findUniqueForUpdate(id);
const isOrg = !!userWorkflow?.team?.isOrganization;
@@ -94,70 +84,100 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
let removedActiveOnIds: number[] = [];
let activeOnWithChildren: number[] = activeOn;
let activeOnWithChildren: number[] = activeOnEventTypeIds;
let oldActiveOnIds: number[] = [];
if (!isOrg) {
// activeOn are event types ids
const activeOnEventTypes = await ctx.prisma.eventType.findMany({
where: {
id: {
in: activeOn,
},
...(userWorkflow.teamId && { parentId: null }), //all children managed event types are added after
},
select: {
id: true,
children: {
select: {
id: true,
},
},
},
const workflowRelationsRepository = new WorkflowRelationsRepository(ctx.prisma);
if (isOrg) {
// activeOn are team ids
if (userWorkflow.isActiveOnAll) {
const teamRepo = new TeamRepository(ctx.prisma);
oldActiveOnIds = (
await teamRepo.findAllByParentId({ parentId: userWorkflow.teamId ?? 0, select: { id: true } })
).map((team) => team.id);
} else {
oldActiveOnIds = (await workflowRelationsRepository.findActiveOnTeams(id)).map(
(teamRel) => teamRel.teamId
);
}
newActiveOn = activeOnEventTypeIds.filter((teamId) => !oldActiveOnIds.includes(teamId));
const isAuthorizedToAddIds = await isAuthorizedToAddActiveOnIds({
newEventTypeIds: [], // No event type IDs for team workflows
newRoutingFormIds: [], // No routing form IDs for team workflows
newTeamIds: newActiveOn,
teamId: userWorkflow?.teamId,
userId: userWorkflow?.userId,
});
if (!isAuthorizedToAddIds) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
removedActiveOnIds = oldActiveOnIds.filter((teamId) => !activeOnEventTypeIds.includes(teamId));
await deleteRemindersOfActiveOnIds({
removedActiveOnIds,
workflowSteps: userWorkflow.steps,
isOrg,
activeOnIds: activeOnEventTypeIds.filter((activeOn) => !newActiveOn.includes(activeOn)),
});
// clean up any old active on values
await workflowRelationsRepository.deleteAllActiveOnRelations(id);
// create all new active on relationships
await workflowRelationsRepository.createActiveOnTeams(id, activeOnEventTypeIds);
} else if (isFormTrigger(trigger)) {
// activeOnRoutingFormIds are routing form ids
const routingFormIds = activeOnRoutingFormIds;
const isAuthorizedToAddIds = await isAuthorizedToAddActiveOnIds({
newEventTypeIds: [], // No event type IDs for form triggers
newRoutingFormIds: routingFormIds ?? [],
newTeamIds: [], // No team IDs for form triggers
teamId: userWorkflow?.teamId,
userId: userWorkflow?.userId,
});
if (!isAuthorizedToAddIds) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// clean up any old active on values
await workflowRelationsRepository.deleteAllActiveOnRelations(id);
// Create new workflow - routing forms relationships
await workflowRelationsRepository.createActiveOnRoutingForms(id, routingFormIds ?? []);
} else {
const eventTypeRepo = new EventTypeRepository(ctx.prisma);
const activeOnEventTypes = await eventTypeRepo.findEventTypesWithoutChildren(
activeOnEventTypeIds,
userWorkflow.teamId
);
activeOnWithChildren = activeOnEventTypes
.map((eventType) => [eventType.id].concat(eventType.children.map((child) => child.id)))
.flat();
let oldActiveOnEventTypes: { id: number; children: { id: number }[] }[];
if (userWorkflow.isActiveOnAll) {
oldActiveOnEventTypes = await ctx.prisma.eventType.findMany({
where: {
...(userWorkflow.teamId ? { teamId: userWorkflow.teamId } : { userId: userWorkflow.userId }),
},
select: {
id: true,
children: {
select: {
id: true,
},
},
},
});
oldActiveOnEventTypes = userWorkflow.teamId
? await eventTypeRepo.findAllIncludingChildrenByTeamId({
teamId: userWorkflow.teamId,
})
: await eventTypeRepo.findAllIncludingChildrenByUserId({
userId: userWorkflow.userId,
});
} else {
oldActiveOnEventTypes = (
await ctx.prisma.workflowsOnEventTypes.findMany({
where: {
workflowId: id,
},
select: {
eventTypeId: true,
eventType: {
select: {
children: {
select: {
id: true,
},
},
},
},
},
})
).map((eventTypeRel) => {
return { id: eventTypeRel.eventTypeId, children: eventTypeRel.eventType.children };
});
oldActiveOnEventTypes = (await workflowRelationsRepository.findActiveOnEventTypes(id)).map(
(eventTypeRel) => {
return { id: eventTypeRel.eventTypeId, children: eventTypeRel.eventType.children };
}
);
}
oldActiveOnIds = oldActiveOnEventTypes.flatMap((eventType) => [
@@ -165,14 +185,14 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
...eventType.children.map((child) => child.id),
]);
newActiveOn = activeOn.filter((eventTypeId) => !oldActiveOnIds.includes(eventTypeId));
const isAuthorizedToAddIds = await isAuthorizedToAddActiveOnIds(
newActiveOn,
isOrg,
userWorkflow?.teamId,
userWorkflow?.userId
);
newActiveOn = activeOnEventTypeIds.filter((eventTypeId) => !oldActiveOnIds.includes(eventTypeId));
const isAuthorizedToAddIds = await isAuthorizedToAddActiveOnIds({
newEventTypeIds: newActiveOn,
newRoutingFormIds: [],
newTeamIds: [],
teamId: userWorkflow?.teamId,
userId: userWorkflow?.userId,
});
if (!isAuthorizedToAddIds) {
throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -181,105 +201,39 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
//remove all scheduled Email and SMS reminders for eventTypes that are not active any more
removedActiveOnIds = oldActiveOnIds.filter((eventTypeId) => !activeOnWithChildren.includes(eventTypeId));
// clean up any old active on values
await deleteRemindersOfActiveOnIds({ removedActiveOnIds, workflowSteps: userWorkflow.steps, isOrg });
//update active on
await ctx.prisma.workflowsOnEventTypes.deleteMany({
where: {
workflowId: id,
},
});
// clean up an old relationships
await workflowRelationsRepository.deleteAllActiveOnRelations(id);
//create all workflow - eventtypes relationships
await ctx.prisma.workflowsOnEventTypes.createMany({
data: activeOnWithChildren.map((eventTypeId) => ({
workflowId: id,
eventTypeId,
})),
});
} else {
// activeOn are team ids
if (userWorkflow.isActiveOnAll) {
oldActiveOnIds = (
await ctx.prisma.team.findMany({
where: {
parent: {
id: userWorkflow.teamId ?? 0,
},
},
select: {
id: true,
},
})
).map((team) => team.id);
} else {
oldActiveOnIds = (
await ctx.prisma.workflowsOnTeams.findMany({
where: {
workflowId: id,
},
select: {
teamId: true,
},
})
).map((teamRel) => teamRel.teamId);
}
newActiveOn = activeOn.filter((teamId) => !oldActiveOnIds.includes(teamId));
const isAuthorizedToAddIds = await isAuthorizedToAddActiveOnIds(
newActiveOn,
isOrg,
userWorkflow?.teamId,
userWorkflow?.userId
);
if (!isAuthorizedToAddIds) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
removedActiveOnIds = oldActiveOnIds.filter((teamId) => !activeOn.includes(teamId));
await deleteRemindersOfActiveOnIds({
removedActiveOnIds,
workflowSteps: userWorkflow.steps,
isOrg,
activeOnIds: activeOn.filter((activeOn) => !newActiveOn.includes(activeOn)),
});
//update active on
await ctx.prisma.workflowsOnTeams.deleteMany({
where: {
workflowId: id,
},
});
await ctx.prisma.workflowsOnTeams.createMany({
data: activeOn.map((teamId) => ({
workflowId: id,
teamId,
})),
});
await workflowRelationsRepository.createActiveOnEventTypes(id, activeOnWithChildren);
}
if (userWorkflow.trigger !== trigger || userWorkflow.time !== time || userWorkflow.timeUnit !== timeUnit) {
//if trigger changed, delete all reminders from steps before change
await deleteRemindersOfActiveOnIds({
removedActiveOnIds: oldActiveOnIds,
workflowSteps: userWorkflow.steps,
isOrg,
});
if (!isFormTrigger(userWorkflow.trigger)) {
// Delete all existing reminders before rescheduling
await deleteRemindersOfActiveOnIds({
removedActiveOnIds: oldActiveOnIds,
workflowSteps: userWorkflow.steps,
isOrg,
});
}
await scheduleWorkflowNotifications({
activeOn, // schedule for activeOn that stayed the same + new active on (old reminders were deleted)
isOrg,
workflowSteps: userWorkflow.steps, // use old steps here, edited and deleted steps are handled below
time,
timeUnit,
trigger,
userId: user.id,
teamId: userWorkflow.teamId,
});
if (!isFormTrigger(trigger)) {
// Schedule new reminders for all activeOn
await scheduleWorkflowNotifications({
activeOn: activeOnWithChildren, // schedule for activeOn that stayed the same + new active on (old reminders were deleted)
isOrg,
workflowSteps: userWorkflow.steps, // use old steps here, edited and deleted steps are handled below
time,
timeUnit,
trigger,
userId: user.id,
teamId: userWorkflow.teamId,
});
}
} else {
// if trigger didn't change, only schedule reminders for all new activeOn
await scheduleWorkflowNotifications({
@@ -291,10 +245,14 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
trigger,
userId: user.id,
teamId: userWorkflow.teamId,
alreadyScheduledActiveOnIds: activeOn.filter((activeOn) => !newActiveOn.includes(activeOn)), // alreadyScheduledActiveOnIds
alreadyScheduledActiveOnIds: activeOnEventTypeIds.filter((activeOn) => !newActiveOn.includes(activeOn)), // alreadyScheduledActiveOnIds
});
}
const workflowStepRepository = new WorkflowStepRepository(ctx.prisma);
const agentRepo = new PrismaAgentRepository(prisma);
// handle deleted and edited workflow steps
await Promise.all(
userWorkflow.steps.map(async (oldStep) => {
@@ -302,7 +260,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
let newStep;
if (foundStep) {
const { senderName, ...rest } = {
newStep = {
...foundStep,
numberVerificationPending: false,
sender: getSender({
@@ -311,40 +269,13 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
senderName: foundStep.senderName,
}),
};
newStep = rest;
}
const remindersFromStep = await ctx.prisma.workflowReminder.findMany({
where: {
workflowStepId: oldStep.id,
},
select: {
id: true,
referenceId: true,
method: true,
booking: {
select: {
eventTypeId: true,
},
},
},
});
const remindersFromStep = await WorkflowReminderRepository.findWorkflowRemindersByStepId(oldStep.id);
//step was deleted
if (!newStep) {
if (oldStep.action === WorkflowActions.CAL_AI_PHONE_CALL && !!oldStep.agentId) {
const agent = await ctx.prisma.agent.findUnique({
where: { id: oldStep.agentId ?? undefined },
select: {
id: true,
outboundPhoneNumbers: {
select: {
id: true,
phoneNumber: true,
subscriptionStatus: true,
},
},
},
});
const agent = await agentRepo.findAgentWithPhoneNumbers(oldStep.agentId ?? undefined);
if (!agent) {
throw new TRPCError({
@@ -354,16 +285,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
await WorkflowRepository.deleteAllWorkflowReminders(remindersFromStep);
await ctx.prisma.workflowStep.delete({
where: {
id: oldStep.id,
},
});
await workflowStepRepository.deleteById(oldStep.id);
const aiPhoneService = createDefaultAIPhoneServiceProvider();
const externalErrors: string[] = [];
const phoneNumberOperations: Promise<any>[] = [];
const phoneNumberOperations: Promise<void | { success: boolean; message: string }>[] = [];
if (agent.outboundPhoneNumbers) {
for (const phoneNumber of agent.outboundPhoneNumbers) {
@@ -464,11 +391,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
} else {
// For non-AI phone steps, just delete reminders and step
await WorkflowRepository.deleteAllWorkflowReminders(remindersFromStep);
await ctx.prisma.workflowStep.delete({
where: {
id: oldStep.id,
},
});
await workflowStepRepository.deleteById(oldStep.id);
}
} else if (
isStepEdited(oldStep, {
@@ -520,25 +443,19 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const didBodyChange = newStep.reminderBody !== oldStep.reminderBody;
await ctx.prisma.workflowStep.update({
where: {
id: oldStep.id,
},
data: {
action: newStep.action,
sendTo: requiresSender ? newStep.sendTo : null,
stepNumber: newStep.stepNumber,
workflowId: newStep.workflowId,
reminderBody: newStep.reminderBody,
emailSubject: newStep.emailSubject,
template: newStep.template,
numberRequired: newStep.numberRequired,
sender: newStep.sender,
numberVerificationPending: false,
includeCalendarEvent: newStep.includeCalendarEvent,
agentId: newStep.agentId || null,
verifiedAt: !SCANNING_WORKFLOW_STEPS ? new Date() : didBodyChange ? null : oldStep.verifiedAt,
},
await workflowStepRepository.updateWorkflowStep(oldStep.id, {
action: newStep.action,
sendTo: requiresSender ? newStep.sendTo : null,
stepNumber: newStep.stepNumber,
reminderBody: newStep.reminderBody,
emailSubject: newStep.emailSubject,
template: newStep.template,
numberRequired: newStep.numberRequired,
sender: newStep.sender,
numberVerificationPending: false,
includeCalendarEvent: newStep.includeCalendarEvent,
agentId: newStep.agentId || null,
verifiedAt: !SCANNING_WORKFLOW_STEPS ? new Date() : didBodyChange ? null : oldStep.verifiedAt,
});
if (SCANNING_WORKFLOW_STEPS && didBodyChange) {
@@ -547,10 +464,10 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
userId: ctx.user.id,
createdAt: new Date().toISOString(),
});
} else {
// schedule notifications for edited steps
} else if (!isFormTrigger(trigger)) {
// schedule notifications for edited steps (only for event-based triggers)
await scheduleWorkflowNotifications({
activeOn,
activeOn: activeOnEventTypeIds,
isOrg,
workflowSteps: [newStep],
time,
@@ -590,20 +507,16 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
await verifyEmailSender(newStep.sendTo || "", user.id, userWorkflow.teamId);
}
const {
id: _stepId,
senderName,
...stepToAdd
} = {
return {
...newStep,
sender: getSender({
action: newStep.action,
sender: newStep.sender || null,
senderName: newStep.senderName,
}),
id: undefined,
senderName: undefined,
};
return stepToAdd;
})
);
@@ -611,12 +524,11 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
//create new steps
const createdSteps = await Promise.all(
addedSteps.map((step) =>
ctx.prisma.workflowStep.create({
data: {
...step,
numberVerificationPending: false,
...(!SCANNING_WORKFLOW_STEPS ? { verifiedAt: new Date() } : {}),
},
workflowStepRepository.createWorkflowStep({
...step,
workflowId: id,
numberVerificationPending: false,
...(!SCANNING_WORKFLOW_STEPS ? { verifiedAt: new Date() } : {}),
})
)
);
@@ -631,10 +543,10 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
})
)
);
} else {
// schedule notification for new step
} else if (!isFormTrigger(trigger)) {
// schedule notification for new step (only for event-based triggers)
await scheduleWorkflowNotifications({
activeOn,
activeOn: activeOnEventTypeIds,
isOrg,
workflowSteps: createdSteps,
time,
@@ -647,50 +559,15 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
//update trigger, name, time, timeUnit
await ctx.prisma.workflow.update({
where: {
id,
},
data: {
name,
trigger,
time,
timeUnit,
isActiveOnAll,
},
await WorkflowRepository.updateWorkflow(id, {
name,
trigger,
time,
timeUnit,
isActiveOnAll,
});
const workflow = await ctx.prisma.workflow.findUnique({
where: {
id,
},
include: {
activeOn: {
select: {
eventType: true,
},
},
activeOnTeams: {
select: {
team: true,
},
},
team: {
select: {
id: true,
slug: true,
members: true,
name: true,
isOrganization: true,
},
},
steps: {
orderBy: {
stepNumber: "asc",
},
},
},
});
const workflow = await WorkflowRepository.findUniqueWithRelations(id);
if (!workflow) {
throw new TRPCError({
@@ -699,48 +576,51 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
});
}
// Remove or add booking field for sms reminder number
const smsReminderNumberNeeded =
activeOn.length &&
steps.some(
(step) =>
step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.WHATSAPP_ATTENDEE
);
await removeSmsReminderFieldForEventTypes({
activeOnToRemove: removedActiveOnIds,
workflowId: id,
isOrg,
activeOn,
});
// Remove or add booking field for sms reminder number (only for event types, not routing forms)
if (!isFormTrigger(trigger)) {
const smsReminderNumberNeeded =
activeOnWithChildren.length &&
steps.some(
(step) =>
step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.WHATSAPP_ATTENDEE
);
if (!smsReminderNumberNeeded) {
await removeSmsReminderFieldForEventTypes({
activeOnToRemove: activeOnWithChildren,
activeOnToRemove: removedActiveOnIds,
workflowId: id,
isOrg,
});
} else {
await upsertSmsReminderFieldForEventTypes({
activeOn: activeOnWithChildren,
workflowId: id,
isSmsReminderNumberRequired: steps.some(
(s) =>
(s.action === WorkflowActions.SMS_ATTENDEE || s.action === WorkflowActions.WHATSAPP_ATTENDEE) &&
s.numberRequired
),
isOrg,
});
if (!smsReminderNumberNeeded) {
await removeSmsReminderFieldForEventTypes({
activeOnToRemove: activeOnWithChildren,
workflowId: id,
isOrg,
});
} else {
await upsertSmsReminderFieldForEventTypes({
activeOn: activeOnWithChildren,
workflowId: id,
isSmsReminderNumberRequired: steps.some(
(s) =>
(s.action === WorkflowActions.SMS_ATTENDEE || s.action === WorkflowActions.WHATSAPP_ATTENDEE) &&
s.numberRequired
),
isOrg,
});
}
}
// Remove or add attendeePhoneNumber field for AI phone call actions
const aiPhoneCallStepsNeeded =
activeOn.length && steps.some((s) => s.action === WorkflowActions.CAL_AI_PHONE_CALL);
activeOnWithChildren.length && steps.some((s) => s.action === WorkflowActions.CAL_AI_PHONE_CALL);
await removeAIAgentCallPhoneNumberFieldForEventTypes({
activeOnToRemove: removedActiveOnIds,
workflowId: id,
isOrg,
activeOn,
activeOn: activeOnWithChildren,
});
if (!aiPhoneCallStepsNeeded) {
@@ -768,10 +648,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
if (!step.agentId) return;
try {
const agent = await ctx.prisma.agent.findUnique({
where: { id: step.agentId },
select: { providerAgentId: true },
});
const agent = await agentRepo.findProviderAgentIdById(step.agentId);
if (!agent?.providerAgentId) {
log.error(`Agent not found for step ${step.id} agentId ${step.agentId}`);
@@ -1,38 +1,57 @@
import { z } from "zod";
import { isFormTrigger } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import {
WORKFLOW_TEMPLATES,
TIME_UNIT,
WORKFLOW_ACTIONS,
WORKFLOW_TRIGGER_EVENTS,
ALLOWED_FORM_WORKFLOW_ACTIONS,
} from "@calcom/features/ee/workflows/lib/constants";
export const ZUpdateInputSchema = z.object({
const stepSchema = z.object({
id: z.number(),
name: z.string(),
activeOn: z.number().array(),
steps: z
.object({
id: z.number(),
stepNumber: z.number(),
action: z.enum(WORKFLOW_ACTIONS),
workflowId: z.number(),
sendTo: z.string().nullable(),
reminderBody: z.string().nullable(),
emailSubject: z.string().nullable(),
template: z.enum(WORKFLOW_TEMPLATES),
numberRequired: z.boolean().nullable(),
sender: z.string().nullable(),
senderName: z.string().nullable(),
includeCalendarEvent: z.boolean(),
agentId: z.string().nullish(),
inboundAgentId: z.string().nullish(),
})
.array(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
time: z.number().nullable(),
timeUnit: z.enum(TIME_UNIT).nullable(),
isActiveOnAll: z.boolean().optional(),
stepNumber: z.number(),
action: z.enum(WORKFLOW_ACTIONS),
workflowId: z.number(),
sendTo: z.string().nullable(),
reminderBody: z.string().nullable(),
emailSubject: z.string().nullable(),
template: z.enum(WORKFLOW_TEMPLATES),
numberRequired: z.boolean().nullable(),
sender: z.string().nullable(),
senderName: z.string().nullable(),
includeCalendarEvent: z.boolean(),
agentId: z.string().nullish(),
inboundAgentId: z.string().nullish(),
});
export const ZUpdateInputSchema = z
.object({
id: z.number(),
name: z.string(),
activeOnEventTypeIds: z.number().array(), // also includes team ids
activeOnRoutingFormIds: z.string().array(),
steps: stepSchema.array(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
time: z.number().nullable(),
timeUnit: z.enum(TIME_UNIT).nullable(),
isActiveOnAll: z.boolean().optional(),
})
.refine(
(data) => {
// For form triggers, validate that all steps use allowed actions
if (isFormTrigger(data.trigger)) {
return data.steps.every((step) =>
(ALLOWED_FORM_WORKFLOW_ACTIONS as readonly string[]).includes(step.action)
);
}
return true;
},
{
message: "This action is not allowed for form triggers",
path: ["steps"],
}
);
export type TUpdateInputSchema = z.infer<typeof ZUpdateInputSchema>;
@@ -26,12 +26,11 @@ import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { Workflow } from "@calcom/prisma/client";
import { WorkflowType as PrismaWorkflowType, type Workflow } from "@calcom/prisma/client";
import type { Prisma, WorkflowStep } from "@calcom/prisma/client";
import type { TimeUnit } from "@calcom/prisma/enums";
import { WorkflowTemplates } from "@calcom/prisma/enums";
@@ -44,8 +43,6 @@ import { TRPCError } from "@trpc/server";
import type { ZWorkflows } from "./getAllActiveWorkflows.schema";
const log = logger.getSubLogger({ prefix: ["workflow"] });
export const bookingSelect = {
userPrimaryEmail: true,
startTime: true,
@@ -456,55 +453,87 @@ async function getAllUserAndTeamEventTypes(teamIds: number[], notMemberOfTeamId:
return teamEventTypes.map((et) => et.id).concat(userEventTypes);
}
export async function isAuthorizedToAddActiveOnIds(
newActiveIds: number[],
isOrg: boolean,
teamId?: number | null,
userId?: number | null
) {
for (const id of newActiveIds) {
if (isOrg) {
const newTeam = await prisma.team.findUnique({
where: {
id,
export async function isAuthorizedToAddActiveOnIds({
newEventTypeIds,
newRoutingFormIds,
newTeamIds,
teamId,
userId,
}: {
newEventTypeIds: number[];
newRoutingFormIds: string[];
newTeamIds: number[];
teamId?: number | null;
userId?: number | null;
}) {
for (const id of newTeamIds) {
const newTeam = await prisma.team.findUnique({
where: {
id,
},
select: {
parent: true,
},
});
if (newTeam?.parent?.id !== teamId) {
return false;
}
}
// Check authorization for event type IDs
for (const id of newEventTypeIds) {
const newEventType = await prisma.eventType.findUnique({
where: {
id,
},
include: {
users: {
select: {
id: true,
},
},
select: {
parent: true,
},
});
if (newTeam?.parent?.id !== teamId) {
children: true,
},
});
if (newEventType) {
if (teamId && teamId !== newEventType.teamId) {
return false;
}
} else {
const newEventType = await prisma.eventType.findUnique({
where: {
id,
},
include: {
users: {
select: {
id: true,
},
},
children: true,
},
});
if (newEventType) {
if (teamId && teamId !== newEventType.teamId) {
return false;
}
if (
!teamId &&
userId &&
newEventType.userId !== userId &&
!newEventType?.users.find((eventTypeUser) => eventTypeUser.id === userId)
) {
return false;
}
if (
!teamId &&
userId &&
newEventType.userId !== userId &&
!newEventType?.users.find((eventTypeUser) => eventTypeUser.id === userId)
) {
return false;
}
}
}
// Check authorization for routing form IDs
for (const id of newRoutingFormIds) {
// For routing forms, check if user has access to the form
const routingForm = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: String(id),
},
select: {
userId: true,
teamId: true,
},
});
if (!routingForm) return false;
if (teamId && teamId !== routingForm.teamId) {
return false;
}
if (!teamId && userId && routingForm.userId !== userId) {
return false;
}
}
return true;
}
@@ -580,6 +609,8 @@ export async function scheduleWorkflowNotifications({
teamId: number | null;
alreadyScheduledActiveOnIds?: number[];
}) {
if (trigger !== WorkflowTriggerEvents.BEFORE_EVENT && trigger !== WorkflowTriggerEvents.AFTER_EVENT) return;
const bookingsToScheduleNotifications = await getBookings(activeOn, isOrg, alreadyScheduledActiveOnIds);
await scheduleBookingReminders(
@@ -757,7 +788,7 @@ export async function scheduleBookingReminders(
let sendTo: string[] = [];
switch (step.action) {
case WorkflowActions.EMAIL_HOST:
case WorkflowActions.EMAIL_HOST: {
sendTo = [bookingInfo.organizer?.email];
const schedulingType = bookingInfo.eventType.schedulingType;
const hosts = bookingInfo.eventType.hosts
@@ -770,6 +801,7 @@ export async function scheduleBookingReminders(
sendTo = sendTo.concat(hosts);
}
break;
}
case WorkflowActions.EMAIL_ATTENDEE:
sendTo = bookingInfo.attendees.map((attendee) => attendee.email);
break;
@@ -941,13 +973,14 @@ export async function getAllWorkflowsFromEventType(
? !eventTypeMetadata?.managedEventConfig?.unlockedFields?.workflows
: false;
const allWorkflows = await getAllWorkflows(
eventTypeWorkflows,
const allWorkflows = await getAllWorkflows({
entityWorkflows: eventTypeWorkflows,
userId,
teamId,
orgId,
workflowsLockedForUser
);
workflowsLockedForUser,
type: PrismaWorkflowType.EVENT_TYPE,
});
return allWorkflows;
}