* 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:
co-authored by
amit@cal.com
coderabbitai[bot]
Devin AI
Amit Sharma
CarinaWolli
Udit Takkar
Benny Joo
cal.com
Morgan
parent
c0c52750b6
commit
44a3a9eabb
+6
-3
@@ -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,
|
||||
|
||||
+480
-34
@@ -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`)
|
||||
|
||||
+120
-22
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
+20
-25
@@ -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)[];
|
||||
}
|
||||
+25
-93
@@ -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;
|
||||
}
|
||||
|
||||
+17
-125
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
-20
@@ -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",
|
||||
|
||||
+1003
-269
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", () => {
|
||||
|
||||
+1
@@ -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>>([]);
|
||||
|
||||
+8
-7
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
+26
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user