diff --git a/apps/api/v2/src/modules/auth/guards/workflows/is-workflow-in-team.ts b/apps/api/v2/src/modules/auth/guards/workflows/is-event-type-workflow-in-team.ts similarity index 82% rename from apps/api/v2/src/modules/auth/guards/workflows/is-workflow-in-team.ts rename to apps/api/v2/src/modules/auth/guards/workflows/is-event-type-workflow-in-team.ts index 8c26a1ea7fd56d..a446a512003008 100644 --- a/apps/api/v2/src/modules/auth/guards/workflows/is-workflow-in-team.ts +++ b/apps/api/v2/src/modules/auth/guards/workflows/is-event-type-workflow-in-team.ts @@ -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 { @@ -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)) { diff --git a/apps/api/v2/src/modules/auth/guards/workflows/is-routing-form-workflow-in-team.ts b/apps/api/v2/src/modules/auth/guards/workflows/is-routing-form-workflow-in-team.ts new file mode 100644 index 00000000000000..2a86cde7421d7f --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/workflows/is-routing-form-workflow-in-team.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + 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 }; + } +} diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index 86aa6eb5474a0d..4c725b7f417b9d 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -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, diff --git a/apps/api/v2/src/modules/organizations/teams/workflows/controllers/org-team-workflows.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/workflows/controllers/org-team-workflows.controller.e2e-spec.ts index 12d8fcab504e98..d99630aaa604a4 100644 --- a/apps/api/v2/src/modules/organizations/teams/workflows/controllers/org-team-workflows.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/teams/workflows/controllers/org-team-workflows.controller.e2e-spec.ts @@ -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 } }, }); + + 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; - sampleCreateWorkflowDto = { + sampleCreateEventTypeWorkflowDto = { name: `E2E Test Workflow ${randomString()}`, activation: { isActiveOnAllEventTypes: true, @@ -208,6 +261,43 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => { html: "

Reminder for your event {EVENT_NAME}.

", }, }, + { + 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: "

Reminder for your event {EVENT_NAME}.

", + }, + }, ], }; 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: "

Reminder for your event {EVENT_NAME}.

", + }, + }, + ]; + 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 = { + 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 = { 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: "

Update Reminder for your event {EVENT_NAME}.

", + }, + } 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 = { + 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: "

Update Reminder for your event {EVENT_NAME}.

", + }, + } 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 = { + const partialUpdateDto: Partial = { 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 = { + const partialUpdateDto: Partial = { 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`) diff --git a/apps/api/v2/src/modules/organizations/teams/workflows/controllers/org-team-workflows.controller.ts b/apps/api/v2/src/modules/organizations/teams/workflows/controllers/org-team-workflows.controller.ts index 7be286cf0c03e6..1716881b6f8f68 100644 --- a/apps/api/v2/src/modules/organizations/teams/workflows/controllers/org-team-workflows.controller.ts +++ b/apps/api/v2/src/modules/organizations/teams/workflows/controllers/org-team-workflows.controller.ts @@ -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 { + ): Promise { 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 { + 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 { - const workflow = await this.workflowsService.getTeamWorkflowById(teamId, workflowId); + ): Promise { + 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 { + 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 { - const workflow = await this.workflowsService.createTeamWorkflow(user, teamId, data); + @Body() data: CreateEventTypeWorkflowDto + ): Promise { + 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 { + 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 { - const workflow = await this.workflowsService.updateTeamWorkflow(user, teamId, workflowId, data); + @Body() data: UpdateEventTypeWorkflowDto + ): Promise { + 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 { + 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 }; } } diff --git a/apps/api/v2/src/modules/workflows/inputs/create-workflow.input.ts b/apps/api/v2/src/modules/workflows/inputs/create-event-type-workflow.input.ts similarity index 86% rename from apps/api/v2/src/modules/workflows/inputs/create-workflow.input.ts rename to apps/api/v2/src/modules/workflows/inputs/create-event-type-workflow.input.ts index 9c0fcd22dbd700..3d21a13a48533f 100644 --- a/apps/api/v2/src/modules/workflows/inputs/create-workflow.input.ts +++ b/apps/api/v2/src/modules/workflows/inputs/create-event-type-workflow.input.ts @@ -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: [ diff --git a/apps/api/v2/src/modules/workflows/inputs/create-form-workflow.ts b/apps/api/v2/src/modules/workflows/inputs/create-form-workflow.ts new file mode 100644 index 00000000000000..0a400753734111 --- /dev/null +++ b/apps/api/v2/src/modules/workflows/inputs/create-form-workflow.ts @@ -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)[]; +} diff --git a/apps/api/v2/src/modules/workflows/inputs/update-workflow.input.ts b/apps/api/v2/src/modules/workflows/inputs/update-event-type-workflow.input.ts similarity index 64% rename from apps/api/v2/src/modules/workflows/inputs/update-workflow.input.ts rename to apps/api/v2/src/modules/workflows/inputs/update-event-type-workflow.input.ts index 124861bae8aa4c..bb3ae3eafb134c 100644 --- a/apps/api/v2/src/modules/workflows/inputs/update-workflow.input.ts +++ b/apps/api/v2/src/modules/workflows/inputs/update-event-type-workflow.input.ts @@ -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: [ diff --git a/apps/api/v2/src/modules/workflows/inputs/update-form-workflow.input.ts b/apps/api/v2/src/modules/workflows/inputs/update-form-workflow.input.ts new file mode 100644 index 00000000000000..f6742f3c5e3ee9 --- /dev/null +++ b/apps/api/v2/src/modules/workflows/inputs/update-form-workflow.input.ts @@ -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)[]; +} diff --git a/apps/api/v2/src/modules/workflows/inputs/workflow-step.input.ts b/apps/api/v2/src/modules/workflows/inputs/workflow-step.input.ts index 1fa00c191e3e0d..ac9a51971576b1 100644 --- a/apps/api/v2/src/modules/workflows/inputs/workflow-step.input.ts +++ b/apps/api/v2/src/modules/workflows/inputs/workflow-step.input.ts @@ -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; } diff --git a/apps/api/v2/src/modules/workflows/inputs/workflow-trigger.input.ts b/apps/api/v2/src/modules/workflows/inputs/workflow-trigger.input.ts index 061cba1cd3974d..6789820fbbc230 100644 --- a/apps/api/v2/src/modules/workflows/inputs/workflow-trigger.input.ts +++ b/apps/api/v2/src/modules/workflows/inputs/workflow-trigger.input.ts @@ -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(EVENT_TYPE_WORKFLOW_TRIGGER_TYPES) + type!: WorkflowEventTypeTriggerType; +} + +export class RoutingFormWorkflowTriggerDto { + @ApiProperty({ + description: "Trigger type for the routing-form workflow", + example: "formSubmitted", }) @IsString() - @IsIn([WORKFLOW_TRIGGER_TYPES]) - type!: WorkflowTriggerType; + @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; +} diff --git a/apps/api/v2/src/modules/workflows/outputs/workflow.output.ts b/apps/api/v2/src/modules/workflows/outputs/base-workflow.output.ts similarity index 50% rename from apps/api/v2/src/modules/workflows/outputs/workflow.output.ts rename to apps/api/v2/src/modules/workflows/outputs/base-workflow.output.ts index 5a58d92a9be4d8..67332defb20339 100644 --- a/apps/api/v2/src/modules/workflows/outputs/workflow.output.ts +++ b/apps/api/v2/src/modules/workflows/outputs/base-workflow.output.ts @@ -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; -} diff --git a/apps/api/v2/src/modules/workflows/outputs/event-type-workflow.output.ts b/apps/api/v2/src/modules/workflows/outputs/event-type-workflow.output.ts new file mode 100644 index 00000000000000..b52e3c219064ad --- /dev/null +++ b/apps/api/v2/src/modules/workflows/outputs/event-type-workflow.output.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/workflows/outputs/routing-form-workflow.output.ts b/apps/api/v2/src/modules/workflows/outputs/routing-form-workflow.output.ts new file mode 100644 index 00000000000000..d5987cf88f6f2a --- /dev/null +++ b/apps/api/v2/src/modules/workflows/outputs/routing-form-workflow.output.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/workflows/services/team-event-type-workflows.service.ts b/apps/api/v2/src/modules/workflows/services/team-event-type-workflows.service.ts new file mode 100644 index 00000000000000..3d2dffff0fe982 --- /dev/null +++ b/apps/api/v2/src/modules/workflows/services/team-event-type-workflows.service.ts @@ -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); + } +} diff --git a/apps/api/v2/src/modules/workflows/services/team-workflows.service.ts b/apps/api/v2/src/modules/workflows/services/team-routing-form-workflows.service.ts similarity index 50% rename from apps/api/v2/src/modules/workflows/services/team-workflows.service.ts rename to apps/api/v2/src/modules/workflows/services/team-routing-form-workflows.service.ts index 1cbcd1f01af55f..3de1cd0b38922d 100644 --- a/apps/api/v2/src/modules/workflows/services/team-workflows.service.ts +++ b/apps/api/v2/src/modules/workflows/services/team-routing-form-workflows.service.ts @@ -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); } } diff --git a/apps/api/v2/src/modules/workflows/services/workflows.input.service.ts b/apps/api/v2/src/modules/workflows/services/workflows.input.service.ts index 64acd8081482d0..d3e2154eaf247d 100644 --- a/apps/api/v2/src/modules/workflows/services/workflows.input.service.ts +++ b/apps/api/v2/src/modules/workflows/services/workflows.input.service.ts @@ -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; + const templateForZod = stepDto?.template?.toUpperCase() as unknown as Uppercase; 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 { + 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 { + 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 { + 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; diff --git a/apps/api/v2/src/modules/workflows/services/workflows.output.service.ts b/apps/api/v2/src/modules/workflows/services/workflows.output.service.ts index 312dd70adffc03..3bf96d06aa60a4 100644 --- a/apps/api/v2/src/modules/workflows/services/workflows.output.service.ts +++ b/apps/api/v2/src/modules/workflows/services/workflows.output.service.ts @@ -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; + @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 customRecipient = step.sendTo ?? ""; + const reminderBody = step.reminderBody ?? ""; + + 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, + }, }; - 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 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; - } + 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", + }; + } } } diff --git a/apps/api/v2/src/modules/workflows/workflows.repository.ts b/apps/api/v2/src/modules/workflows/workflows.repository.ts index e06066a5ba8391..f8be74f9dfd349 100644 --- a/apps/api/v2/src/modules/workflows/workflows.repository.ts +++ b/apps/api/v2/src/modules/workflows/workflows.repository.ts @@ -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 { + async getEventTypeTeamWorkflowById(teamId: number, id: number): Promise { 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 { + async getRoutingFormTeamWorkflowById(teamId: number, id: number): Promise { + 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 { 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 { + 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,29 @@ export class WorkflowsRepository { timeUnit: TimeUnit.HOUR, teamId, }, - include: { activeOn: true, steps: true }, + include: { activeOn: true, steps: true, activeOnRoutingForms: true }, + }); + } + + async updateRoutingFormTeamWorkflow( + 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.getRoutingFormTeamWorkflowById(teamId, workflowId); + return workflow; } - async updateTeamWorkflow( + async updateEventTypeTeamWorkflow( user: UserWithProfile, teamId: number, workflowId: number, @@ -77,7 +138,7 @@ export class WorkflowsRepository { input: data, }); - const workflow = await this.getTeamWorkflowById(teamId, workflowId); + const workflow = await this.getEventTypeTeamWorkflowById(teamId, workflowId); return workflow; } } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 2d0cc310756616..1f20bb84df9b49 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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.", @@ -3697,6 +3702,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", diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index c9d4b6a5848718..923958492c396b 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -28,8 +28,8 @@ "required": false, "in": "query", "description": "The number of items to return", - "example": 10, "schema": { + "example": 10, "type": "number" } }, @@ -38,8 +38,8 @@ "required": false, "in": "query", "description": "The number of items to skip", - "example": 0, "schema": { + "example": 0, "type": "number" } }, @@ -48,8 +48,8 @@ "required": false, "in": "query", "description": "Filter managed users by email. If you want to filter by multiple emails, separate them with a comma.", - "example": "?emails=email1@example.com,email2@example.com", "schema": { + "example": "?emails=email1@example.com,email2@example.com", "type": "array", "items": { "type": "string" @@ -431,11 +431,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -444,10 +444,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -634,11 +634,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -647,10 +647,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -1129,8 +1129,8 @@ "required": false, "in": "query", "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", - "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", "schema": { + "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", "type": "array", "items": { "type": "string" @@ -1142,8 +1142,8 @@ "required": false, "in": "query", "description": "Filter by teamIds. Team ids must be separated by a comma.", - "example": "?teamIds=100,200", "schema": { + "example": "?teamIds=100,200", "type": "array", "items": { "type": "number" @@ -1219,8 +1219,8 @@ "required": false, "in": "query", "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", - "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", "schema": { + "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", "type": "array", "items": { "type": "string" @@ -1232,8 +1232,8 @@ "required": false, "in": "query", "description": "Filter by teamIds. Team ids must be separated by a comma.", - "example": "?teamIds=100,200", "schema": { + "example": "?teamIds=100,200", "type": "array", "items": { "type": "number" @@ -1447,8 +1447,8 @@ "required": false, "in": "query", "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", - "example": "?status=upcoming,past", "schema": { + "example": "?status=upcoming,past", "type": "array", "items": { "type": "string", @@ -1461,8 +1461,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's email address.", - "example": "example@domain.com", "schema": { + "example": "example@domain.com", "type": "string" } }, @@ -1471,8 +1471,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's name.", - "example": "John Doe", "schema": { + "example": "John Doe", "type": "string" } }, @@ -1481,8 +1481,8 @@ "required": false, "in": "query", "description": "Filter bookings by the booking Uid.", - "example": "2NtaeaVcKfpmSZ4CthFdfk", "schema": { + "example": "2NtaeaVcKfpmSZ4CthFdfk", "type": "string" } }, @@ -1491,8 +1491,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.", - "example": "?eventTypeIds=100,200", "schema": { + "example": "?eventTypeIds=100,200", "type": "string" } }, @@ -1501,8 +1501,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type id belonging to the user.", - "example": "?eventTypeId=100", "schema": { + "example": "?eventTypeId=100", "type": "string" } }, @@ -1511,8 +1511,8 @@ "required": false, "in": "query", "description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.", - "example": "?teamIds=50,60", "schema": { + "example": "?teamIds=50,60", "type": "string" } }, @@ -1521,8 +1521,8 @@ "required": false, "in": "query", "description": "Filter bookings by team id that user is part of", - "example": "?teamId=50", "schema": { + "example": "?teamId=50", "type": "string" } }, @@ -1531,8 +1531,8 @@ "required": false, "in": "query", "description": "Filter bookings with start after this date string.", - "example": "?afterStart=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterStart=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -1541,8 +1541,8 @@ "required": false, "in": "query", "description": "Filter bookings with end before this date string.", - "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "schema": { + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "type": "string" } }, @@ -1551,8 +1551,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been created after this date string.", - "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -1561,8 +1561,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been created before this date string.", - "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", "schema": { + "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", "type": "string" } }, @@ -1571,8 +1571,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been updated after this date string.", - "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -1581,8 +1581,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been updated before this date string.", - "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", "schema": { + "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", "type": "string" } }, @@ -1591,8 +1591,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -1602,8 +1602,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -1613,8 +1613,8 @@ "required": false, "in": "query", "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", - "example": "?sortCreated=asc OR ?sortCreated=desc", "schema": { + "example": "?sortCreated=asc OR ?sortCreated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -1624,8 +1624,8 @@ "required": false, "in": "query", "description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.", - "example": "?sortUpdated=asc OR ?sortUpdated=desc", "schema": { + "example": "?sortUpdated=asc OR ?sortUpdated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -1635,9 +1635,9 @@ "required": false, "in": "query", "description": "The number of items to return", - "example": 10, "schema": { "default": 100, + "example": 10, "type": "number" } }, @@ -1646,9 +1646,9 @@ "required": false, "in": "query", "description": "The number of items to skip", - "example": 0, "schema": { "default": 0, + "example": 0, "type": "number" } }, @@ -1657,8 +1657,8 @@ "required": false, "in": "query", "description": "Filter bookings by ids of users within your organization.", - "example": "?userIds=100,200", "schema": { + "example": "?userIds=100,200", "type": "string" } }, @@ -1871,11 +1871,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -1884,10 +1884,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -2283,8 +2283,8 @@ "required": false, "in": "query", "description": "Filter by teamIds. Team ids must be separated by a comma.", - "example": "?teamIds=100,200", "schema": { + "example": "?teamIds=100,200", "type": "array", "items": { "type": "number" @@ -2473,8 +2473,8 @@ "required": true, "in": "query", "description": "\n Time starting from which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z\n ", - "example": "2050-09-05", "schema": { + "example": "2050-09-05", "type": "string" } }, @@ -2483,8 +2483,8 @@ "required": true, "in": "query", "description": "\n Time until which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z", - "example": "2050-09-06", "schema": { + "example": "2050-09-06", "type": "string" } }, @@ -2493,8 +2493,8 @@ "required": false, "in": "query", "description": "Time zone in which the available slots should be returned. Defaults to UTC.", - "example": "Europe/Rome", "schema": { + "example": "Europe/Rome", "type": "string" } }, @@ -2503,8 +2503,8 @@ "required": false, "in": "query", "description": "If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.", - "example": "60", "schema": { + "example": "60", "type": "number" } }, @@ -2513,8 +2513,8 @@ "required": false, "in": "query", "description": "Format of slot times in response. Use 'range' to get start and end times.", - "example": "range", "schema": { + "example": "range", "enum": ["range", "time"], "type": "string" } @@ -2524,8 +2524,8 @@ "required": false, "in": "query", "description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.", - "example": "abc123def456", "schema": { + "example": "abc123def456", "type": "string" } }, @@ -2534,8 +2534,8 @@ "required": false, "in": "query", "description": "Whether to queue the form response.", - "example": true, "schema": { + "example": true, "type": "boolean" } } @@ -2664,11 +2664,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -2677,10 +2677,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -3095,8 +3095,8 @@ "required": false, "in": "query", "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", - "example": "?status=upcoming,past", "schema": { + "example": "?status=upcoming,past", "type": "array", "items": { "type": "string", @@ -3109,8 +3109,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's email address.", - "example": "example@domain.com", "schema": { + "example": "example@domain.com", "type": "string" } }, @@ -3119,8 +3119,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's name.", - "example": "John Doe", "schema": { + "example": "John Doe", "type": "string" } }, @@ -3129,8 +3129,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type ids belonging to the team. Event type ids must be separated by a comma.", - "example": "?eventTypeIds=100,200", "schema": { + "example": "?eventTypeIds=100,200", "type": "string" } }, @@ -3139,8 +3139,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type id belonging to the team.", - "example": "?eventTypeId=100", "schema": { + "example": "?eventTypeId=100", "type": "string" } }, @@ -3149,8 +3149,8 @@ "required": false, "in": "query", "description": "Filter bookings with start after this date string.", - "example": "?afterStart=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterStart=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -3159,8 +3159,8 @@ "required": false, "in": "query", "description": "Filter bookings with end before this date string.", - "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "schema": { + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "type": "string" } }, @@ -3169,8 +3169,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -3180,8 +3180,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -3191,8 +3191,8 @@ "required": false, "in": "query", "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", - "example": "?sortCreated=asc OR ?sortCreated=desc", "schema": { + "example": "?sortCreated=asc OR ?sortCreated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -3202,10 +3202,10 @@ "required": false, "in": "query", "description": "The number of items to return", - "example": 10, "schema": { "minimum": 1, "maximum": 250, + "example": 10, "type": "number" } }, @@ -3214,9 +3214,9 @@ "required": false, "in": "query", "description": "The number of items to skip", - "example": 0, "schema": { "minimum": 0, + "example": 0, "type": "number" } }, @@ -3297,8 +3297,8 @@ "required": false, "in": "query", "description": "Filter booking references by type", - "example": "google_calendar", "schema": { + "example": "google_calendar", "enum": [ "google_calendar", "office365_calendar", @@ -4107,11 +4107,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -4120,10 +4120,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -4538,11 +4538,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -4551,10 +4551,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -5183,8 +5183,8 @@ "required": true, "in": "query", "description": "\n Time starting from which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z\n ", - "example": "2050-09-05", "schema": { + "example": "2050-09-05", "type": "string" } }, @@ -5193,8 +5193,8 @@ "required": true, "in": "query", "description": "\n Time until which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z", - "example": "2050-09-06", "schema": { + "example": "2050-09-06", "type": "string" } }, @@ -5203,8 +5203,8 @@ "required": false, "in": "query", "description": "Time zone in which the available slots should be returned. Defaults to UTC.", - "example": "Europe/Rome", "schema": { + "example": "Europe/Rome", "type": "string" } }, @@ -5213,8 +5213,8 @@ "required": false, "in": "query", "description": "If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.", - "example": "60", "schema": { + "example": "60", "type": "number" } }, @@ -5223,8 +5223,8 @@ "required": false, "in": "query", "description": "Format of slot times in response. Use 'range' to get start and end times.", - "example": "range", "schema": { + "example": "range", "enum": ["range", "time"], "type": "string" } @@ -5234,8 +5234,8 @@ "required": false, "in": "query", "description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.", - "example": "abc123def456", "schema": { + "example": "abc123def456", "type": "string" } }, @@ -5244,8 +5244,8 @@ "required": false, "in": "query", "description": "Whether to queue the form response.", - "example": true, "schema": { + "example": true, "type": "boolean" } } @@ -5382,11 +5382,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -5395,10 +5395,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -5678,11 +5678,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -5691,10 +5691,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -5705,7 +5705,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetWorkflowsOutput" + "$ref": "#/components/schemas/GetEventTypeWorkflowsOutput" } } } @@ -5714,8 +5714,8 @@ "tags": ["Orgs / Teams / Workflows"] }, "post": { - "operationId": "OrganizationTeamWorkflowsController_createWorkflow", - "summary": "Create organization team workflow", + "operationId": "OrganizationTeamWorkflowsController_createEventTypeWorkflow", + "summary": "Create organization team workflow for event-types", "parameters": [ { "name": "Authorization", @@ -5758,7 +5758,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateWorkflowDto" + "$ref": "#/components/schemas/CreateEventTypeWorkflowDto" } } } @@ -5769,7 +5769,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetWorkflowOutput" + "$ref": "#/components/schemas/GetEventTypeWorkflowOutput" } } } @@ -5778,10 +5778,10 @@ "tags": ["Orgs / Teams / Workflows"] } }, - "/v2/organizations/{orgId}/teams/{teamId}/workflows/{workflowId}": { + "/v2/organizations/{orgId}/teams/{teamId}/workflows/routing-form": { "get": { - "operationId": "OrganizationTeamWorkflowsController_getWorkflowById", - "summary": "Get organization team workflow", + "operationId": "OrganizationTeamWorkflowsController_getRoutingFormWorkflows", + "summary": "Get organization team workflows", "parameters": [ { "name": "Authorization", @@ -5811,7 +5811,7 @@ } }, { - "name": "teamId", + "name": "orgId", "required": true, "in": "path", "schema": { @@ -5819,12 +5819,37 @@ } }, { - "name": "workflowId", + "name": "teamId", "required": true, "in": "path", "schema": { "type": "number" } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "Maximum number of items to return", + "schema": { + "minimum": 1, + "maximum": 250, + "default": 250, + "example": 25, + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "Number of items to skip", + "schema": { + "minimum": 0, + "default": 0, + "example": 0, + "type": "number" + } } ], "responses": { @@ -5833,7 +5858,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetWorkflowOutput" + "$ref": "#/components/schemas/GetRoutingFormWorkflowsOutput" } } } @@ -5841,9 +5866,9 @@ }, "tags": ["Orgs / Teams / Workflows"] }, - "patch": { - "operationId": "OrganizationTeamWorkflowsController_updateWorkflow", - "summary": "Update organization team workflow", + "post": { + "operationId": "OrganizationTeamWorkflowsController_createFormWorkflow", + "summary": "Create organization team workflow for routing-forms", "parameters": [ { "name": "Authorization", @@ -5879,14 +5904,6 @@ "schema": { "type": "number" } - }, - { - "name": "workflowId", - "required": true, - "in": "path", - "schema": { - "type": "number" - } } ], "requestBody": { @@ -5894,28 +5911,30 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateWorkflowDto" + "$ref": "#/components/schemas/CreateFormWorkflowDto" } } } }, "responses": { - "200": { + "201": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetWorkflowOutput" + "$ref": "#/components/schemas/GetRoutingFormWorkflowOutput" } } } } }, "tags": ["Orgs / Teams / Workflows"] - }, - "delete": { - "operationId": "OrganizationTeamWorkflowsController_deleteWorkflow", - "summary": "Delete organization team workflow", + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/workflows/{workflowId}": { + "get": { + "operationId": "OrganizationTeamWorkflowsController_getWorkflowById", + "summary": "Get organization team workflow", "parameters": [ { "name": "Authorization", @@ -5963,16 +5982,21 @@ ], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetEventTypeWorkflowOutput" + } + } + } } }, "tags": ["Orgs / Teams / Workflows"] - } - }, - "/v2/organizations/{orgId}/users": { - "get": { - "operationId": "OrganizationsUsersController_getOrganizationsUsers", - "summary": "Get all users", + }, + "patch": { + "operationId": "OrganizationTeamWorkflowsController_updateWorkflow", + "summary": "Update organization team workflow", "parameters": [ { "name": "Authorization", @@ -6002,7 +6026,7 @@ } }, { - "name": "orgId", + "name": "teamId", "required": true, "in": "path", "schema": { @@ -6010,96 +6034,98 @@ } }, { - "name": "take", - "required": false, - "in": "query", - "description": "The number of items to return", - "example": 10, + "name": "workflowId", + "required": true, + "in": "path", "schema": { - "minimum": 1, - "maximum": 1000, "type": "number" } - }, + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEventTypeWorkflowDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetEventTypeWorkflowOutput" + } + } + } + } + }, + "tags": ["Orgs / Teams / Workflows"] + }, + "delete": { + "operationId": "OrganizationTeamWorkflowsController_deleteWorkflow", + "summary": "Delete organization team workflow", + "parameters": [ { - "name": "skip", + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", "required": false, - "in": "query", - "description": "The number of items to skip", - "example": 0, "schema": { - "minimum": 0, - "type": "number" + "type": "string" } }, { - "name": "emails", + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", "required": false, - "in": "query", - "description": "The email address or an array of email addresses to filter by", "schema": { - "type": "array", - "items": { - "type": "string" - } + "type": "string" } }, { - "name": "assignedOptionIds", + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", "required": false, - "in": "query", - "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", - "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", "schema": { - "type": "array", - "items": { - "type": "string" - } + "type": "string" } }, { - "name": "attributeQueryOperator", - "required": false, - "in": "query", - "description": "Query operator used to filter assigned options, AND by default.", - "example": "NONE", + "name": "teamId", + "required": true, + "in": "path", "schema": { - "default": "AND", - "enum": ["OR", "AND", "NONE"], - "type": "string" + "type": "number" } }, { - "name": "teamIds", - "required": false, - "in": "query", - "description": "Filter by teamIds. Team ids must be separated by a comma.", - "example": "?teamIds=100,200", + "name": "workflowId", + "required": true, + "in": "path", "schema": { - "type": "array", - "items": { - "type": "number" - } + "type": "number" } } ], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetOrganizationUsersResponseDTO" - } - } - } + "description": "" } }, - "tags": ["Orgs / Users"] - }, - "post": { - "operationId": "OrganizationsUsersController_createOrganizationUser", - "summary": "Create a user", + "tags": ["Orgs / Teams / Workflows"] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/workflows/{workflowId}/routing-form": { + "get": { + "operationId": "OrganizationTeamWorkflowsController_getRoutingFormWorkflowById", + "summary": "Get organization team workflow", "parameters": [ { "name": "Authorization", @@ -6127,37 +6153,41 @@ "schema": { "type": "string" } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateOrganizationUserInput" - } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "workflowId", + "required": true, + "in": "path", + "schema": { + "type": "number" } } - }, + ], "responses": { - "201": { + "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetOrganizationUserOutput" + "$ref": "#/components/schemas/GetRoutingFormWorkflowOutput" } } } } }, - "tags": ["Orgs / Users"] - } - }, - "/v2/organizations/{orgId}/users/{userId}": { + "tags": ["Orgs / Teams / Workflows"] + }, "patch": { - "operationId": "OrganizationsUsersController_updateOrganizationUser", - "summary": "Update a user", + "operationId": "OrganizationTeamWorkflowsController_updateRoutingFormWorkflow", + "summary": "Update organization routing form team workflow", "parameters": [ { "name": "Authorization", @@ -6187,7 +6217,7 @@ } }, { - "name": "orgId", + "name": "teamId", "required": true, "in": "path", "schema": { @@ -6195,7 +6225,7 @@ } }, { - "name": "userId", + "name": "workflowId", "required": true, "in": "path", "schema": { @@ -6208,7 +6238,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateOrganizationUserInput" + "$ref": "#/components/schemas/UpdateFormWorkflowDto" } } } @@ -6219,17 +6249,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetOrganizationUserOutput" + "$ref": "#/components/schemas/GetRoutingFormWorkflowOutput" } } } } }, - "tags": ["Orgs / Users"] + "tags": ["Orgs / Teams / Workflows"] }, "delete": { - "operationId": "OrganizationsUsersController_deleteOrganizationUser", - "summary": "Delete a user", + "operationId": "OrganizationTeamWorkflowsController_deleteRoutingFormWorkflow", + "summary": "Delete organization team routing-form workflow", "parameters": [ { "name": "Authorization", @@ -6259,7 +6289,7 @@ } }, { - "name": "orgId", + "name": "teamId", "required": true, "in": "path", "schema": { @@ -6267,7 +6297,7 @@ } }, { - "name": "userId", + "name": "workflowId", "required": true, "in": "path", "schema": { @@ -6277,17 +6307,331 @@ ], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetOrganizationUserOutput" - } - } - } + "description": "" } }, - "tags": ["Orgs / Users"] + "tags": ["Orgs / Teams / Workflows"] + } + }, + "/v2/organizations/{orgId}/users": { + "get": { + "operationId": "OrganizationsUsersController_getOrganizationsUsers", + "summary": "Get all users", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "schema": { + "minimum": 1, + "maximum": 1000, + "example": 10, + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "schema": { + "minimum": 0, + "example": 0, + "type": "number" + } + }, + { + "name": "emails", + "required": false, + "in": "query", + "description": "The email address or an array of email addresses to filter by", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "assignedOptionIds", + "required": false, + "in": "query", + "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", + "schema": { + "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "attributeQueryOperator", + "required": false, + "in": "query", + "description": "Query operator used to filter assigned options, AND by default.", + "schema": { + "default": "AND", + "example": "NONE", + "enum": ["OR", "AND", "NONE"], + "type": "string" + } + }, + { + "name": "teamIds", + "required": false, + "in": "query", + "description": "Filter by teamIds. Team ids must be separated by a comma.", + "schema": { + "example": "?teamIds=100,200", + "type": "array", + "items": { + "type": "number" + } + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationUsersResponseDTO" + } + } + } + } + }, + "tags": ["Orgs / Users"] + }, + "post": { + "operationId": "OrganizationsUsersController_createOrganizationUser", + "summary": "Create a user", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationUserInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationUserOutput" + } + } + } + } + }, + "tags": ["Orgs / Users"] + } + }, + "/v2/organizations/{orgId}/users/{userId}": { + "patch": { + "operationId": "OrganizationsUsersController_updateOrganizationUser", + "summary": "Update a user", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationUserInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationUserOutput" + } + } + } + } + }, + "tags": ["Orgs / Users"] + }, + "delete": { + "operationId": "OrganizationsUsersController_deleteOrganizationUser", + "summary": "Delete a user", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationUserOutput" + } + } + } + } + }, + "tags": ["Orgs / Users"] } }, "/v2/organizations/{orgId}/users/{userId}/bookings": { @@ -6343,8 +6687,8 @@ "required": false, "in": "query", "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", - "example": "?status=upcoming,past", "schema": { + "example": "?status=upcoming,past", "type": "array", "items": { "type": "string", @@ -6357,8 +6701,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's email address.", - "example": "example@domain.com", "schema": { + "example": "example@domain.com", "type": "string" } }, @@ -6367,8 +6711,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's name.", - "example": "John Doe", "schema": { + "example": "John Doe", "type": "string" } }, @@ -6377,8 +6721,8 @@ "required": false, "in": "query", "description": "Filter bookings by the booking Uid.", - "example": "2NtaeaVcKfpmSZ4CthFdfk", "schema": { + "example": "2NtaeaVcKfpmSZ4CthFdfk", "type": "string" } }, @@ -6387,8 +6731,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.", - "example": "?eventTypeIds=100,200", "schema": { + "example": "?eventTypeIds=100,200", "type": "string" } }, @@ -6397,8 +6741,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type id belonging to the user.", - "example": "?eventTypeId=100", "schema": { + "example": "?eventTypeId=100", "type": "string" } }, @@ -6407,8 +6751,8 @@ "required": false, "in": "query", "description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.", - "example": "?teamIds=50,60", "schema": { + "example": "?teamIds=50,60", "type": "string" } }, @@ -6417,8 +6761,8 @@ "required": false, "in": "query", "description": "Filter bookings by team id that user is part of", - "example": "?teamId=50", "schema": { + "example": "?teamId=50", "type": "string" } }, @@ -6427,8 +6771,8 @@ "required": false, "in": "query", "description": "Filter bookings with start after this date string.", - "example": "?afterStart=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterStart=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -6437,8 +6781,8 @@ "required": false, "in": "query", "description": "Filter bookings with end before this date string.", - "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "schema": { + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "type": "string" } }, @@ -6447,8 +6791,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been created after this date string.", - "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -6457,8 +6801,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been created before this date string.", - "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", "schema": { + "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", "type": "string" } }, @@ -6467,8 +6811,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been updated after this date string.", - "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -6477,8 +6821,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been updated before this date string.", - "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", "schema": { + "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", "type": "string" } }, @@ -6487,8 +6831,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -6498,8 +6842,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -6509,8 +6853,8 @@ "required": false, "in": "query", "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", - "example": "?sortCreated=asc OR ?sortCreated=desc", "schema": { + "example": "?sortCreated=asc OR ?sortCreated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -6520,8 +6864,8 @@ "required": false, "in": "query", "description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.", - "example": "?sortUpdated=asc OR ?sortUpdated=desc", "schema": { + "example": "?sortUpdated=asc OR ?sortUpdated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -6531,9 +6875,9 @@ "required": false, "in": "query", "description": "The number of items to return", - "example": 10, "schema": { "default": 100, + "example": 10, "type": "number" } }, @@ -6542,9 +6886,9 @@ "required": false, "in": "query", "description": "The number of items to skip", - "example": 0, "schema": { "default": 0, + "example": 0, "type": "number" } } @@ -6602,11 +6946,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -6615,10 +6959,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -6627,8 +6971,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -6638,8 +6982,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -6869,11 +7213,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -6882,10 +7226,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -6894,8 +7238,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -6905,8 +7249,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -6916,8 +7260,8 @@ "required": false, "in": "query", "description": "Filter ooo entries by the user email address. user must be within your organization.", - "example": "example@domain.com", "schema": { + "example": "example@domain.com", "type": "string" } } @@ -7293,11 +7637,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -7306,10 +7650,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -7706,8 +8050,8 @@ "required": false, "in": "query", "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", - "example": "?status=upcoming,past", "schema": { + "example": "?status=upcoming,past", "type": "array", "items": { "type": "string", @@ -7720,8 +8064,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's email address.", - "example": "example@domain.com", "schema": { + "example": "example@domain.com", "type": "string" } }, @@ -7730,8 +8074,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's name.", - "example": "John Doe", "schema": { + "example": "John Doe", "type": "string" } }, @@ -7740,8 +8084,8 @@ "required": false, "in": "query", "description": "Filter bookings by the booking Uid.", - "example": "2NtaeaVcKfpmSZ4CthFdfk", "schema": { + "example": "2NtaeaVcKfpmSZ4CthFdfk", "type": "string" } }, @@ -7750,8 +8094,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.", - "example": "?eventTypeIds=100,200", "schema": { + "example": "?eventTypeIds=100,200", "type": "string" } }, @@ -7760,8 +8104,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type id belonging to the user.", - "example": "?eventTypeId=100", "schema": { + "example": "?eventTypeId=100", "type": "string" } }, @@ -7770,8 +8114,8 @@ "required": false, "in": "query", "description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.", - "example": "?teamIds=50,60", "schema": { + "example": "?teamIds=50,60", "type": "string" } }, @@ -7780,8 +8124,8 @@ "required": false, "in": "query", "description": "Filter bookings by team id that user is part of", - "example": "?teamId=50", "schema": { + "example": "?teamId=50", "type": "string" } }, @@ -7790,8 +8134,8 @@ "required": false, "in": "query", "description": "Filter bookings with start after this date string.", - "example": "?afterStart=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterStart=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -7800,8 +8144,8 @@ "required": false, "in": "query", "description": "Filter bookings with end before this date string.", - "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "schema": { + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "type": "string" } }, @@ -7810,8 +8154,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been created after this date string.", - "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -7820,8 +8164,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been created before this date string.", - "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", "schema": { + "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", "type": "string" } }, @@ -7830,8 +8174,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been updated after this date string.", - "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -7840,8 +8184,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been updated before this date string.", - "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", "schema": { + "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", "type": "string" } }, @@ -7850,8 +8194,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -7861,8 +8205,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -7872,8 +8216,8 @@ "required": false, "in": "query", "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", - "example": "?sortCreated=asc OR ?sortCreated=desc", "schema": { + "example": "?sortCreated=asc OR ?sortCreated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -7883,8 +8227,8 @@ "required": false, "in": "query", "description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.", - "example": "?sortUpdated=asc OR ?sortUpdated=desc", "schema": { + "example": "?sortUpdated=asc OR ?sortUpdated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -7894,9 +8238,9 @@ "required": false, "in": "query", "description": "The number of items to return", - "example": 10, "schema": { "default": 100, + "example": 10, "type": "number" } }, @@ -7905,9 +8249,9 @@ "required": false, "in": "query", "description": "The number of items to skip", - "example": 0, "schema": { "default": 0, + "example": 0, "type": "number" } }, @@ -8613,8 +8957,8 @@ "required": false, "in": "query", "description": "Filter booking references by type", - "example": "google_calendar", "schema": { + "example": "google_calendar", "enum": [ "google_calendar", "office365_calendar", @@ -8840,8 +9184,8 @@ "required": true, "in": "query", "description": "The timezone of the logged in user represented as a string", - "example": "America/New_York", "schema": { + "example": "America/New_York", "type": "string" } }, @@ -8850,8 +9194,8 @@ "required": false, "in": "query", "description": "The starting date for the busy times query", - "example": "2023-10-01", "schema": { + "example": "2023-10-01", "type": "string" } }, @@ -8860,8 +9204,8 @@ "required": false, "in": "query", "description": "The ending date for the busy times query", - "example": "2023-10-31", "schema": { + "example": "2023-10-31", "type": "string" } }, @@ -9852,11 +10196,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -9865,10 +10209,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -10305,11 +10649,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -10318,10 +10662,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -10329,9 +10673,9 @@ "name": "slug", "required": false, "in": "query", - "example": "organization-slug", "description": "The slug of the managed organization", "schema": { + "example": "organization-slug", "type": "string" } }, @@ -10339,9 +10683,9 @@ "name": "metadataKey", "required": false, "in": "query", - "example": "metadata-key", "description": "The key of the metadata - it is case sensitive so provide exactly as stored. If you provide it then you must also provide metadataValue", "schema": { + "example": "metadata-key", "type": "string" } }, @@ -10349,9 +10693,9 @@ "name": "metadataValue", "required": false, "in": "query", - "example": "metadata-value", "description": "The value of the metadata - it is case sensitive so provide exactly as stored. If you provide it then you must also provide metadataKey", "schema": { + "example": "metadata-value", "type": "string" } } @@ -10983,11 +11327,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -10996,10 +11340,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -11046,11 +11390,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -11059,10 +11403,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -11203,8 +11547,8 @@ "required": true, "in": "query", "description": "\n Time starting from which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z\n ", - "example": "2050-09-05", "schema": { + "example": "2050-09-05", "type": "string" } }, @@ -11213,8 +11557,8 @@ "required": true, "in": "query", "description": "\n Time until which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z", - "example": "2050-09-06", "schema": { + "example": "2050-09-06", "type": "string" } }, @@ -11223,8 +11567,8 @@ "required": false, "in": "query", "description": "Time zone in which the available slots should be returned. Defaults to UTC.", - "example": "Europe/Rome", "schema": { + "example": "Europe/Rome", "type": "string" } }, @@ -11233,8 +11577,8 @@ "required": false, "in": "query", "description": "If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.", - "example": "60", "schema": { + "example": "60", "type": "number" } }, @@ -11243,8 +11587,8 @@ "required": false, "in": "query", "description": "Format of slot times in response. Use 'range' to get start and end times.", - "example": "range", "schema": { + "example": "range", "enum": ["range", "time"], "type": "string" } @@ -11254,8 +11598,8 @@ "required": false, "in": "query", "description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.", - "example": "abc123def456", "schema": { + "example": "abc123def456", "type": "string" } }, @@ -11685,96 +12029,108 @@ "required": false, "in": "query", "description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.", - "example": "abc123def456", - "schema": {} + "schema": { + "example": "abc123def456" + } }, { "name": "start", "required": true, "in": "query", "description": "\n Time starting from which available slots should be checked.\n\n Must be in UTC timezone as ISO 8601 datestring.\n\n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z.", - "example": "2050-09-05", - "schema": {} + "schema": { + "example": "2050-09-05" + } }, { "name": "end", "required": true, "in": "query", "description": "\n Time until which available slots should be checked.\n\n Must be in UTC timezone as ISO 8601 datestring.\n\n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z.", - "example": "2050-09-06", - "schema": {} + "schema": { + "example": "2050-09-06" + } }, { "name": "organizationSlug", "required": false, "in": "query", "description": "The slug of the organization to which user with username belongs or team with teamSlug belongs.", - "example": "org-slug", - "schema": {} + "schema": { + "example": "org-slug" + } }, { "name": "teamSlug", "required": false, "in": "query", "description": "The slug of the team who owns event type with eventTypeSlug - used when slots are checked for team event type.", - "example": "team-slug", - "schema": {} + "schema": { + "example": "team-slug" + } }, { "name": "username", "required": false, "in": "query", "description": "The username of the user who owns event type with eventTypeSlug - used when slots are checked for individual user event type.", - "example": "bob", - "schema": {} + "schema": { + "example": "bob" + } }, { "name": "eventTypeSlug", "required": false, "in": "query", "description": "The slug of the event type for which available slots should be checked. If slug is provided then username or teamSlug must be provided too and if relevant organizationSlug too.", - "example": "event-type-slug", - "schema": {} + "schema": { + "example": "event-type-slug" + } }, { "name": "eventTypeId", "required": false, "in": "query", "description": "The ID of the event type for which available slots should be checked.", - "example": "100", - "schema": {} + "schema": { + "example": "100" + } }, { "name": "usernames", "required": false, "in": "query", "description": "The usernames for which available slots should be checked separated by a comma.\n\n Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.\n\n Must contain at least 2 usernames.", - "example": "alice,bob", - "schema": {} + "schema": { + "example": "alice,bob" + } }, { "name": "format", "required": false, "in": "query", "description": "Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.", - "example": "range", - "schema": {} + "schema": { + "example": "range" + } }, { "name": "duration", "required": false, "in": "query", "description": "If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.", - "example": "60", - "schema": {} + "schema": { + "example": "60" + } }, { "name": "timeZone", "required": false, "in": "query", "description": "Time zone in which the available slots should be returned. Defaults to UTC.", - "example": "Europe/Rome", - "schema": {} + "schema": { + "example": "Europe/Rome" + } } ], "responses": { @@ -12704,11 +13060,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -12717,10 +13073,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -12911,11 +13267,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -12924,10 +13280,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -13145,11 +13501,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -13158,10 +13514,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -13208,11 +13564,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -13221,10 +13577,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -13519,11 +13875,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -13532,10 +13888,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -13574,11 +13930,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -13587,10 +13943,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -13753,11 +14109,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -13766,10 +14122,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -19008,7 +19364,7 @@ "description": "The scheduling type for the team event - collective, roundRobin or managed." }, "hosts": { - "description": "Hosts contain specific team members you want to assign to this event type, but if you want to assign all team members, use `assignAllTeamMembers: true` instead and omit this field. For platform customers the hosts can include userIds only of managed users.", + "description": "Hosts contain specific team members you want to assign to this event type, but if you want to assign all team members, use `assignAllTeamMembers: true` instead and omit this field. For platform customers the hosts can include userIds only of managed users. Provide either hosts or assignAllTeamMembers but not both", "type": "array", "items": { "$ref": "#/components/schemas/Host" @@ -19016,7 +19372,7 @@ }, "assignAllTeamMembers": { "type": "boolean", - "description": "If true, all current and future team members will be assigned to this event type" + "description": "If true, all current and future team members will be assigned to this event type. Provide either assignAllTeamMembers or hosts but not both" }, "locations": { "type": "array", @@ -19801,6 +20157,7 @@ "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users who are the event-type owner or org/team admin/owner can book this event type." }, "hosts": { + "description": "Hosts contain specific team members you want to assign to this event type, but if you want to assign all team members, use `assignAllTeamMembers: true` instead and omit this field. For platform customers the hosts can include userIds only of managed users. Provide either hosts or assignAllTeamMembers but not both", "type": "array", "items": { "$ref": "#/components/schemas/Host" @@ -19808,7 +20165,7 @@ }, "assignAllTeamMembers": { "type": "boolean", - "description": "If true, all current and future team members will be assigned to this event type" + "description": "If true, all current and future team members will be assigned to this event type. Provide either assignAllTeamMembers or hosts but not both" }, "locations": { "type": "array", @@ -20663,7 +21020,7 @@ } } }, - "WorkflowActivationOutputDto": { + "EventTypeWorkflowActivationOutputDto": { "type": "object", "properties": { "isActiveOnAllEventTypes": { @@ -20672,54 +21029,276 @@ "description": "Whether the workflow is active for all event types associated with the team/user", "example": false }, - "activeOnEventTypeIds": { + "activeOnEventTypeIds": { + "description": "List of Event Type IDs the workflow is specifically active on (if not active on all)", + "example": [698191, 698192], + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "WorkflowTriggerOffsetOutputDto": { + "type": "object", + "properties": { + "value": { + "type": "number", + "description": "Time value for offset", + "example": 24 + }, + "unit": { + "type": "string", + "description": "Unit for the offset time", + "example": "hour", + "enum": ["hour", "minute", "day"] + } + }, + "required": ["value", "unit"] + }, + "EventTypeWorkflowTriggerOutputDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Trigger type for the workflow", + "example": "beforeEvent", + "enum": [ + "beforeEvent", + "eventCancelled", + "newEvent", + "afterEvent", + "rescheduleEvent", + "afterHostsCalVideoNoShow", + "afterGuestsCalVideoNoShow", + "bookingRejected", + "bookingRequested", + "bookingPaymentInitiated", + "bookingPaid", + "bookingNoShowUpdated" + ] + }, + "offset": { + "description": "Offset details (present for BEFORE_EVENT/AFTER_EVENT)", + "allOf": [ + { + "$ref": "#/components/schemas/WorkflowTriggerOffsetOutputDto" + } + ] + } + }, + "required": ["type"] + }, + "WorkflowMessageOutputDto": { + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Subject of the message", + "example": "Reminder: Your Meeting {EVENT_NAME} - {EVENT_DATE_ddd, MMM D, YYYY h:mma} with Cal.com" + }, + "html": { + "type": "string", + "description": "HTML content of the message", + "example": "

Reminder for {EVENT_NAME}.

" + }, + "text": { + "type": "string", + "description": "Text content of the message (used for SMS/WhatsApp)", + "example": "Reminder for {EVENT_NAME}." + } + }, + "required": ["subject"] + }, + "EventTypeWorkflowStepOutputDto": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Unique identifier of the step", + "example": 67244 + }, + "stepNumber": { + "type": "number", + "description": "Step number in the workflow sequence", + "example": 1 + }, + "recipient": { + "type": "string", + "description": "Intended recipient type", + "example": "const", + "enum": ["const", "attendee", "email", "phone_number"] + }, + "email": { + "type": "string", + "description": "Verified Email if action is EMAIL_ADDRESS", + "example": 31214 + }, + "phone": { + "type": "string", + "description": "Verified Phone if action is SMS_NUMBER or WHATSAPP_NUMBER" + }, + "phoneRequired": { + "type": "boolean", + "description": "whether or not the attendees are required to provide their phone numbers when booking", + "example": true, + "default": false + }, + "template": { + "type": "string", + "description": "Template type used", + "example": "reminder", + "enum": ["reminder", "custom", "rescheduled", "completed", "rating", "cancelled"] + }, + "includeCalendarEvent": { + "type": "object", + "default": false, + "description": "Whether a calendar event (.ics) was included (for email actions)", + "example": true + }, + "sender": { + "type": "string", + "description": "Displayed sender name used for this step", + "example": "Cal.com Notifications" + }, + "message": { + "description": "Message content for this step", + "allOf": [ + { + "$ref": "#/components/schemas/WorkflowMessageOutputDto" + } + ] + }, + "action": { + "type": "string", + "description": "Action to perform", + "example": "email_host", + "enum": [ + "email_host", + "email_attendee", + "email_address", + "sms_attendee", + "sms_number", + "whatsapp_attendee", + "whatsapp_number", + "cal_ai_phone_call" + ] + } + }, + "required": ["id", "stepNumber", "recipient", "template", "sender", "message", "action"] + }, + "EventTypeWorkflowOutput": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Unique identifier of the workflow", + "example": 101 + }, + "name": { + "type": "string", + "description": "Name of the workflow", + "example": "Platform Test Workflow" + }, + "userId": { + "type": "number", + "description": "ID of the user who owns the workflow (if not team-owned)", + "example": 2313 + }, + "teamId": { + "type": "number", + "description": "ID of the team owning the workflow", + "example": 4214321 + }, + "createdAt": { + "type": "object", + "description": "Timestamp of creation", + "example": "2024-05-12T10:00:00.000Z" + }, + "updatedAt": { + "type": "object", + "description": "Timestamp of last update", + "example": "2024-05-12T11:30:00.000Z" + }, + "type": { + "type": "string", + "enum": ["event-type"], + "description": "type of the workflow", + "example": "event-type", + "default": "event-type" + }, + "activation": { + "description": "Activation settings for the workflow", + "allOf": [ + { + "$ref": "#/components/schemas/EventTypeWorkflowActivationOutputDto" + } + ] + }, + "trigger": { + "description": "Trigger configuration", + "allOf": [ + { + "$ref": "#/components/schemas/EventTypeWorkflowTriggerOutputDto" + } + ] + }, + "steps": { + "description": "Steps comprising the workflow", + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTypeWorkflowStepOutputDto" + } + } + }, + "required": ["id", "name", "type", "activation", "trigger", "steps"] + }, + "GetEventTypeWorkflowsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Indicates the status of the response", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "description": "List of workflows", + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTypeWorkflowOutput" + } + } + }, + "required": ["status", "data"] + }, + "RoutingFormWorkflowActivationOutputDto": { + "type": "object", + "properties": { + "isActiveOnAllRoutingForms": { + "type": "boolean", + "default": false, + "description": "Whether the workflow is active for all routing forms associated with the team/user", + "example": false + }, + "activeOnRoutingFormIds": { "description": "List of Event Type IDs the workflow is specifically active on (if not active on all)", - "example": [698191, 698192], + "example": ["5cacdec7-1234-6e1b-78d9-7bcda8a1b332"], "type": "array", "items": { - "type": "number" + "type": "string" } } } }, - "WorkflowTriggerOffsetOutputDto": { - "type": "object", - "properties": { - "value": { - "type": "number", - "description": "Time value for offset", - "example": 24 - }, - "unit": { - "type": "string", - "description": "Unit for the offset time", - "example": "hour", - "enum": ["hour", "minute", "day"] - } - }, - "required": ["value", "unit"] - }, - "WorkflowTriggerOutputDto": { + "RoutingFormWorkflowTriggerOutputDto": { "type": "object", "properties": { "type": { "type": "string", "description": "Trigger type for the workflow", - "example": "beforeEvent", - "enum": [ - "beforeEvent", - "eventCancelled", - "newEvent", - "afterEvent", - "rescheduleEvent", - "afterHostsCalVideoNoShow", - "afterGuestsCalVideoNoShow", - "bookingRejected", - "bookingRequested", - "bookingPaymentInitiated", - "bookingPaid", - "bookingNoShowUpdated" - ] + "example": "formSubmitted", + "enum": ["formSubmitted"] }, "offset": { "description": "Offset details (present for BEFORE_EVENT/AFTER_EVENT)", @@ -20732,28 +21311,7 @@ }, "required": ["type"] }, - "WorkflowMessageOutputDto": { - "type": "object", - "properties": { - "subject": { - "type": "string", - "description": "Subject of the message", - "example": "Reminder: Your Meeting {EVENT_NAME} - {EVENT_DATE_ddd, MMM D, YYYY h:mma} with Cal.com" - }, - "html": { - "type": "string", - "description": "HTML content of the message", - "example": "

Reminder for {EVENT_NAME}.

" - }, - "text": { - "type": "string", - "description": "Text content of the message (used for SMS/WhatsApp)", - "example": "Reminder for {EVENT_NAME}." - } - }, - "required": ["subject"] - }, - "WorkflowStepOutputDto": { + "RoutingFormWorkflowStepOutputDto": { "type": "object", "properties": { "id": { @@ -20766,21 +21324,6 @@ "description": "Step number in the workflow sequence", "example": 1 }, - "action": { - "type": "string", - "description": "Action to perform", - "example": "email_host", - "enum": [ - "email_host", - "email_attendee", - "email_address", - "sms_attendee", - "sms_number", - "whatsapp_attendee", - "whatsapp_number", - "cal_ai_phone_call" - ] - }, "recipient": { "type": "string", "description": "Intended recipient type", @@ -20796,6 +21339,12 @@ "type": "string", "description": "Verified Phone if action is SMS_NUMBER or WHATSAPP_NUMBER" }, + "phoneRequired": { + "type": "boolean", + "description": "whether or not the attendees are required to provide their phone numbers when booking", + "example": true, + "default": false + }, "template": { "type": "string", "description": "Template type used", @@ -20820,11 +21369,17 @@ "$ref": "#/components/schemas/WorkflowMessageOutputDto" } ] + }, + "action": { + "type": "string", + "description": "Action to perform", + "example": "email_host", + "enum": ["email_attendee", "email_address"] } }, - "required": ["id", "stepNumber", "action", "recipient", "template", "sender", "message"] + "required": ["id", "stepNumber", "recipient", "template", "sender", "message", "action"] }, - "WorkflowOutput": { + "RoutingFormWorkflowOutput": { "type": "object", "properties": { "id": { @@ -20847,11 +21402,28 @@ "description": "ID of the team owning the workflow", "example": 4214321 }, + "createdAt": { + "type": "object", + "description": "Timestamp of creation", + "example": "2024-05-12T10:00:00.000Z" + }, + "updatedAt": { + "type": "object", + "description": "Timestamp of last update", + "example": "2024-05-12T11:30:00.000Z" + }, + "type": { + "type": "string", + "enum": ["routing-form"], + "description": "type of the workflow", + "example": "routing-form", + "default": "routing-form" + }, "activation": { - "description": "Activation settings (scope)", + "description": "Activation settings for the workflow", "allOf": [ { - "$ref": "#/components/schemas/WorkflowActivationOutputDto" + "$ref": "#/components/schemas/RoutingFormWorkflowActivationOutputDto" } ] }, @@ -20859,7 +21431,7 @@ "description": "Trigger configuration", "allOf": [ { - "$ref": "#/components/schemas/WorkflowTriggerOutputDto" + "$ref": "#/components/schemas/RoutingFormWorkflowTriggerOutputDto" } ] }, @@ -20867,23 +21439,13 @@ "description": "Steps comprising the workflow", "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowStepOutputDto" + "$ref": "#/components/schemas/RoutingFormWorkflowStepOutputDto" } - }, - "createdAt": { - "type": "object", - "description": "Timestamp of creation", - "example": "2024-05-12T10:00:00.000Z" - }, - "updatedAt": { - "type": "object", - "description": "Timestamp of last update", - "example": "2024-05-12T11:30:00.000Z" } }, - "required": ["id", "name", "activation", "trigger", "steps"] + "required": ["id", "name", "type", "activation", "trigger", "steps"] }, - "GetWorkflowsOutput": { + "GetRoutingFormWorkflowsOutput": { "type": "object", "properties": { "status": { @@ -20896,13 +21458,32 @@ "description": "List of workflows", "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowOutput" + "$ref": "#/components/schemas/RoutingFormWorkflowOutput" + } + } + }, + "required": ["status", "data"] + }, + "GetEventTypeWorkflowOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Indicates the status of the response", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "description": "workflow", + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTypeWorkflowOutput" } } }, "required": ["status", "data"] }, - "GetWorkflowOutput": { + "GetRoutingFormWorkflowOutput": { "type": "object", "properties": { "status": { @@ -20915,7 +21496,7 @@ "description": "workflow", "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowOutput" + "$ref": "#/components/schemas/RoutingFormWorkflowOutput" } } }, @@ -21396,6 +21977,12 @@ "$ref": "#/components/schemas/TextWorkflowMessageDto" } ] + }, + "phoneRequired": { + "type": "boolean", + "default": false, + "description": "whether or not the attendees are required to provide their phone numbers when booking", + "example": true } }, "required": ["action", "stepNumber", "recipient", "template", "sender", "message"] @@ -21569,32 +22156,37 @@ "$ref": "#/components/schemas/TextWorkflowMessageDto" } ] + }, + "phoneRequired": { + "type": "boolean", + "default": false, + "description": "whether or not the attendees are required to provide their phone numbers when booking", + "example": true } }, "required": ["action", "stepNumber", "recipient", "template", "sender", "message"] }, - "BaseWorkflowTriggerDto": { + "EventTypeWorkflowTriggerDto": { "type": "object", "properties": { "type": { "type": "object", "enum": [ - [ - "beforeEvent", - "eventCancelled", - "newEvent", - "afterEvent", - "rescheduleEvent", - "afterHostsCalVideoNoShow", - "afterGuestsCalVideoNoShow", - "bookingRejected", - "bookingRequested", - "bookingPaymentInitiated", - "bookingPaid", - "bookingNoShowUpdated" - ] + "beforeEvent", + "eventCancelled", + "newEvent", + "afterEvent", + "rescheduleEvent", + "afterHostsCalVideoNoShow", + "afterGuestsCalVideoNoShow", + "bookingRejected", + "bookingRequested", + "bookingPaymentInitiated", + "bookingPaid", + "bookingNoShowUpdated" ], - "description": "Trigger type for the workflow" + "description": "Trigger type for the event-type workflow", + "example": "beforeEvent" } }, "required": ["type"] @@ -21620,7 +22212,7 @@ }, "required": ["isActiveOnAllEventTypes"] }, - "CreateWorkflowDto": { + "CreateEventTypeWorkflowDto": { "type": "object", "properties": { "name": { @@ -21637,7 +22229,7 @@ ] }, "trigger": { - "description": "Trigger configuration for the workflow", + "description": "Trigger configuration for the event-type workflow, allowed triggers are beforeEvent,eventCancelled,newEvent,afterEvent,rescheduleEvent,afterHostsCalVideoNoShow,afterGuestsCalVideoNoShow,bookingRejected,bookingRequested,bookingPaymentInitiated,bookingPaid,bookingNoShowUpdated", "oneOf": [ { "$ref": "#/components/schemas/OnBeforeEventTriggerDto" @@ -21679,7 +22271,7 @@ }, "steps": { "type": "array", - "description": "Steps to execute as part of the workflow", + "description": "Steps to execute as part of the event-type workflow, allowed steps are email_host,email_attendee,email_address,sms_attendee,sms_number,whatsapp_attendee,whatsapp_number,cal_ai_phone_call", "items": { "oneOf": [ { @@ -21709,6 +22301,96 @@ }, "required": ["name", "activation", "trigger", "steps"] }, + "OnFormSubmittedTriggerDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "default": "formSubmitted", + "enum": ["formSubmitted"], + "description": "Trigger type for the workflow", + "example": "formSubmitted" + } + }, + "required": ["type"] + }, + "RoutingFormWorkflowTriggerDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["formSubmitted"], + "description": "Trigger type for the routing-form workflow", + "example": "formSubmitted" + } + }, + "required": ["type"] + }, + "WorkflowFormActivationDto": { + "type": "object", + "properties": { + "isActiveOnAllRoutingForms": { + "type": "boolean", + "description": "Whether the workflow is active for all the routing forms", + "example": false + }, + "activeOnRoutingFormIds": { + "description": "List of routing form IDs the workflow applies to", + "example": ["abd1-123edf-a213d-123dfwf"], + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": ["isActiveOnAllRoutingForms"] + }, + "CreateFormWorkflowDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the workflow", + "example": "Platform Test Workflow" + }, + "activation": { + "description": "Activation settings for the workflow", + "allOf": [ + { + "$ref": "#/components/schemas/WorkflowFormActivationDto" + } + ] + }, + "trigger": { + "description": "Trigger configuration for the routing-form workflow, allowed triggers are formSubmitted", + "oneOf": [ + { + "$ref": "#/components/schemas/OnFormSubmittedTriggerDto" + } + ], + "allOf": [ + { + "$ref": "#/components/schemas/OnFormSubmittedTriggerDto" + } + ] + }, + "steps": { + "type": "array", + "description": "Steps to execute as part of the routing-form workflow, allowed steps are email_attendee,email_address", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/WorkflowEmailAddressStepDto" + }, + { + "$ref": "#/components/schemas/WorkflowEmailAttendeeStepDto" + } + ] + } + } + }, + "required": ["name", "activation", "trigger", "steps"] + }, "UpdateEmailAddressWorkflowStepDto": { "type": "object", "properties": { @@ -21980,6 +22662,12 @@ } ] }, + "phoneRequired": { + "type": "boolean", + "default": false, + "description": "whether or not the attendees are required to provide their phone numbers when booking", + "example": true + }, "id": { "type": "number", "description": "Unique identifier of the step you want to update, if adding a new step do not provide this id", @@ -22100,6 +22788,12 @@ } ] }, + "phoneRequired": { + "type": "boolean", + "default": false, + "description": "whether or not the attendees are required to provide their phone numbers when booking", + "example": true + }, "id": { "type": "number", "description": "Unique identifier of the step you want to update, if adding a new step do not provide this id", @@ -22172,7 +22866,7 @@ }, "required": ["action", "stepNumber", "recipient", "template", "sender", "verifiedPhoneId", "message"] }, - "UpdateWorkflowDto": { + "UpdateEventTypeWorkflowDto": { "type": "object", "properties": { "name": { @@ -22181,7 +22875,7 @@ "example": "Platform Test Workflow" }, "activation": { - "description": "Activation settings for the workflow, the action that will trigger the workflow.", + "description": "Activation settings for the workflow", "allOf": [ { "$ref": "#/components/schemas/WorkflowActivationDto" @@ -22189,7 +22883,7 @@ ] }, "trigger": { - "description": "Trigger configuration for the workflow", + "description": "Trigger configuration for the event-type workflow, allowed triggers are beforeEvent,eventCancelled,newEvent,afterEvent,rescheduleEvent,afterHostsCalVideoNoShow,afterGuestsCalVideoNoShow,bookingRejected,bookingRequested,bookingPaymentInitiated,bookingPaid,bookingNoShowUpdated", "oneOf": [ { "$ref": "#/components/schemas/OnBeforeEventTriggerDto" @@ -22231,7 +22925,7 @@ }, "steps": { "type": "array", - "description": "Steps to execute as part of the workflow", + "description": "Steps to execute as part of the event-type workflow, allowed steps are email_host,email_attendee,email_address,sms_attendee,sms_number,whatsapp_attendee,whatsapp_number,cal_ai_phone_call", "items": { "oneOf": [ { @@ -22260,6 +22954,46 @@ } } }, + "UpdateFormWorkflowDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the workflow", + "example": "Rounting-form Test Workflow" + }, + "trigger": { + "description": "Trigger configuration for the routing-form workflow, allowed triggers are formSubmitted", + "oneOf": [ + { + "$ref": "#/components/schemas/OnFormSubmittedTriggerDto" + } + ], + "allOf": [ + { + "$ref": "#/components/schemas/OnFormSubmittedTriggerDto" + } + ] + }, + "steps": { + "type": "array", + "description": "Steps to execute as part of the routing-form workflow, allowed steps are email_attendee,email_address", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateEmailAddressWorkflowStepDto" + }, + { + "$ref": "#/components/schemas/UpdateEmailAttendeeWorkflowStepDto" + } + ] + } + }, + "activation": { + "$ref": "#/components/schemas/WorkflowFormActivationDto" + } + } + }, "CreatePrivateLinkInput": { "type": "object", "properties": { diff --git a/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts b/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts index abaa533c1361b3..8ff6469e320b8c 100644 --- a/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts +++ b/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts @@ -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" }; + describe("Workflows", () => { + it("should call WorkflowService.scheduleFormWorkflows for FORM_SUBMITTED workflows", async () => { + const mockWorkflows = [ + { + 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, + }, + ], + }, + ]; - vi.mocked(getWebhooks).mockImplementation(async (options) => { - if (options.triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT) { - return [mockWebhook as any]; - } - return []; - }); + vi.mocked(WorkflowService.getAllWorkflowsFromRoutingForm).mockResolvedValueOnce(mockWorkflows as any); - await _onFormSubmission(mockForm as any, mockResponse, responseId, chosenAction); + await _onFormSubmission(mockForm as any, mockResponse, responseId); - expect(getWebhooks).toHaveBeenCalledWith( - expect.objectContaining({ - triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT, - }) - ); - expect(tasker.create).toHaveBeenCalledWith( - "triggerFormSubmittedNoEventWebhook", - { - responseId, - form: { - id: mockForm.id, - name: mockForm.name, - teamId: mockForm.teamId, + expect(WorkflowService.getAllWorkflowsFromRoutingForm).toHaveBeenCalledWith(mockForm); + expect(WorkflowService.scheduleFormWorkflows).toHaveBeenCalledWith({ + workflows: mockWorkflows, + responses: { + email: { + value: "test@response.com", + response: "test@response.com", }, - responses: { - email: { - value: "test@response.com", - response: "test@response.com", - }, - name: { value: "Test Name", response: "Test Name" }, - }, - redirect: chosenAction, - webhook: mockWebhook, + name: { value: "Test Name", response: "Test Name" }, + }, + form: { + ...mockForm, + fields: mockForm.fields.map((field) => ({ + type: field.type, + identifier: field.identifier, + })), }, - { scheduledAt: expect.any(Date) } - ); + }); }); }); @@ -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); diff --git a/packages/app-store/routing-forms/lib/formSubmissionUtils.ts b/packages/app-store/routing-forms/lib/formSubmissionUtils.ts index 7c8948a4edce8b..4147942d0fe19e 100644 --- a/packages/app-store/routing-forms/lib/formSubmissionUtils.ts +++ b/packages/app-store/routing-forms/lib/formSubmissionUtils.ts @@ -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 & { user: Pick; userWithEmails?: string[] }, + SerializableForm & { + user: Pick; + 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; diff --git a/packages/app-store/routing-forms/lib/handleResponse.test.ts b/packages/app-store/routing-forms/lib/handleResponse.test.ts index f3b329b3c7f6d8..213b7fedd257a2 100644 --- a/packages/app-store/routing-forms/lib/handleResponse.test.ts +++ b/packages/app-store/routing-forms/lib/handleResponse.test.ts @@ -84,6 +84,8 @@ const mockForm: TargetRoutingFormForResponse = { user: { id: 1, email: "test@example.com", + timeFormat: null, + locale: null, }, team: { parentId: 2, diff --git a/packages/features/bookings/lib/handleNewBooking/test/buildDryRunBooking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/buildDryRunBooking.test.ts index cd81340acf332d..51a271c5044183 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/buildDryRunBooking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/buildDryRunBooking.test.ts @@ -6,6 +6,7 @@ import { buildDryRunBooking } from "../../handleNewBooking"; vi.mock("@calcom/prisma", () => ({ default: {}, // empty object as default export + prisma: {}, })); describe("buildDryRunBooking", () => { diff --git a/packages/features/bookings/lib/handleNewBooking/test/buildEventForTeamEventType.test.ts b/packages/features/bookings/lib/handleNewBooking/test/buildEventForTeamEventType.test.ts index df5f504163adc1..77ef9de0b57326 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/buildEventForTeamEventType.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/buildEventForTeamEventType.test.ts @@ -12,6 +12,7 @@ vi.mock("@calcom/lib/server/i18n", () => ({ vi.mock("@calcom/prisma", () => { return { default: vi.fn(), + prisma: {}, }; }); diff --git a/packages/features/ee/workflows/components/AddActionDialog.tsx b/packages/features/ee/workflows/components/AddActionDialog.tsx index 7f8a262f3779b7..9645777b87c786 100644 --- a/packages/features/ee/workflows/components/AddActionDialog.tsx +++ b/packages/features/ee/workflows/components/AddActionDialog.tsx @@ -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} /> ); }} diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx index 079dc41a421ed9..f2662b9cee46ba 100644 --- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx @@ -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} /> @@ -205,6 +268,9 @@ export default function WorkflowDetailsPage(props: Props) { inboundAgentData={inboundAgentData} isInboundAgentLoading={isInboundAgentLoading} allOptions={allOptions} + actionOptions={transformedActionOptions} + updateTemplate={updateTemplate} + setUpdateTemplate={setUpdateTemplate} /> @@ -234,6 +300,7 @@ export default function WorkflowDetailsPage(props: Props) { isOpenDialog={isAddActionDialogOpen} setIsOpenDialog={setIsAddActionDialogOpen} addAction={addAction} + actionOptions={transformedActionOptions} /> ); diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index f90a624164c3ad..ee47cc644b3fd0 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -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>; 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 ( -
-
+
+
- -
- - - + +
+ + +
-
- - +
+ +
@@ -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`); - - // 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 templateOptions = getWorkflowTemplateOptions(t, step?.action, hasActiveTeamPlan, trigger); - 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 steps = useWatch({ + control: form.control, + name: "steps", + }); + + 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 ( <>
- + { 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) {
{!!timeSectionText && ( -
- +
+
)} @@ -577,14 +601,16 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { {selectedOptions && setSelectedOptions && allOptions && (
{isOrganization ? ( -
+
) : ( - + )} { 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) && (
( { @@ -630,7 +668,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
)} {!!timeSectionText && ( -
+

{t("testing_sms_workflow_info_message")}

@@ -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) { />
-
{t("sender_id_info")}
+
{t("sender_id_info")}
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sender && ( -

{t("sender_id_error_message")}

+

{t("sender_id_error_message")}

)} ) : ( @@ -812,23 +839,23 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
)} {isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && !stepAgentId && ( -
-
+
+
-

+

{t("cal_ai_agent")} - + {t("set_up_required")}

-

+

{t("no_phone_number_connected")}.

{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && ( -

+

{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}

)} @@ -1149,9 +1176,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { ) : ( !props.readOnly && ( <> -
+
-
+
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && ( -

+

{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}

)} @@ -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) {
)} {!isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && ( -
+
{isEmailSubjectNeeded && (
-