From b0215ce4932feee94facf4cbe2cce0507e4e6dc0 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Wed, 8 Oct 2025 21:12:42 +0530 Subject: [PATCH 01/18] fix: cal ai email --- .../retell-ai/__tests__/route.test.ts | 372 ++++++++++++++++++ apps/web/app/api/webhooks/retell-ai/route.ts | 15 +- apps/web/public/static/locales/en/common.json | 4 + packages/emails/email-manager.ts | 18 +- .../CreditBalanceLimitReachedEmail.tsx | 17 +- .../CreditBalanceLowWarningEmail.tsx | 17 +- .../credit-balance-limit-reached-email.ts | 6 + .../credit-balance-low-warning-email.ts | 6 + .../features/ee/billing/credit-service.ts | 34 +- .../tasker/tasks/executeAIPhoneCall.ts | 78 +++- .../repository/PrismaAgentRepository.ts | 6 + .../repository/PrismaPhoneNumberRepository.ts | 2 +- packages/lib/server/repository/team.ts | 27 ++ 13 files changed, 570 insertions(+), 32 deletions(-) diff --git a/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts index 68d32740a26846..6ba9e55da58d5d 100644 --- a/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository"; import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository"; import type { CalAiPhoneNumber, User, Team, Agent } from "@calcom/prisma/client"; +import { CreditUsageType } from "@calcom/prisma/enums"; import { POST } from "../route"; @@ -73,6 +74,8 @@ vi.mock("retell-sdk", () => ({ const mockHasAvailableCredits = vi.fn(); const mockChargeCredits = vi.fn(); +const mockSendCreditBalanceLimitReachedEmails = vi.fn(); +const mockSendCreditBalanceLowWarningEmails = vi.fn(); vi.mock("@calcom/features/ee/billing/credit-service", () => ({ CreditService: vi.fn().mockImplementation(() => ({ @@ -81,6 +84,12 @@ vi.mock("@calcom/features/ee/billing/credit-service", () => ({ })), })); +vi.mock("@calcom/emails/email-manager", () => ({ + sendCreditBalanceLimitReachedEmails: (...args: unknown[]) => + mockSendCreditBalanceLimitReachedEmails(...args), + sendCreditBalanceLowWarningEmails: (...args: unknown[]) => mockSendCreditBalanceLowWarningEmails(...args), +})); + vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({ PrismaPhoneNumberRepository: { findByPhoneNumber: vi.fn(), @@ -866,4 +875,367 @@ describe("Retell AI Webhook Handler", () => { expect(mockChargeCredits).not.toHaveBeenCalled(); }); }); + + describe("Cal AI Credit Type", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(Retell.verify).mockReturnValue(true); + }); + + it("should pass CAL_AI_PHONE_CALL creditFor when charging credits for web call", async () => { + const mockAgent: Pick< + Agent, + "id" | "name" | "providerAgentId" | "enabled" | "userId" | "teamId" | "createdAt" | "updatedAt" + > = { + id: "agent-123", + name: "Test Agent", + providerAgentId: "agent_cal_ai_test", + enabled: true, + userId: 1, + teamId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent); + mockChargeCredits.mockResolvedValue(undefined); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "web-call-credit-type", + call_type: "web_call", + agent_id: "agent_cal_ai_test", + call_status: "ended", + start_timestamp: 1757673314024, + call_cost: { + total_duration_seconds: 120, + combined_cost: 2.0, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + expect(mockChargeCredits).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 1, + credits: 58, + callDuration: 120, + creditFor: CreditUsageType.CAL_AI_PHONE_CALL, + externalRef: "retell:web-call-credit-type", + }) + ); + }); + + it("should pass CAL_AI_PHONE_CALL creditFor when charging credits for phone call", async () => { + const mockPhoneNumber: MockPhoneNumberWithUser = { + id: 1, + phoneNumber: "+1234567890", + userId: 1, + teamId: null, + provider: "test-provider", + providerPhoneNumberId: "test-provider-id", + createdAt: new Date(), + updatedAt: new Date(), + stripeCustomerId: null, + stripeSubscriptionId: null, + subscriptionStatus: null, + inboundAgentId: null, + outboundAgentId: null, + user: { id: 1, email: "test@example.com", name: "Test User" }, + team: null, + }; + + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + mockChargeCredits.mockResolvedValue(undefined); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "phone-call-credit-type", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 100, + total_duration_seconds: 180, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + expect(mockChargeCredits).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 1, + credits: 87, + callDuration: 180, + creditFor: CreditUsageType.CAL_AI_PHONE_CALL, + externalRef: "retell:phone-call-credit-type", + }) + ); + }); + + it("should pass CAL_AI_PHONE_CALL creditFor for team-based calls", async () => { + const mockPhoneNumber: MockPhoneNumberWithTeam = { + id: 2, + phoneNumber: "+1234567890", + userId: null, + teamId: 5, + provider: "test-provider", + providerPhoneNumberId: "test-provider-id-2", + createdAt: new Date(), + updatedAt: new Date(), + stripeCustomerId: null, + stripeSubscriptionId: null, + subscriptionStatus: null, + inboundAgentId: null, + outboundAgentId: null, + team: { id: 5, name: "Test Team" }, + user: null, + }; + + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + mockChargeCredits.mockResolvedValue(undefined); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "team-call-credit-type", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 50, + total_duration_seconds: 60, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + expect(mockChargeCredits).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: 5, + credits: 29, + callDuration: 60, + creditFor: CreditUsageType.CAL_AI_PHONE_CALL, + externalRef: "retell:team-call-credit-type", + }) + ); + }); + }); + + describe("Cal AI Credit Email Integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(Retell.verify).mockReturnValue(true); + mockSendCreditBalanceLimitReachedEmails.mockResolvedValue(undefined); + mockSendCreditBalanceLowWarningEmails.mockResolvedValue(undefined); + }); + + it("should trigger email with CAL_AI_PHONE_CALL creditFor when credits run out", async () => { + const mockPhoneNumber: MockPhoneNumberWithUser = { + id: 1, + phoneNumber: "+1234567890", + userId: 1, + teamId: null, + provider: "test-provider", + providerPhoneNumberId: "test-provider-id", + createdAt: new Date(), + updatedAt: new Date(), + stripeCustomerId: null, + stripeSubscriptionId: null, + subscriptionStatus: null, + inboundAgentId: null, + outboundAgentId: null, + user: { id: 1, email: "test@example.com", name: "Test User" }, + team: null, + }; + + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + + mockChargeCredits.mockImplementation(async (params) => { + if (params.creditFor === CreditUsageType.CAL_AI_PHONE_CALL) { + await mockSendCreditBalanceLimitReachedEmails({ + user: { + id: params.userId, + name: "Test User", + email: "test@example.com", + t: (key: string) => key, + }, + creditFor: params.creditFor, + }); + } + return { userId: params.userId }; + }); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "call-trigger-email", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 100, + total_duration_seconds: 120, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + + expect(mockChargeCredits).toHaveBeenCalledWith( + expect.objectContaining({ + creditFor: CreditUsageType.CAL_AI_PHONE_CALL, + }) + ); + + expect(mockSendCreditBalanceLimitReachedEmails).toHaveBeenCalledWith( + expect.objectContaining({ + creditFor: CreditUsageType.CAL_AI_PHONE_CALL, + }) + ); + }); + + it("should trigger warning email with CAL_AI_PHONE_CALL creditFor when credits are low", async () => { + const mockAgent: Pick< + Agent, + "id" | "name" | "providerAgentId" | "enabled" | "userId" | "teamId" | "createdAt" | "updatedAt" + > = { + id: "agent-123", + name: "Test Agent", + providerAgentId: "agent_warning_test", + enabled: true, + userId: 1, + teamId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent); + + mockChargeCredits.mockImplementation(async (params) => { + if (params.creditFor === CreditUsageType.CAL_AI_PHONE_CALL) { + await mockSendCreditBalanceLowWarningEmails({ + user: { + id: params.userId, + name: "Test User", + email: "test@example.com", + t: (key: string) => key, + }, + balance: 100, + creditFor: params.creditFor, + }); + } + return { userId: params.userId }; + }); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "call-trigger-warning", + call_type: "web_call", + agent_id: "agent_warning_test", + call_status: "ended", + start_timestamp: 1757673314024, + call_cost: { + total_duration_seconds: 60, + combined_cost: 1.0, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + + expect(mockChargeCredits).toHaveBeenCalledWith( + expect.objectContaining({ + creditFor: CreditUsageType.CAL_AI_PHONE_CALL, + }) + ); + + expect(mockSendCreditBalanceLowWarningEmails).toHaveBeenCalledWith( + expect.objectContaining({ + creditFor: CreditUsageType.CAL_AI_PHONE_CALL, + balance: 100, + }) + ); + }); + + it("should not pass SMS creditFor for Cal AI calls", async () => { + const mockPhoneNumber: MockPhoneNumberWithTeam = { + id: 2, + phoneNumber: "+1234567890", + userId: null, + teamId: 5, + provider: "test-provider", + providerPhoneNumberId: "test-provider-id-2", + createdAt: new Date(), + updatedAt: new Date(), + stripeCustomerId: null, + stripeSubscriptionId: null, + subscriptionStatus: null, + inboundAgentId: null, + outboundAgentId: null, + team: { id: 5, name: "Test Team" }, + user: null, + }; + + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + mockChargeCredits.mockResolvedValue({ teamId: 5 }); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "call-not-sms", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 50, + total_duration_seconds: 30, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + + expect(mockChargeCredits).toHaveBeenCalledWith( + expect.objectContaining({ + creditFor: CreditUsageType.CAL_AI_PHONE_CALL, + }) + ); + + expect(mockChargeCredits).not.toHaveBeenCalledWith( + expect.objectContaining({ + creditFor: CreditUsageType.SMS, + }) + ); + }); + }); }); diff --git a/apps/web/app/api/webhooks/retell-ai/route.ts b/apps/web/app/api/webhooks/retell-ai/route.ts index 3cb4572a724d98..35b371f43e648a 100644 --- a/apps/web/app/api/webhooks/retell-ai/route.ts +++ b/apps/web/app/api/webhooks/retell-ai/route.ts @@ -62,6 +62,8 @@ const RetellWebhookSchema = z.object({ .passthrough(), }); +type RetellCallData = z.infer["call"]; + async function chargeCreditsForCall({ userId, teamId, @@ -119,7 +121,7 @@ async function chargeCreditsForCall({ } } -async function handleCallAnalyzed(callData: any) { +async function handleCallAnalyzed(callData: RetellCallData) { const { from_number, call_id, call_cost, call_type, agent_id } = callData; if ( @@ -133,7 +135,7 @@ async function handleCallAnalyzed(callData: any) { ); return { success: true, - message: `Invalid or missing call_cost.total_duration_seconds for call ${call_id}` + message: `Invalid or missing call_cost.total_duration_seconds for call ${call_id}`, }; } @@ -146,7 +148,7 @@ async function handleCallAnalyzed(callData: any) { log.error(`Web call ${call_id} missing agent_id, cannot charge credits`); return { success: false, - message: `Web call ${call_id} missing agent_id, cannot charge credits` + message: `Web call ${call_id} missing agent_id, cannot charge credits`, }; } @@ -158,13 +160,12 @@ async function handleCallAnalyzed(callData: any) { log.error(`No agent found for providerAgentId ${agent_id}, call ${call_id}`); return { success: false, - message: `No agent found for providerAgentId ${agent_id}, call ${call_id}` + message: `No agent found for providerAgentId ${agent_id}, call ${call_id}`, }; } - userId = agent.userId ?? undefined; - teamId = agent.teamId ?? undefined; + teamId = agent.team?.parentId ?? agent.teamId ?? undefined; log.info(`Processing web call ${call_id} for agent ${agent_id}, user ${userId}, team ${teamId}`); } else { @@ -179,7 +180,7 @@ async function handleCallAnalyzed(callData: any) { } userId = phoneNumber.userId ?? undefined; - teamId = phoneNumber.teamId ?? undefined; + teamId = phoneNumber.team?.parentId ?? phoneNumber.teamId ?? undefined; log.info(`Processing phone call ${call_id} from ${from_number}, user ${userId}, team ${teamId}`); } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e10c43eff99b58..ea48177a9971d3 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3435,6 +3435,10 @@ "low_credits_warning_message_user": "Your Cal.com account is running low on credits. To avoid any disruption in service, please purchase additional credits. If your balance runs out, SMS messages will stop sending and will be sent as emails instead.", "credit_limit_reached_message": "Your Cal.com team {{teamName}} has run out of credits. As a result, SMS messages are now being sent via email instead. To resume sending SMS, please purchase additional credits.", "credit_limit_reached_message_user": "Your Cal.com account has run out of credits. As a result, SMS messages are now being sent via email instead. To resume sending SMS, please purchase additional credits.", + "cal_ai_low_credits_warning_message": "Your Cal.com team {{teamName}} is running low on credits. To avoid any disruption in Cal AI phone service, please purchase additional credits. If your balance runs out, Cal AI phone calls will be disabled.", + "cal_ai_low_credits_warning_message_user": "Your Cal.com account is running low on credits. To avoid any disruption in Cal AI phone service, please purchase additional credits. If your balance runs out, Cal AI phone calls will be disabled.", + "cal_ai_credit_limit_reached_message": "Your Cal.com team {{teamName}} has run out of credits. As a result, Cal AI phone calls are now disabled. To resume using Cal AI phone calls, please purchase additional credits.", + "cal_ai_credit_limit_reached_message_user": "Your Cal.com account has run out of credits. As a result, Cal AI phone calls are now disabled. To resume using Cal AI phone calls, please purchase additional credits.", "current_credit_balance": "Current balance: {{balance}} credits", "current_balance": "Current balance:", "notification_about_your_booking": "Notification about your booking", diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 74463692c4efac..7e6df457ce0204 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -10,6 +10,7 @@ import { formatCalEvent } from "@calcom/lib/formatCalendarEvent"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { withReporting } from "@calcom/lib/sentryWrapper"; +import type { CreditUsageType } from "@calcom/prisma/enums"; import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; @@ -810,28 +811,32 @@ export const sendCreditBalanceLowWarningEmails = async (input: { t: TFunction; }; balance: number; + creditFor?: CreditUsageType; }) => { - const { team, balance, user } = input; + const { team, balance, user, creditFor } = input; if ((!team || !team.adminAndOwners.length) && !user) return; if (team) { const emailsToSend: Promise[] = []; for (const admin of team.adminAndOwners) { - emailsToSend.push(sendEmail(() => new CreditBalanceLowWarningEmail({ user: admin, balance, team }))); + emailsToSend.push( + sendEmail(() => new CreditBalanceLowWarningEmail({ user: admin, balance, team, creditFor })) + ); } await Promise.all(emailsToSend); } if (user) { - await sendEmail(() => new CreditBalanceLowWarningEmail({ user, balance })); + await sendEmail(() => new CreditBalanceLowWarningEmail({ user, balance, creditFor })); } }; export const sendCreditBalanceLimitReachedEmails = async ({ team, user, + creditFor, }: { team?: { name: string; @@ -849,6 +854,7 @@ export const sendCreditBalanceLimitReachedEmails = async ({ email: string; t: TFunction; }; + creditFor?: CreditUsageType; }) => { if ((!team || !team.adminAndOwners.length) && !user) return; @@ -856,13 +862,15 @@ export const sendCreditBalanceLimitReachedEmails = async ({ const emailsToSend: Promise[] = []; for (const admin of team.adminAndOwners) { - emailsToSend.push(sendEmail(() => new CreditBalanceLimitReachedEmail({ user: admin, team }))); + emailsToSend.push( + sendEmail(() => new CreditBalanceLimitReachedEmail({ user: admin, team, creditFor })) + ); } await Promise.all(emailsToSend); } if (user) { - await sendEmail(() => new CreditBalanceLimitReachedEmail({ user })); + await sendEmail(() => new CreditBalanceLimitReachedEmail({ user, creditFor })); } }; diff --git a/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx b/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx index 23353747987c89..dbd43d74b6fe05 100644 --- a/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx +++ b/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx @@ -1,6 +1,7 @@ import type { TFunction } from "i18next"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { CreditUsageType } from "@calcom/prisma/enums"; import { CallToAction, V2BaseEmailHtml } from "../components"; import type { BaseScheduledEmail } from "./BaseScheduledEmail"; @@ -17,9 +18,11 @@ export const CreditBalanceLimitReachedEmail = ( email: string; t: TFunction; }; + creditFor?: CreditUsageType; } & Partial> ) => { - const { team, user } = props; + const { team, user, creditFor } = props; + const isCalAi = creditFor === CreditUsageType.CAL_AI_PHONE_CALL; if (team) { return ( @@ -28,7 +31,11 @@ export const CreditBalanceLimitReachedEmail = ( <> {user.t("hi_user_name", { name: user.name })},

- <>{user.t("credit_limit_reached_message", { teamName: team.name })} + <> + {isCalAi + ? user.t("cal_ai_credit_limit_reached_message", { teamName: team.name }) + : user.t("credit_limit_reached_message", { teamName: team.name })} +

{user.t("hi_user_name", { name: user.name })},

- <>{user.t("credit_limit_reached_message_user")} + <> + {isCalAi + ? user.t("cal_ai_credit_limit_reached_message_user") + : user.t("credit_limit_reached_message_user")} +

> ) => { - const { team, balance, user } = props; + const { team, balance, user, creditFor } = props; + const isCalAi = creditFor === CreditUsageType.CAL_AI_PHONE_CALL; if (team) { return ( @@ -29,7 +32,11 @@ export const CreditBalanceLowWarningEmail = ( <> {user.t("hi_user_name", { name: user.name })},

- <>{user.t("low_credits_warning_message", { teamName: team.name })} + <> + {isCalAi + ? user.t("cal_ai_low_credits_warning_message", { teamName: team.name }) + : user.t("low_credits_warning_message", { teamName: team.name })} +

{user.t("hi_user_name", { name: user.name })},

- <>{user.t("low_credits_warning_message_user")} + <> + {isCalAi + ? user.t("cal_ai_low_credits_warning_message_user") + : user.t("low_credits_warning_message_user")} +

> { @@ -39,6 +44,7 @@ export default class CreditBalanceLimitReachedEmail extends BaseEmail { html: await renderEmail("CreditBalanceLimitReachedEmail", { team: this.team, user: this.user, + creditFor: this.creditFor, }), text: this.getTextBody(), }; diff --git a/packages/emails/templates/credit-balance-low-warning-email.ts b/packages/emails/templates/credit-balance-low-warning-email.ts index bc9c9f34357e47..02b66b1a535ab6 100644 --- a/packages/emails/templates/credit-balance-low-warning-email.ts +++ b/packages/emails/templates/credit-balance-low-warning-email.ts @@ -1,6 +1,7 @@ import type { TFunction } from "i18next"; import { EMAIL_FROM_NAME } from "@calcom/lib/constants"; +import type { CreditUsageType } from "@calcom/prisma/enums"; import { renderEmail } from ".."; import BaseEmail from "./_base-email"; @@ -17,20 +18,24 @@ export default class CreditBalanceLowWarningEmail extends BaseEmail { name: string; }; balance: number; + creditFor?: CreditUsageType; constructor({ user, balance, team, + creditFor, }: { user: { id: number; name: string | null; email: string; t: TFunction }; balance: number; team?: { id: number; name: string | null }; + creditFor?: CreditUsageType; }) { super(); this.user = { ...user, name: user.name || "" }; this.team = team ? { ...team, name: team.name || "" } : undefined; this.balance = balance; + this.creditFor = creditFor; } protected async getNodeMailerPayload(): Promise> { @@ -44,6 +49,7 @@ export default class CreditBalanceLowWarningEmail extends BaseEmail { balance: this.balance, team: this.team, user: this.user, + creditFor: this.creditFor, }), text: this.getTextBody(), }; diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index 3f80f16d816fac..56245987fcc833 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -15,8 +15,7 @@ import { CreditsRepository } from "@calcom/lib/server/repository/credits"; import { MembershipRepository } from "@calcom/lib/server/repository/membership"; import { TeamRepository } from "@calcom/lib/server/repository/team"; import prisma, { type PrismaTransaction } from "@calcom/prisma"; -import type { CreditUsageType } from "@calcom/prisma/enums"; -import { CreditType } from "@calcom/prisma/enums"; +import { CreditUsageType, CreditType } from "@calcom/prisma/enums"; const log = logger.getSubLogger({ prefix: ["[CreditService]"] }); @@ -37,6 +36,7 @@ type LowCreditBalanceResultBase = { email: string; t: TFunction; }; + creditFor?: CreditUsageType; }; type LowCreditBalanceLimitReachedResult = LowCreditBalanceResultBase & { @@ -136,6 +136,7 @@ export class CreditService { teamId: teamIdToCharge, userId: userIdToCharge, remainingCredits: remainingCredits ?? 0, + creditFor, tx, }); } @@ -413,11 +414,13 @@ export class CreditService { teamId, userId, remainingCredits, + creditFor, tx, }: { teamId?: number | null; userId?: number | null; remainingCredits: number; + creditFor?: CreditUsageType; tx: PrismaTransaction; }): Promise { let warningLimit = 0; @@ -486,6 +489,7 @@ export class CreditService { user, teamId, userId, + creditFor, }; } @@ -512,6 +516,7 @@ export class CreditService { balance: remainingCredits, team: teamWithAdmins, user, + creditFor, }; } @@ -533,26 +538,37 @@ export class CreditService { private async _handleLowCreditBalanceResult(result: LowCreditBalanceResult) { if (!result) return; + console.log("handleLowCreditBalanceResult", result); + try { if (result.type === "LIMIT_REACHED") { - await Promise.all([ + const promises: Promise[] = [ sendCreditBalanceLimitReachedEmails({ team: result.team, user: result.user, + creditFor: result.creditFor, }).catch((error) => { log.error("Failed to send credit limit reached email", error, { result }); }), - cancelScheduledMessagesAndScheduleEmails({ teamId: result.teamId, userId: result.userId }).catch( - (error) => { - log.error("Failed to cancel scheduled messages", error, { result }); - } - ), - ]); + ]; + + if (result.creditFor === CreditUsageType.SMS) { + promises.push( + cancelScheduledMessagesAndScheduleEmails({ teamId: result.teamId, userId: result.userId }).catch( + (error) => { + log.error("Failed to cancel scheduled messages", error, { result }); + } + ) + ); + } + + await Promise.all(promises); } else if (result.type === "WARNING") { await sendCreditBalanceLowWarningEmails({ balance: result.balance, team: result.team, user: result.user, + creditFor: result.creditFor, }).catch((error) => { log.error("Failed to send credit warning email", error, { result }); }); diff --git a/packages/features/tasker/tasks/executeAIPhoneCall.ts b/packages/features/tasker/tasks/executeAIPhoneCall.ts index 308442ab175700..4702afe7643a3c 100644 --- a/packages/features/tasker/tasks/executeAIPhoneCall.ts +++ b/packages/features/tasker/tasks/executeAIPhoneCall.ts @@ -1,10 +1,17 @@ +import type { TFunction } from "i18next"; + import dayjs from "@calcom/dayjs"; +import { sendCreditBalanceLimitReachedEmails } from "@calcom/emails/email-manager"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import logger from "@calcom/lib/logger"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { TeamRepository } from "@calcom/lib/server/repository/team"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import prisma from "@calcom/prisma"; +import { CreditUsageType } from "@calcom/prisma/enums"; interface ExecuteAIPhoneCallPayload { workflowReminderId: number; @@ -107,15 +114,78 @@ export async function executeAIPhoneCall(payload: string) { }); if (!hasCredits) { - log.warn(`Insufficient credits for AI phone call`, { + log.warn(`Insufficient credits for AI phone call for workflow reminder ${data.workflowReminderId}`, { userId: data.userId, teamId: data.teamId, workflowReminderId: data.workflowReminderId, bookingUid: workflowReminder.booking?.uid, }); - throw new Error( - `Insufficient credits to make AI phone call. Please purchase more credits. user: ${data?.userId}, team: ${data?.teamId}` - ); + + try { + let teamWithAdmins: + | { + id: number; + name: string; + adminAndOwners: { id: number; name: string | null; email: string; t: TFunction }[]; + } + | undefined; + let user: + | { + id: number; + name: string | null; + email: string; + t: TFunction; + } + | undefined; + + if (data.teamId) { + const teamRepository = new TeamRepository(prisma); + const team = await teamRepository.findTeamWithAdminMembers({ teamId: data.teamId }); + + if (team) { + teamWithAdmins = { + id: team.id, + name: team.name ?? "", + adminAndOwners: await Promise.all( + team.members.map(async (member) => ({ + id: member.user.id, + name: member.user.name, + email: member.user.email, + t: await getTranslation(member.user.locale ?? "en", "common"), + })) + ), + }; + } + } else if (data.userId) { + const userRepository = new UserRepository(prisma); + const userRecord = await userRepository.findById({ id: data.userId }); + + if (userRecord) { + user = { + id: userRecord.id, + name: userRecord.name, + email: userRecord.email, + t: await getTranslation(userRecord.locale ?? "en", "common"), + }; + } + } + + if (teamWithAdmins || user) { + await sendCreditBalanceLimitReachedEmails({ + team: teamWithAdmins, + user, + creditFor: CreditUsageType.CAL_AI_PHONE_CALL, + }); + log.info("Credit limit reached email sent for AI phone call", { + userId: data.userId, + teamId: data.teamId, + }); + } + } catch (emailError) { + log.error("Failed to send credit limit email for AI phone call", emailError); + } + + return; } } diff --git a/packages/lib/server/repository/PrismaAgentRepository.ts b/packages/lib/server/repository/PrismaAgentRepository.ts index 21bec58da8540c..6da2d814eb9733 100644 --- a/packages/lib/server/repository/PrismaAgentRepository.ts +++ b/packages/lib/server/repository/PrismaAgentRepository.ts @@ -177,6 +177,12 @@ export class PrismaAgentRepository { teamId: true, createdAt: true, updatedAt: true, + team: { + select: { + id: true, + parentId: true, + }, + }, }, where: { providerAgentId, diff --git a/packages/lib/server/repository/PrismaPhoneNumberRepository.ts b/packages/lib/server/repository/PrismaPhoneNumberRepository.ts index 0d7e1af1c09423..00a4e73659811b 100644 --- a/packages/lib/server/repository/PrismaPhoneNumberRepository.ts +++ b/packages/lib/server/repository/PrismaPhoneNumberRepository.ts @@ -528,7 +528,7 @@ export class PrismaPhoneNumberRepository { userId: true, teamId: true, user: { select: { id: true, email: true, name: true } }, - team: { select: { id: true, name: true } }, + team: { select: { id: true, name: true, parentId: true } }, }, }); } diff --git a/packages/lib/server/repository/team.ts b/packages/lib/server/repository/team.ts index a522773559bb7e..0b794f47f1a72d 100644 --- a/packages/lib/server/repository/team.ts +++ b/packages/lib/server/repository/team.ts @@ -437,4 +437,31 @@ export class TeamRepository { return !conflictingTeam; } + + async findTeamWithAdminMembers({ teamId }: { teamId: number }) { + return await this.prismaClient.team.findUnique({ + where: { id: teamId }, + select: { + id: true, + name: true, + members: { + where: { + role: { + in: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + locale: true, + }, + }, + }, + }, + }, + }); + } } From 95659ca6eee97a3e252663c8afaf572e43670373 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 9 Oct 2025 13:33:16 +0530 Subject: [PATCH 02/18] fix: remove --- packages/features/ee/billing/credit-service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index 56245987fcc833..c9086ec8020719 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -538,8 +538,6 @@ export class CreditService { private async _handleLowCreditBalanceResult(result: LowCreditBalanceResult) { if (!result) return; - console.log("handleLowCreditBalanceResult", result); - try { if (result.type === "LIMIT_REACHED") { const promises: Promise[] = [ From c2d38899bb5005b26fcbeff4a40098322edcc9ce Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 9 Oct 2025 14:07:05 +0530 Subject: [PATCH 03/18] fix: org --- .../ee/billing/credit-service.test.ts | 31 ++++++++++++- .../features/ee/billing/credit-service.ts | 45 ++++++++++++++++--- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/features/ee/billing/credit-service.test.ts b/packages/features/ee/billing/credit-service.test.ts index 1e61fae92eb07a..af3b08f82279db 100644 --- a/packages/features/ee/billing/credit-service.test.ts +++ b/packages/features/ee/billing/credit-service.test.ts @@ -12,7 +12,12 @@ import { CreditService } from "./credit-service"; import { StripeBillingService } from "./stripe-billling-service"; import { InternalTeamBilling } from "./teams/internal-team-billing"; -const MOCK_TX = {}; +const MOCK_TX = { + team: { + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn().mockResolvedValue(null), + }, +}; vi.mock("@calcom/prisma", async (importOriginal) => { const actual = await importOriginal(); @@ -110,6 +115,12 @@ describe("CreditService", () => { describe("Team credits", () => { describe("hasAvailableCredits", () => { it("should return true if team has not yet reached limit", async () => { + vi.mocked(MOCK_TX.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + parentId: null, + }); + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ id: "1", additionalCredits: 0, @@ -134,6 +145,12 @@ describe("CreditService", () => { it("should return false if team limit reached this month", async () => { vi.setSystemTime(new Date("2024-06-20T11:59:59Z")); + vi.mocked(MOCK_TX.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + parentId: null, + }); + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ id: "1", additionalCredits: 0, @@ -158,6 +175,10 @@ describe("CreditService", () => { }, ]); + vi.mocked(MOCK_TX.team.findMany).mockResolvedValue([ + { id: 1, isOrganization: false, parentId: null }, + ]); + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ id: "1", additionalCredits: 0, @@ -184,6 +205,10 @@ describe("CreditService", () => { }, ]); + vi.mocked(MOCK_TX.team.findMany).mockResolvedValue([ + { id: 1, isOrganization: false, parentId: null }, + ]); + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ id: "1", additionalCredits: 0, @@ -767,6 +792,10 @@ describe("CreditService", () => { { teamId: 2 }, ]); + vi.mocked(MOCK_TX.team.findMany).mockResolvedValue([ + { id: 2, isOrganization: false, parentId: null }, + ]); + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ id: "2", additionalCredits: 100, diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index c9086ec8020719..30670eea4a0851 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -167,7 +167,16 @@ export class CreditService { if (!IS_SMS_CREDITS_ENABLED) return true; if (teamId) { - const creditBalance = await CreditsRepository.findCreditBalance({ teamId }, tx); + // Check if this team has a parent organization + const team = await tx.team.findUnique({ + where: { id: teamId }, + select: { id: true, isOrganization: true, parentId: true }, + }); + + // If team has a parent (organization), check parent's credits instead + const teamIdToCheck = team?.parentId ?? teamId; + + const creditBalance = await CreditsRepository.findCreditBalance({ teamId: teamIdToCheck }, tx); const limitReached = creditBalance?.limitReachedAt && @@ -176,13 +185,13 @@ export class CreditService { if (!limitReached) return true; // check if team is still out of credits - const teamCredits = await this._getAllCreditsForTeam({ teamId, tx }); + const teamCredits = await this._getAllCreditsForTeam({ teamId: teamIdToCheck, tx }); const availableCredits = teamCredits.totalRemainingMonthlyCredits + teamCredits.additionalCredits; if (availableCredits > 0) { await CreditsRepository.updateCreditBalance( { - teamId, + teamId: teamIdToCheck, data: { limitReachedAt: null, warningSentAt: null, @@ -218,6 +227,7 @@ export class CreditService { /* If user has memberships, it always returns a team, even if all have limit reached. In that case, limitReached: true is returned + Prioritizes organization credits over team credits */ protected async _getTeamWithAvailableCredits({ userId, tx }: { userId: number; tx: PrismaTransaction }) { const memberships = await MembershipRepository.findAllAcceptedPublishedTeamMemberships(userId, tx); @@ -226,11 +236,34 @@ export class CreditService { return null; } - //check if user is member of team that has available credits + const teams = await tx.team.findMany({ + where: { id: { in: memberships.map((m) => m.teamId) } }, + select: { id: true, isOrganization: true, parentId: true, parent: { select: { id: true } } }, + }); + + const teamMap = new Map(teams.map((t) => [t.id, t])); + + const orgMemberships: typeof memberships = []; + const teamMemberships: typeof memberships = []; + for (const membership of memberships) { - const creditBalance = await CreditsRepository.findCreditBalance({ teamId: membership.teamId }, tx); + const team = teamMap.get(membership.teamId); + if (team?.isOrganization && !team.parentId) { + orgMemberships.push(membership); + } else { + teamMemberships.push(membership); + } + } + + // Check organizations first, then teams + const orderedMemberships = [...orgMemberships, ...teamMemberships]; + + //check if user is member of team that has available credits + for (const membership of orderedMemberships) { + const creditBalance = await CreditsRepository.findCreditBalance({ teamId: membership.teamId }, tx); const allCredits = await this._getAllCreditsForTeam({ teamId: membership.teamId, tx }); + const limitReached = creditBalance?.limitReachedAt && dayjs(creditBalance.limitReachedAt).isAfter(dayjs().startOf("month")); @@ -260,7 +293,7 @@ export class CreditService { } return { - teamId: memberships[0].teamId, + teamId: orderedMemberships[0].teamId, availableCredits: 0, creditType: CreditType.ADDITIONAL, limitReached: true, From c95a63c3bfac66fbe05f9eddadc708f912a2fea5 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Thu, 9 Oct 2025 11:29:07 +0200 Subject: [PATCH 04/18] replace Cal AI with Cal.ai --- apps/web/app/(use-page-wrapper)/workflow/new/page.tsx | 2 +- .../app/api/webhooks/retell-ai/__tests__/route.test.ts | 6 +++--- apps/web/public/static/locales/en/common.json | 8 ++++---- .../providers/retellAI/services/AgentService.ts | 2 +- packages/features/calAIPhone/workflowTemplates.ts | 4 ++-- .../ee/workflows/lib/reminders/aiPhoneCallManager.ts | 4 ++-- packages/features/tasker/tasks/executeAIPhoneCall.ts | 2 +- packages/lib/server/repository/PrismaApiKeyRepository.ts | 2 +- .../routers/viewer/aiVoiceAgent/testCall.handler.ts | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/web/app/(use-page-wrapper)/workflow/new/page.tsx b/apps/web/app/(use-page-wrapper)/workflow/new/page.tsx index b6f616440920b8..6555cf19d8b015 100644 --- a/apps/web/app/(use-page-wrapper)/workflow/new/page.tsx +++ b/apps/web/app/(use-page-wrapper)/workflow/new/page.tsx @@ -130,7 +130,7 @@ const Page = async ({ searchParams }: PageProps) => { throw error; } - console.error("Failed to create Cal AI workflow:", error); + console.error("Failed to create Cal.ai workflow:", error); redirect("/workflows?error=failed-to-create-workflow"); } }; diff --git a/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts index 6ba9e55da58d5d..44b357740b3f7b 100644 --- a/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts @@ -876,7 +876,7 @@ describe("Retell AI Webhook Handler", () => { }); }); - describe("Cal AI Credit Type", () => { + describe("Cal.ai Credit Type", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(Retell.verify).mockReturnValue(true); @@ -1037,7 +1037,7 @@ describe("Retell AI Webhook Handler", () => { }); }); - describe("Cal AI Credit Email Integration", () => { + describe("Cal.ai Credit Email Integration", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(Retell.verify).mockReturnValue(true); @@ -1182,7 +1182,7 @@ describe("Retell AI Webhook Handler", () => { ); }); - it("should not pass SMS creditFor for Cal AI calls", async () => { + it("should not pass SMS creditFor for Cal.ai calls", async () => { const mockPhoneNumber: MockPhoneNumberWithTeam = { id: 2, phoneNumber: "+1234567890", diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index ea48177a9971d3..bea31c66e9de50 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3435,10 +3435,10 @@ "low_credits_warning_message_user": "Your Cal.com account is running low on credits. To avoid any disruption in service, please purchase additional credits. If your balance runs out, SMS messages will stop sending and will be sent as emails instead.", "credit_limit_reached_message": "Your Cal.com team {{teamName}} has run out of credits. As a result, SMS messages are now being sent via email instead. To resume sending SMS, please purchase additional credits.", "credit_limit_reached_message_user": "Your Cal.com account has run out of credits. As a result, SMS messages are now being sent via email instead. To resume sending SMS, please purchase additional credits.", - "cal_ai_low_credits_warning_message": "Your Cal.com team {{teamName}} is running low on credits. To avoid any disruption in Cal AI phone service, please purchase additional credits. If your balance runs out, Cal AI phone calls will be disabled.", - "cal_ai_low_credits_warning_message_user": "Your Cal.com account is running low on credits. To avoid any disruption in Cal AI phone service, please purchase additional credits. If your balance runs out, Cal AI phone calls will be disabled.", - "cal_ai_credit_limit_reached_message": "Your Cal.com team {{teamName}} has run out of credits. As a result, Cal AI phone calls are now disabled. To resume using Cal AI phone calls, please purchase additional credits.", - "cal_ai_credit_limit_reached_message_user": "Your Cal.com account has run out of credits. As a result, Cal AI phone calls are now disabled. To resume using Cal AI phone calls, please purchase additional credits.", + "cal_ai_low_credits_warning_message": "Your Cal.com team {{teamName}} is running low on credits. To avoid any disruption in Cal.ai phone service, please purchase additional credits. If your balance runs out, Cal.ai phone calls will be disabled.", + "cal_ai_low_credits_warning_message_user": "Your Cal.com account is running low on credits. To avoid any disruption in Cal.ai phone service, please purchase additional credits. If your balance runs out, Cal.ai phone calls will be disabled.", + "cal_ai_credit_limit_reached_message": "Your Cal.com team {{teamName}} has run out of credits. As a result, Cal.ai phone calls are now disabled. To resume using Cal.ai phone calls, please purchase additional credits.", + "cal_ai_credit_limit_reached_message_user": "Your Cal.com account has run out of credits. As a result, Cal.ai phone calls are now disabled. To resume using Cal.ai phone calls, please purchase additional credits.", "current_credit_balance": "Current balance: {{balance}} credits", "current_balance": "Current balance:", "notification_about_your_booking": "Notification about your booking", diff --git a/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts b/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts index 6c867c9c56a061..8602a0adbff1d9 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts @@ -32,7 +32,7 @@ export class AgentService { userId, teamId, expiresAt: null, - note: `Cal AI Phone API Key for agent ${userId} ${teamId ? `for team ${teamId}` : ""}`, + note: `Cal.ai Phone API Key for agent ${userId} ${teamId ? `for team ${teamId}` : ""}`, }); } diff --git a/packages/features/calAIPhone/workflowTemplates.ts b/packages/features/calAIPhone/workflowTemplates.ts index b8bed40a7f91af..79fff877a13c9a 100644 --- a/packages/features/calAIPhone/workflowTemplates.ts +++ b/packages/features/calAIPhone/workflowTemplates.ts @@ -18,7 +18,7 @@ const scheduleRule = ` ## Schedule Rule // Key are from components/sections/template/data/workflows.ts page in https://github.com/calcom/website export const calAIPhoneWorkflowTemplates = { - // name: "Cal AI No-show Follow-up Call", + // name: "Cal.ai No-show Follow-up Call", // description: "Automatically call attendee when marked as no-show" "wf-10": { generalPrompt: `## You are calling an attendee who was marked as a no-show for their appointment. Your goal is to help them reschedule. Be understanding, friendly, and non-judgmental. @@ -47,7 +47,7 @@ ${responseGuideline} 9. Thank them for their time and call function end_call to hang up.`, }, - // name: "Cal AI 1-hour Meeting Reminder", + // name: "Cal.ai 1-hour Meeting Reminder", // description: "Remind attendee 1 hour before the meeting" "wf-11": { generalPrompt: `## You are calling to remind an attendee about their upcoming appointment in 1 hour. Be friendly, helpful, and concise. diff --git a/packages/features/ee/workflows/lib/reminders/aiPhoneCallManager.ts b/packages/features/ee/workflows/lib/reminders/aiPhoneCallManager.ts index aa3ffaa513a6da..027535f615da20 100644 --- a/packages/features/ee/workflows/lib/reminders/aiPhoneCallManager.ts +++ b/packages/features/ee/workflows/lib/reminders/aiPhoneCallManager.ts @@ -140,7 +140,7 @@ export const scheduleAIPhoneCall = async (args: ScheduleAIPhoneCallArgs) => { const featuresRepository = new FeaturesRepository(prisma); const calAIVoiceAgents = await featuresRepository.checkIfFeatureIsEnabledGlobally("cal-ai-voice-agents"); if (!calAIVoiceAgents) { - logger.warn("Cal AI voice agents are disabled - skipping AI phone call scheduling"); + logger.warn("Cal.ai voice agents are disabled - skipping AI phone call scheduling"); return; } @@ -259,7 +259,7 @@ const scheduleAIPhoneCallTask = async (args: ScheduleAIPhoneCallTaskArgs) => { const featuresRepository = new FeaturesRepository(prisma); const calAIVoiceAgents = await featuresRepository.checkIfFeatureIsEnabledGlobally("cal-ai-voice-agents"); if (!calAIVoiceAgents) { - logger.warn("Cal AI voice agents are disabled - skipping AI phone call"); + logger.warn("Cal.ai voice agents are disabled - skipping AI phone call"); return; } diff --git a/packages/features/tasker/tasks/executeAIPhoneCall.ts b/packages/features/tasker/tasks/executeAIPhoneCall.ts index 4702afe7643a3c..ee502be99c4eef 100644 --- a/packages/features/tasker/tasks/executeAIPhoneCall.ts +++ b/packages/features/tasker/tasks/executeAIPhoneCall.ts @@ -38,7 +38,7 @@ export async function executeAIPhoneCall(payload: string) { const calAIVoiceAgents = await featuresRepository.checkIfFeatureIsEnabledGlobally("cal-ai-voice-agents"); if (!calAIVoiceAgents) { - log.warn("Cal AI voice agents are disabled - skipping AI phone call"); + log.warn("Cal.ai voice agents are disabled - skipping AI phone call"); return; } diff --git a/packages/lib/server/repository/PrismaApiKeyRepository.ts b/packages/lib/server/repository/PrismaApiKeyRepository.ts index be4aae05b26917..f4d28ecbe0db33 100644 --- a/packages/lib/server/repository/PrismaApiKeyRepository.ts +++ b/packages/lib/server/repository/PrismaApiKeyRepository.ts @@ -28,7 +28,7 @@ export class PrismaApiKeyRepository { orderBy: { createdAt: "desc" }, }); return apiKeys.filter((apiKey) => { - if (apiKey.note?.startsWith("Cal AI Phone API Key")) { + if (apiKey.note?.startsWith("Cal.ai Phone API Key")) { return false; } return true; diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts index 9769e9da3199e1..24f8018e8f680d 100644 --- a/packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts +++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts @@ -23,7 +23,7 @@ export const testCallHandler = async ({ ctx, input }: TestCallHandlerOptions) => const featuresRepository = new FeaturesRepository(prisma); const calAIVoiceAgents = await featuresRepository.checkIfFeatureIsEnabledGlobally("cal-ai-voice-agents"); if (!calAIVoiceAgents) { - logger.warn("Cal AI voice agents are disabled - skipping AI phone call scheduling"); + logger.warn("Cal.ai voice agents are disabled - skipping AI phone call scheduling"); return; } From 5c0642974c1259deef562433aa12b8a56b5f35f4 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 9 Oct 2025 20:51:14 +0530 Subject: [PATCH 05/18] fix: use --- .../ee/billing/credit-service.test.ts | 143 ++++++++++++++++-- .../features/ee/billing/credit-service.ts | 25 ++- 2 files changed, 143 insertions(+), 25 deletions(-) diff --git a/packages/features/ee/billing/credit-service.test.ts b/packages/features/ee/billing/credit-service.test.ts index af3b08f82279db..95d6f1a47230ad 100644 --- a/packages/features/ee/billing/credit-service.test.ts +++ b/packages/features/ee/billing/credit-service.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import dayjs from "@calcom/dayjs"; import * as EmailManager from "@calcom/emails/email-manager"; +import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { CreditsRepository } from "@calcom/lib/server/repository/credits"; import { MembershipRepository } from "@calcom/lib/server/repository/membership"; import { TeamRepository } from "@calcom/lib/server/repository/team"; @@ -15,7 +16,7 @@ import { InternalTeamBilling } from "./teams/internal-team-billing"; const MOCK_TX = { team: { findMany: vi.fn().mockResolvedValue([]), - findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), }, }; @@ -71,6 +72,9 @@ vi.mock("@calcom/emails/email-manager"); vi.mock("../workflows/lib/reminders/reminderScheduler", () => ({ cancelScheduledMessagesAndScheduleEmails: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({ + default: vi.fn().mockResolvedValue(null), +})); const creditService = new CreditService(); @@ -115,11 +119,7 @@ describe("CreditService", () => { describe("Team credits", () => { describe("hasAvailableCredits", () => { it("should return true if team has not yet reached limit", async () => { - vi.mocked(MOCK_TX.team.findUnique).mockResolvedValue({ - id: 1, - isOrganization: false, - parentId: null, - }); + vi.mocked(getOrgIdFromMemberOrTeamId).mockResolvedValue(null); vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ id: "1", @@ -145,11 +145,7 @@ describe("CreditService", () => { it("should return false if team limit reached this month", async () => { vi.setSystemTime(new Date("2024-06-20T11:59:59Z")); - vi.mocked(MOCK_TX.team.findUnique).mockResolvedValue({ - id: 1, - isOrganization: false, - parentId: null, - }); + vi.mocked(getOrgIdFromMemberOrTeamId).mockResolvedValue(null); vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ id: "1", @@ -819,6 +815,131 @@ describe("CreditService", () => { expect(CreditsRepository.findCreditBalance).toHaveBeenCalledTimes(1); expect(CreditsRepository.findCreditBalance).toHaveBeenCalledWith({ teamId: 2 }, MOCK_TX); }); + + describe("Organization priority", () => { + it("should use organization credits when user belongs to org, ignoring team memberships", async () => { + vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([ + { teamId: 1 }, + { teamId: 2 }, + ]); + + vi.mocked(MOCK_TX.team.findMany).mockResolvedValue([ + { id: 1, isOrganization: true, parentId: null }, + { id: 2, isOrganization: false, parentId: null }, + ]); + + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ + id: "1", + additionalCredits: 50, + limitReachedAt: null, + warningSentAt: null, + }); + + vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({ + totalMonthlyCredits: 1000, + totalRemainingMonthlyCredits: 800, + additionalCredits: 50, + totalCreditsUsedThisMonth: 200, + }); + + const result = await creditService.getTeamWithAvailableCredits(1); + + expect(result).toEqual({ + teamId: 1, + availableCredits: 850, + creditType: CreditType.MONTHLY, + }); + }); + + it("should return org with limitReached when org has no credits, ignoring teams", async () => { + vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([ + { teamId: 1 }, + { teamId: 2 }, + ]); + + vi.mocked(MOCK_TX.team.findMany).mockResolvedValue([ + { id: 1, isOrganization: true, parentId: null }, + { id: 2, isOrganization: false, parentId: null }, + ]); + + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ + id: "1", + additionalCredits: 0, + limitReachedAt: new Date(), + warningSentAt: null, + }); + + vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({ + totalMonthlyCredits: 1000, + totalRemainingMonthlyCredits: 0, + additionalCredits: 0, + totalCreditsUsedThisMonth: 1000, + }); + + const result = await creditService.getTeamWithAvailableCredits(1); + + expect(result).toEqual({ + teamId: 1, + availableCredits: 0, + creditType: CreditType.ADDITIONAL, + limitReached: true, + }); + }); + + it("should check teams when user has no org membership", async () => { + vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([ + { teamId: 2 }, + ]); + + vi.mocked(MOCK_TX.team.findMany).mockResolvedValue([ + { id: 2, isOrganization: false, parentId: null }, + ]); + + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ + id: "2", + additionalCredits: 100, + limitReachedAt: null, + warningSentAt: null, + }); + + vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({ + totalMonthlyCredits: 500, + totalRemainingMonthlyCredits: 300, + additionalCredits: 100, + totalCreditsUsedThisMonth: 200, + }); + + const result = await creditService.getTeamWithAvailableCredits(1); + + expect(result).toEqual({ + teamId: 2, + availableCredits: 400, + creditType: CreditType.MONTHLY, + }); + }); + + it("should use parent org credits when teamId belongs to org", async () => { + vi.mocked(getOrgIdFromMemberOrTeamId).mockResolvedValue(100); + + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ + id: "100", + additionalCredits: 200, + limitReachedAt: null, + warningSentAt: null, + }); + + vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({ + totalMonthlyCredits: 2000, + totalRemainingMonthlyCredits: 1500, + additionalCredits: 200, + totalCreditsUsedThisMonth: 500, + }); + + const result = await creditService.hasAvailableCredits({ teamId: 50 }); + + expect(result).toBe(true); + }); + }); }); describe("Idempotency", () => { diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index 30670eea4a0851..c926bb313b4f14 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -9,6 +9,7 @@ import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billlin import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing"; import { cancelScheduledMessagesAndScheduleEmails } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants"; +import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; import { CreditsRepository } from "@calcom/lib/server/repository/credits"; @@ -167,14 +168,11 @@ export class CreditService { if (!IS_SMS_CREDITS_ENABLED) return true; if (teamId) { - // Check if this team has a parent organization - const team = await tx.team.findUnique({ - where: { id: teamId }, - select: { id: true, isOrganization: true, parentId: true }, - }); + // Check if this team belongs to an organization or is itself an organization + const orgId = await getOrgIdFromMemberOrTeamId({ teamId }, tx); - // If team has a parent (organization), check parent's credits instead - const teamIdToCheck = team?.parentId ?? teamId; + // Use organization credits if team belongs to org, otherwise use team's own credits + const teamIdToCheck = orgId ?? teamId; const creditBalance = await CreditsRepository.findCreditBalance({ teamId: teamIdToCheck }, tx); @@ -227,7 +225,8 @@ export class CreditService { /* If user has memberships, it always returns a team, even if all have limit reached. In that case, limitReached: true is returned - Prioritizes organization credits over team credits + If user belongs to any organization, ONLY organization credits are checked (team memberships are ignored) + If user does not belong to an organization, team credits are checked */ protected async _getTeamWithAvailableCredits({ userId, tx }: { userId: number; tx: PrismaTransaction }) { const memberships = await MembershipRepository.findAllAcceptedPublishedTeamMemberships(userId, tx); @@ -255,12 +254,10 @@ export class CreditService { } } - // Check organizations first, then teams - const orderedMemberships = [...orgMemberships, ...teamMemberships]; - + // If user belongs to any organization, ONLY check organization credits + const membershipsToCheck = orgMemberships.length > 0 ? orgMemberships : teamMemberships; - //check if user is member of team that has available credits - for (const membership of orderedMemberships) { + for (const membership of membershipsToCheck) { const creditBalance = await CreditsRepository.findCreditBalance({ teamId: membership.teamId }, tx); const allCredits = await this._getAllCreditsForTeam({ teamId: membership.teamId, tx }); @@ -293,7 +290,7 @@ export class CreditService { } return { - teamId: orderedMemberships[0].teamId, + teamId: membershipsToCheck[0].teamId, availableCredits: 0, creditType: CreditType.ADDITIONAL, limitReached: true, From f413c63820ba590d32fee39e5c362f67fa83e834 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 9 Oct 2025 21:02:04 +0530 Subject: [PATCH 06/18] fix: feedback --- packages/features/ee/billing/credit-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index c926bb313b4f14..4d537b2921b61a 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -580,7 +580,7 @@ export class CreditService { }), ]; - if (result.creditFor === CreditUsageType.SMS) { + if (!result.creditFor || result.creditFor === CreditUsageType.SMS) { promises.push( cancelScheduledMessagesAndScheduleEmails({ teamId: result.teamId, userId: result.userId }).catch( (error) => { From 27f30f5b5ec5f6f4fefba649f6373c9ea82598ab Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 9 Oct 2025 23:58:04 +0530 Subject: [PATCH 07/18] fix: types --- packages/lib/getOrgIdFromMemberOrTeamId.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/lib/getOrgIdFromMemberOrTeamId.ts b/packages/lib/getOrgIdFromMemberOrTeamId.ts index 82875fc3a7dfd4..64dc34f71a6113 100644 --- a/packages/lib/getOrgIdFromMemberOrTeamId.ts +++ b/packages/lib/getOrgIdFromMemberOrTeamId.ts @@ -1,5 +1,7 @@ import prisma from "@calcom/prisma"; -import type { Prisma } from "@calcom/prisma/client"; +import type { Prisma, PrismaClient } from "@calcom/prisma/client"; + +type PrismaTransaction = Omit; const getOrgMemberOrTeamWhere = (memberId?: number | null, teamId?: number | null) => { const conditions: Prisma.TeamWhereInput[] = []; @@ -44,11 +46,15 @@ const getOrgMemberOrTeamWhere = (memberId?: number | null, teamId?: number | nul }; }; -export default async function getOrgIdFromMemberOrTeamId(args: { - memberId?: number | null; - teamId?: number | null; -}) { - const orgId = await prisma.team.findFirst({ +export default async function getOrgIdFromMemberOrTeamId( + args: { + memberId?: number | null; + teamId?: number | null; + }, + tx?: PrismaTransaction +) { + const client = tx ?? prisma; + const orgId = await client.team.findFirst({ where: getOrgMemberOrTeamWhere(args.memberId, args.teamId), select: { id: true, From ea19c1742a49afd5f90de8f4fccd7eb04d41e75d Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Fri, 10 Oct 2025 00:05:19 +0530 Subject: [PATCH 08/18] fix: types --- packages/lib/getOrgIdFromMemberOrTeamId.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/lib/getOrgIdFromMemberOrTeamId.ts b/packages/lib/getOrgIdFromMemberOrTeamId.ts index 64dc34f71a6113..765c72ed4744ce 100644 --- a/packages/lib/getOrgIdFromMemberOrTeamId.ts +++ b/packages/lib/getOrgIdFromMemberOrTeamId.ts @@ -1,7 +1,5 @@ -import prisma from "@calcom/prisma"; -import type { Prisma, PrismaClient } from "@calcom/prisma/client"; - -type PrismaTransaction = Omit; +import prisma, { type PrismaTransaction } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; const getOrgMemberOrTeamWhere = (memberId?: number | null, teamId?: number | null) => { const conditions: Prisma.TeamWhereInput[] = []; From 1dabea49485a87ea029fe0ec98656d2d5a701b8c Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Fri, 10 Oct 2025 00:06:46 +0530 Subject: [PATCH 09/18] fix: types --- packages/features/ee/billing/credit-service.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index 4d537b2921b61a..31f5957c2d6450 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -471,6 +471,11 @@ export class CreditService { creditBalance?.limitReachedAt && (!teamId || dayjs(creditBalance?.limitReachedAt).isAfter(dayjs().startOf("month"))) ) { + log.info("User or team has limit already reached or team has already reached limit this month", { + teamId, + userId, + creditBalance, + }); return null; // user has limit already reached or team has already reached limit this month } From 541984c01e8c154cac40f5805a6eae210b89c72a Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Fri, 10 Oct 2025 00:21:21 +0530 Subject: [PATCH 10/18] fix: tests --- .../api/webhooks/retell-ai/__tests__/route.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts index c5c97b80f5a57d..6de8609356c94d 100644 --- a/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts @@ -899,7 +899,7 @@ describe("Retell AI Webhook Handler", () => { updatedAt: new Date(), }; - vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent); + mockFindByProviderAgentId.mockResolvedValue(mockAgent); mockChargeCredits.mockResolvedValue(undefined); const body: RetellWebhookBody = { @@ -951,7 +951,7 @@ describe("Retell AI Webhook Handler", () => { team: null, }; - vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber); mockChargeCredits.mockResolvedValue(undefined); const body: RetellWebhookBody = { @@ -1004,7 +1004,7 @@ describe("Retell AI Webhook Handler", () => { user: null, }; - vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber); mockChargeCredits.mockResolvedValue(undefined); const body: RetellWebhookBody = { @@ -1066,7 +1066,7 @@ describe("Retell AI Webhook Handler", () => { team: null, }; - vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber); mockChargeCredits.mockImplementation(async (params) => { if (params.creditFor === CreditUsageType.CAL_AI_PHONE_CALL) { @@ -1132,7 +1132,7 @@ describe("Retell AI Webhook Handler", () => { updatedAt: new Date(), }; - vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent); + mockFindByProviderAgentId.mockResolvedValue(mockAgent); mockChargeCredits.mockImplementation(async (params) => { if (params.creditFor === CreditUsageType.CAL_AI_PHONE_CALL) { @@ -1203,7 +1203,7 @@ describe("Retell AI Webhook Handler", () => { user: null, }; - vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber); mockChargeCredits.mockResolvedValue({ teamId: 5 }); const body: RetellWebhookBody = { From 966e8829b9cbe88ad6f9d8143fe2b18a82973491 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Tue, 21 Oct 2025 17:10:57 +0530 Subject: [PATCH 11/18] Merge branch 'main' into fix/cal-ai-credits --- .agents/knowledge-base.md | 220 ++- .github/CODEOWNERS | 9 +- .vscode/settings.json | 3 +- .yarn/versions/13de130d.yml | 0 .yarn/versions/3a60c02f.yml | 0 .yarn/versions/492e4e82.yml | 0 .yarn/versions/c0a99150.yml | 2 + .yarn/versions/e11ed2da.yml | 0 README.md | 9 +- .../v1/lib/helpers/rateLimitApiKey.test.ts | 4 +- apps/api/v1/lib/helpers/rateLimitApiKey.ts | 2 +- apps/api/v1/lib/helpers/verifyApiKey.test.ts | 152 +- apps/api/v1/lib/helpers/verifyApiKey.ts | 37 +- apps/api/v1/lib/utils/isLockedOrBlocked.ts | 10 +- apps/api/v1/lib/validations/user.ts | 2 +- .../api/bookings/[id]/recordings/_get.ts | 2 +- .../[id]/transcripts/[recordingId]/_get.ts | 2 +- .../api/bookings/[id]/transcripts/_get.ts | 2 +- apps/api/v1/pages/api/bookings/_post.ts | 14 +- .../v1/pages/api/connected-calendars/_get.ts | 2 +- .../api/v1/pages/api/teams/[teamId]/_patch.ts | 2 +- .../v1/pages/api/users/[userId]/_delete.ts | 2 +- apps/api/v1/pages/api/users/_post.ts | 2 +- .../lib/bookings/[id]/recordings/_get.test.ts | 4 +- .../transcripts/[recordingId]/_get.test.ts | 4 +- .../bookings/[id]/transcripts/_get.test.ts | 4 +- apps/api/v1/test/lib/bookings/_post.test.ts | 4 +- apps/api/v1/test/lib/users/_post.test.ts | 109 +- .../test/lib/utils/isLockedOrBlocked.test.ts | 64 +- .../ee/bookings/2024-04-15/bookings.module.ts | 6 + .../bookings.controller.e2e-spec.ts | 16 +- .../controllers/bookings.controller.ts | 61 +- .../ee/bookings/2024-08-13/bookings.module.ts | 6 + .../controllers/bookings.controller.ts | 62 +- .../controllers/e2e/user-bookings.e2e-spec.ts | 151 +- .../2024-08-13/services/bookings.service.ts | 117 +- .../2024-08-13/services/errors.service.ts | 13 + .../2024-08-13/services/input.service.ts | 2 +- .../services/event-types.service.ts | 6 +- .../event-types.controller.e2e-spec.ts | 191 ++- .../controllers/event-types.controller.ts | 28 +- .../services/input-event-types.service.ts | 90 +- .../services/output-event-types.service.ts | 28 + .../transformed/event-type.tranformed.ts | 2 + .../internal-to-api/booking-fields.ts | 2 +- .../controllers/schedules.controller.ts | 29 +- .../src/lib/modules/booking-cancel.module.ts | 10 + .../src/lib/modules/instant-booking.module.ts | 8 + .../lib/modules/recurring-booking.module.ts | 10 + .../src/lib/modules/regular-booking.module.ts | 32 + .../lib/services/booking-cancel.service.ts | 15 + .../instant-booking-create.service.ts | 6 + .../lib/services/recurring-booking.service.ts | 13 + .../lib/services/regular-booking.service.ts | 46 + .../controllers/atoms.schedules.controller.ts | 57 + ...m.ts => is-event-type-workflow-in-team.ts} | 9 +- .../is-routing-form-workflow-in-team.ts | 59 + ...-member-team-admin-event-types.e2e-spec.ts | 13 + .../event-types/services/input.service.ts | 73 +- .../event-types/services/output.service.ts | 30 +- .../organizations/organizations.module.ts | 8 +- .../org-team-workflows.controller.e2e-spec.ts | 570 +++++++- .../org-team-workflows.controller.ts | 142 +- ...am-event-type-slots.controller.e2e-spec.ts | 206 ++- .../controllers/slots.controller.ts | 12 +- .../services/slots.service.ts | 21 +- .../inputs/get-team-memberships.input.ts | 5 +- .../users/inputs/create-managed-user.input.ts | 6 +- .../users/inputs/update-managed-user.input.ts | 3 + ...ts => create-event-type-workflow.input.ts} | 45 +- .../workflows/inputs/create-form-workflow.ts | 106 ++ ...ts => update-event-type-workflow.input.ts} | 118 +- .../inputs/update-form-workflow.input.ts | 97 ++ .../workflows/inputs/workflow-step.input.ts | 108 +- .../inputs/workflow-trigger.input.ts | 95 +- ...flow.output.ts => base-workflow.output.ts} | 142 +- .../outputs/event-type-workflow.output.ts | 140 ++ .../outputs/routing-form-workflow.output.ts | 138 ++ .../team-event-type-workflows.service.ts | 113 ++ ...=> team-routing-form-workflows.service.ts} | 62 +- .../services/workflows.input.service.ts | 185 ++- .../services/workflows.output.service.ts | 307 +++- .../modules/workflows/workflows.repository.ts | 73 +- .../booking-successful/[uid]/page.tsx | 50 + .../team/[slug]/[type]/pageWithCachedData.tsx | 2 +- .../team/[slug]/[type]/queries.ts | 4 +- .../(main-nav)/availability/page.tsx | 13 +- .../(main-nav)/bookings/[status]/page.tsx | 2 +- .../(main-nav)/teams/server-page.tsx | 4 +- .../apps/(homepage)/page.tsx | 2 +- .../getting-started/[[...step]]/page.tsx | 2 +- .../admin/organizations/[id]/edit/page.tsx | 2 +- .../admin/users/[id]/edit/page.tsx | 2 +- .../SettingsLayoutAppDirClient.tsx | 50 +- .../developer/webhooks/[id]/page.tsx | 5 +- .../settings/(settings-layout)/layout.tsx | 20 +- .../(org-admin-only)/privacy/page.tsx | 27 +- .../_components/FingerprintAnimation.tsx | 63 + .../roles/_components/PbacOptInModal.tsx | 129 ++ .../roles/_components/PbacOptInView.tsx | 59 + .../organizations/roles/page.tsx | 20 +- .../teams/other/(main-page)/page.tsx | 2 +- .../organizations/new/resume/page.tsx | 22 + .../video/meeting-not-started/[uid]/page.tsx | 2 +- .../app/(use-page-wrapper)/workflows/page.tsx | 2 +- .../app/api/availability/calendar/route.ts | 2 +- apps/web/app/api/cancel/route.ts | 9 + .../api/cron/calendar-subscriptions/route.ts | 2 +- apps/web/app/api/cron/credentials/route.ts | 4 +- .../app/api/cron/selected-calendars/route.ts | 2 +- .../web/app/api/recorded-daily-video/route.ts | 2 +- .../web/app/api/support/conversation/route.ts | 4 +- apps/web/app/api/teams/api/create/route.ts | 5 + apps/web/app/api/teams/create/route.ts | 3 + apps/web/app/api/username/route.ts | 2 +- .../video/recording/__tests__/route.test.ts | 4 +- apps/web/app/api/video/recording/route.ts | 2 +- .../calendar-subscription/[provider]/route.ts | 2 +- apps/web/app/cache/membership.ts | 2 +- apps/web/components/apps/AppPage.tsx | 25 +- .../apps/alby/AlbyPaymentComponent.tsx | 2 +- .../btcpayserver/BtcpayPaymentComponent.tsx | 2 +- .../components/booking/BookingListItem.tsx | 75 + apps/web/components/booking/bookingActions.ts | 29 +- .../components/dialog/ReportBookingDialog.tsx | 139 ++ apps/web/components/dialog/RerouteDialog.tsx | 2 +- apps/web/components/error/error-page.tsx | 1 + .../[[...step]]/getServerSideProps.ts | 2 +- .../getServerSidePropsRoutingLink.ts | 11 +- .../getServerSidePropsSingleForm.ts | 4 +- apps/web/lib/booking.ts | 18 +- .../d/[link]/[slug]/getServerSideProps.tsx | 11 +- .../team/[slug]/[type]/getServerSideProps.ts | 4 +- apps/web/lib/pages/auth/verify-email.test.ts | 6 +- apps/web/lib/pages/auth/verify-email.ts | 4 +- .../reschedule/[uid]/getServerSideProps.ts | 4 +- .../team/[slug]/[type]/getServerSideProps.tsx | 20 +- .../lib/team/[slug]/getServerSideProps.tsx | 2 +- apps/web/lib/types/booking.ts | 6 +- .../web/lib/video/[uid]/getServerSideProps.ts | 8 +- .../meeting-ended/[uid]/getServerSideProps.ts | 2 +- .../[uid]/getServerSideProps.ts | 2 +- apps/web/modules/auth/login-view.tsx | 4 +- .../availability/availability-view.tsx | 1 + .../bookings/components/BookingsCalendar.tsx | 34 + .../bookings/components/BookingsList.tsx | 69 + apps/web/modules/bookings/types.ts | 25 + ...ookings-single-view.getServerSideProps.tsx | 11 +- .../bookings/views/bookings-single-view.tsx | 12 +- ...ngs-listing-view.tsx => bookings-view.tsx} | 102 +- .../settings/my-account/general-view.tsx | 16 + .../new/_components/AddNewTeamsForm.tsx | 1 + .../new/_components/OnboardMembersView.tsx | 71 +- .../new/_components/PaymentStatusView.tsx | 6 +- .../organizations/new/resume-view.tsx | 114 ++ .../organizations/privacy/blocklist-table.tsx | 207 +++ .../blocklist-entry-details-sheet.tsx | 131 ++ .../create-blocklist-entry-modal.tsx | 173 +++ .../settings/teams/[id]/event-types-view.tsx | 2 +- apps/web/modules/test-setup.ts | 2 +- .../users/views/users-public-view.test.tsx | 9 +- apps/web/package.json | 5 +- apps/web/pages/_error.tsx | 1 + apps/web/pages/api/book/event.ts | 15 +- apps/web/pages/api/book/instant-event.ts | 11 +- apps/web/pages/api/book/recurring-event.ts | 21 +- .../booking-duplicate-api-calls.e2e.ts | 196 ++- apps/web/playwright/booking-seats.e2e.ts | 18 +- apps/web/playwright/fixtures/apps.ts | 3 +- apps/web/playwright/fixtures/users.ts | 2 +- apps/web/playwright/lib/orgMigration.ts | 2 +- .../organization-creation-flows.e2e.ts | 451 ++++++ .../organization/organization-creation.e2e.ts | 475 ------- apps/web/playwright/out-of-office.e2e.ts | 14 +- apps/web/playwright/payment-apps.e2e.ts | 22 +- apps/web/public/static/locales/en/common.json | 78 +- .../lib/[user]/[type]/getServerSideProps.ts | 6 +- .../server/lib/[user]/getServerSideProps.ts | 17 +- .../sso/[provider]/getServerSideProps.tsx | 2 +- apps/web/test/lib/checkBookingLimits.test.ts | 2 +- apps/web/test/lib/checkDurationLimits.test.ts | 4 +- apps/web/test/lib/generateCsv.test.ts | 5 +- .../utils/bookingScenario/bookingScenario.ts | 32 +- .../getMockRequestDataForBooking.ts | 1 + docs/api-reference/v2/openapi.json | 1231 ++++++++++++++--- docs/images/deploy-northflank.svg | 79 ++ docs/mint.json | 15 +- docs/platform/atoms/calendar-view.mdx | 9 +- docs/platform/atoms/create-schedule.mdx | 127 ++ docs/platform/atoms/event-type.mdx | 16 +- docs/platform/atoms/list-schedules.mdx | 116 ++ docs/self-hosting/deployments/northflank.mdx | 13 + infra/README.md | 3 - infra/docker/api/Dockerfile | 73 - package.json | 3 - packages/app-store/_appRegistry.ts | 2 +- packages/app-store/_utils/installation.ts | 2 +- .../_utils/oauth/getCurrentTokenObject.ts | 2 +- .../_utils/oauth/updateProfilePhotoGoogle.ts | 2 +- .../_utils/oauth/updateTokenObject.ts | 2 +- .../_utils/throwIfNotHaveAdminAccessToTeam.ts | 2 +- .../pages/setup/_getServerSideProps.tsx | 2 +- .../dailyvideo/lib/VideoApiAdapter.ts | 4 +- .../app-store/delegationCredential.test.ts | 10 +- packages/app-store/delegationCredential.ts | 8 +- .../app-store/dub/lib/AnalyticsService.ts | 2 +- packages/app-store/getVideoAdapters.ts | 45 + .../app-store/googlecalendar/api/callback.ts | 2 +- .../googlecalendar/lib/CalendarService.ts | 2 +- .../lib/__tests__/getFreeBusyResult.test.ts | 135 ++ packages/app-store/hubspot/lib/CrmService.ts | 19 +- packages/app-store/jelly/config.json | 2 +- .../routing-forms/api/responses/[formId].ts | 5 +- .../routerGetCrmContactOwnerEmail.ts | 2 +- .../lib/formSubmissionUtils.test.ts | 150 +- .../routing-forms/lib/formSubmissionUtils.ts | 26 +- .../routing-forms/lib/getSerializableForm.ts | 2 +- .../routing-forms/lib/handleResponse.test.ts | 2 + .../lib/isFormCreateEditAllowed.ts | 2 +- .../app-store/salesforce/lib/CrmService.ts | 4 +- .../__tests__/CrmService.integration.test.ts | 10 +- .../routingForm/incompleteBookingAction.ts | 2 +- .../lib/routingFormBookingFormHandler.ts | 2 +- .../api/__tests__/portal.test.ts | 4 +- .../stripepayment/lib/PaymentService.ts | 2 +- .../lib/getCustomerAndCheckoutSession.ts | 2 +- .../lib/services/base/BillingPortalService.ts | 2 +- .../factory/BillingPortalServiceFactory.ts | 2 +- packages/app-store/vital/lib/reschedule.ts | 2 +- .../wipemycalother/lib/reschedule.ts | 2 +- .../src/components/UserFieldsResponses.tsx | 2 +- packages/embeds/embed-core/index.html | 8 + .../embed-core/playground/lib/playground.ts | 5 + .../embed-core/playwright/lib/testUtils.ts | 16 + .../playwright/tests/action-based.e2e.ts | 29 + .../embeds/embed-core/src/embed-iframe.ts | 102 +- .../__tests__/isLinkReady.test.ts | 11 +- .../src/embed-iframe/__tests__/utils.test.ts | 230 +++ .../src/embed-iframe/lib/embedStore.ts | 4 +- .../embed-core/src/embed-iframe/lib/utils.ts | 108 +- packages/features/apps/components/AllApps.tsx | 2 +- packages/features/apps/components/AppCard.tsx | 2 +- packages/features/auth/SAMLLogin.tsx | 2 +- .../auth}/lib/getLocaleFromRequest.ts | 0 .../features/auth/lib/getServerSession.ts | 2 +- .../auth}/lib/hooks/useLastUsed.tsx | 0 .../features/auth/lib/next-auth-options.ts | 8 +- packages/features/auth/lib/verifyEmail.ts | 15 +- .../auth/signup/handlers/calcomHandler.ts | 13 +- .../auth/signup/handlers/selfHostedHandler.ts | 2 +- .../signup/utils/createOrUpdateMemberships.ts | 2 +- .../auth/signup/utils/organization.ts | 2 +- packages/features/auth/signup/utils/token.ts | 2 +- .../auth/signup/utils}/validateUsername.ts | 0 .../filterRedundantDateRanges.test.ts | 0 .../filterRedundantDateRanges.ts | 2 +- .../mergeOverlappingDateRanges.test.ts | 2 +- .../mergeOverlappingDateRanges.ts | 2 +- .../getAggregatedAvailability.test.ts | 0 .../getAggregatedAvailability.ts | 8 +- .../availability/lib/getUserAvailability.ts | 22 +- .../BookEventForm/BookEventForm.tsx | 10 +- .../BookEventForm/BookingFields.tsx | 3 +- .../components/DecoyBookingSuccessCard.tsx | 115 ++ .../Booker/components/LargeCalendar.tsx | 4 +- .../Booker/components/hooks/useBookings.ts | 68 +- .../components/hooks/useDecoyBooking.ts | 31 + .../Booker/components/hooks/usePrefetch.ts | 47 +- .../utils/areDifferentValidMonths.test.ts | 53 + .../Booker/utils/areDifferentValidMonths.ts | 14 + .../utils/getPrefetchMonthCount.test.ts | 103 ++ .../Booker/utils/getPrefetchMonthCount.ts | 35 + .../Booker/utils/isMonthChange.test.ts | 44 + .../bookings/Booker/utils/isMonthChange.ts | 11 + .../utils/isMonthViewPrefetchEnabled.test.ts | 65 + .../utils/isMonthViewPrefetchEnabled.ts | 14 + .../utils/isPrefetchNextMonthEnabled.test.ts | 208 +++ .../utils/isPrefetchNextMonthEnabled.ts | 29 + .../components/BookingSuccessCard.tsx | 114 ++ .../components/event-meta/Members.tsx | 4 +- .../di/BookingCancelService.container.ts | 14 + .../di/BookingCancelService.module.ts | 24 + .../InstantBookingCreateService.container.ts | 13 + .../di/InstantBookingCreateService.module.ts | 20 + .../di/RecurringBookingService.container.ts | 14 + .../di/RecurringBookingService.module.ts | 25 + .../di}/RegularBookingService.container.ts | 7 +- .../di}/RegularBookingService.module.ts | 15 +- .../{di/bookings => bookings/di}/tokens.ts | 2 + .../bookings}/hooks/useBookerUrl.ts | 0 .../features/bookings/lib/EventManager.ts | 6 +- .../bookings/lib/bookingCreateBodySchema.ts | 66 +- .../lib/bookingSuccessRedirect.test.ts | 0 .../bookings}/lib/bookingSuccessRedirect.ts | 3 +- .../lib}/buildEventUrlFromBooking.test.ts | 4 +- .../bookings/lib}/buildEventUrlFromBooking.ts | 5 +- .../bookings/lib}/checkBookingLimits.ts | 9 +- .../bookings/lib}/checkDurationLimits.ts | 9 +- .../bookings/lib/client/decoyBookingStore.ts | 86 ++ .../bookings/lib/create-instant-booking.ts | 10 +- .../bookings/lib/dto/BookingCancel.d.ts | 35 + packages/features/bookings/lib/dto/types.d.ts | 26 +- .../getAllCredentials.test.ts | 4 +- .../getAllCredentials.ts | 2 +- .../bookings/lib}/getAllUserBookings.ts | 0 .../features/bookings/lib/getBookingFields.ts | 2 +- .../lib/getBookingResponsesSchema.test.ts | 60 + .../bookings/lib/getBookingResponsesSchema.ts | 5 +- .../bookings/lib/getCalEventResponses.ts | 2 +- .../lib}/getLuckyUser.integration-test.ts | 0 .../features/bookings/lib/getLuckyUser.ts | 6 +- .../bookings/lib/handleBookingRequested.ts | 2 +- .../bookings/lib/handleCancelBooking.ts | 51 +- .../bookings/lib/handleConfirmation.ts | 4 +- .../features/bookings/lib/handleNewBooking.ts | 308 ++++- .../checkActiveBookingsLimitForBooker.ts | 4 +- .../checkBookingAndDurationLimits.ts | 4 +- .../checkIfBookerEmailIsBlocked.ts | 52 +- .../handleNewBooking/ensureAvailableUsers.ts | 2 +- .../handleNewBooking/getEventTypesFromDB.ts | 3 +- .../getRequiresConfirmationFlags.ts | 4 +- .../handleNewBooking/loadAndValidateUsers.ts | 9 +- .../lib/handleNewBooking/loadUsers.ts | 2 +- .../originalRescheduledBookingUtils.ts | 2 +- .../test/booking-validations.test.ts | 822 ++++++++++- .../test/buildDryRunBooking.test.ts | 1 + .../test/buildEventForTeamEventType.test.ts | 1 + .../test/email-verification-booking.test.ts | 12 +- .../test/getNewBookingHandler.ts | 15 +- .../test/handleNewRecurringBooking.test.ts | 315 +---- .../test/spam-booking.integration-test.ts | 873 ++++++++++++ .../bookings/lib/handleNewRecurringBooking.ts | 35 +- .../handleSeats/cancel/cancelAttendeeSeat.ts | 8 +- .../bookings/lib/handleSeats/handleSeats.ts | 2 +- .../lib/lastAttendeeDeleteBooking.ts | 2 +- .../filterHostsBySameRoundRobinHost.test.ts | 2 +- .../filterHostsBySameRoundRobinHost.ts | 2 +- ...QualifiedHostsWithDelegationCredentials.ts | 2 +- packages/features/bookings/lib/index.ts | 1 + .../lib/interfaces/IBookingCancelService.ts | 12 + .../bookings/lib/payment/getBooking.ts | 2 +- .../lib/payment/handleNoShowFee.test.ts | 14 +- .../bookings/lib/payment/handleNoShowFee.ts | 6 +- .../processNoShowFeeOnCancellation.test.ts | 4 +- .../payment/processNoShowFeeOnCancellation.ts | 2 +- .../repositories/BookingRepository.test.ts} | 2 +- .../repositories/BookingRepository.ts} | 130 +- .../bookings/services/BookingAccessService.ts | 98 ++ packages/features/bookings/types.ts | 12 +- .../bot-detection/BotDetectionService.test.ts | 2 +- .../bot-detection/BotDetectionService.ts | 2 +- .../busyTimes/lib}/getBusyTimesFromLimits.ts | 11 +- .../busyTimes/services}/getBusyTimes.test.ts | 0 .../busyTimes/services}/getBusyTimes.ts | 7 +- .../lib/getShouldServeCache.test.ts | 92 ++ .../calendar-cache/lib/getShouldServeCache.ts | 2 +- .../GoogleCalendarSubscription.adapter.ts | 22 +- .../GoogleCalendarSubscriptionAdapter.test.ts | 124 +- .../lib/cache/CalendarCacheEventRepository.ts | 4 +- .../lib/cache/CalendarCacheEventService.ts | 1 + .../CalendarCacheEventRepository.test.ts | 4 +- .../CalendarCacheEventService.test.ts | 7 +- .../lib/sync/CalendarSyncService.ts | 2 +- .../features/calendar-view/LargeCalendar.tsx | 45 +- .../lib/getConnectedDestinationCalendars.ts | 2 +- .../weeklyview/components/Calendar.tsx | 3 +- .../calendars/weeklyview/state/store.ts | 6 +- .../calendars/weeklyview/types/state.ts | 4 + packages/features/conferencing/README.md | 2 +- .../conferencing/lib}/videoClient.ts | 43 +- .../credentials/deleteCredential.test.ts | 6 +- .../repositories/CredentialRepository.ts} | 3 +- .../crmManager}/crmScheduler.ts | 0 packages/features/data-table/GUIDE.md | 2 +- .../__tests__/filterSegments/create.test.ts | 6 +- .../__tests__/filterSegments/delete.test.ts | 6 +- .../__tests__/filterSegments/get.test.ts | 6 +- .../__tests__/filterSegments/update.test.ts | 6 +- .../data-table/hooks/useDebouncedWidth.ts | 4 +- .../data-table/repositories}/filterSegment.ts | 0 .../repositories}/filterSegment.type.ts | 0 .../DelegationCredentialRepository.test.ts} | 8 +- .../DelegationCredentialRepository.ts} | 3 +- .../InstantBookingCreateService.container.ts | 14 - .../RecurringBookingService.container.ts | 29 - .../InstantBookingCreateService.module.ts | 11 - .../modules/RecurringBookingService.module.ts | 12 - .../features/di/containers/BookingLimits.ts | 2 +- packages/features/di/containers/BusyTimes.ts | 2 +- .../features/di/containers/InsightsBooking.ts | 4 +- .../features/di/containers/InsightsRouting.ts | 4 +- packages/features/di/modules/Booking.ts | 2 +- packages/features/di/modules/BusyTimes.ts | 5 +- .../features/di/modules/CheckBookingLimits.ts | 2 +- packages/features/di/modules/EventType.ts | 2 +- .../features/di/modules/InsightsBooking.ts | 4 +- .../features/di/modules/InsightsRouting.ts | 4 +- packages/features/di/modules/Membership.ts | 2 +- packages/features/di/modules/Team.ts | 2 +- packages/features/di/modules/User.ts | 2 +- packages/features/di/tokens.ts | 7 +- .../features/di/watchlist/Watchlist.tokens.ts | 12 + .../containers/SpamCheckService.container.ts | 11 + .../di/watchlist/containers/watchlist.ts | 66 + .../di/watchlist/modules/Watchlist.module.ts | 36 + .../ee/api-keys/lib}/autoLock.test.ts | 2 +- .../ee/api-keys}/lib/autoLock.ts | 4 +- .../billing/api/webhook/_invoice.paid.org.ts | 40 +- .../ee/billing/billing-service-factory.ts | 2 +- .../ee/billing/credit-service.test.ts | 10 +- .../features/ee/billing/credit-service.ts | 67 +- .../billing/repository/IBillingRepository.ts | 7 + ...test.ts => stripe-billing-service.test.ts} | 2 +- ...g-service.ts => stripe-billing-service.ts} | 47 +- .../ee/dsync/lib}/assignValueToUser.test.ts | 1 - .../ee/dsync/lib}/assignValueToUser.ts | 20 +- .../ee/dsync/lib/handleGroupEvents.ts | 2 +- .../features/ee/dsync/lib/handleUserEvents.ts | 4 +- .../ee/dsync/lib/inviteExistingUserToOrg.ts | 2 +- .../ee/dsync/lib/removeUserFromOrg.ts | 2 +- .../lib/users/createUsersAndConnectToOrg.ts | 4 +- .../lib/users/inviteExistingUserToOrg.ts | 2 +- .../lib/ImpersonationProvider.ts | 2 +- .../__mocks__/organizationMock.ts} | 4 +- .../components/AdminOnboardingHandover.tsx | 81 +- .../components/CreateANewOrganizationForm.tsx | 50 +- .../lib/OrganizationPaymentService.test.ts | 12 +- .../lib/OrganizationPaymentService.ts | 45 +- .../lib/OrganizationPermissionService.ts | 7 +- .../lib/ensureOrganizationIsReviewed.ts | 2 +- .../lib}/getBookerBaseUrlSync.ts | 0 .../organizations/lib/getBookerUrlServer.ts} | 4 +- .../ee/organizations/lib}/getBrand.ts | 0 .../organizations/lib/getTeamUrlSync.test.ts} | 7 +- .../ee/organizations/lib/getTeamUrlSync.ts} | 3 +- .../organizations/lib/onboardingStore.test.ts | 389 ++++++ .../ee/organizations/lib/onboardingStore.ts | 89 +- .../ee/organizations/lib/orgDomains.ts | 4 +- .../createOrganizationFromOnboarding.test.ts | 540 -------- .../createOrganizationFromOnboarding.ts | 567 -------- .../lib/server/orgCreationUtils.test.ts | 116 ++ .../lib/server/orgCreationUtils.ts | 13 +- .../onboarding/BaseOnboardingService.ts | 605 ++++++++ .../BillingEnabledOrgOnboardingService.ts | 270 ++++ .../IOrganizationOnboardingService.ts | 32 + .../OrganizationOnboardingFactory.ts | 53 + .../onboarding/OrganizationOnboardingSetup.md | 170 +++ .../onboarding/SelfHostedOnboardingService.ts | 230 +++ .../__tests__/BaseOnboardingService.test.ts | 249 ++++ ...BillingEnabledOrgOnboardingService.test.ts | 814 +++++++++++ .../OrganizationOnboardingFactory.test.ts | 204 +++ .../SelfHostedOnboardingService.test.ts | 523 +++++++ .../lib/service/onboarding/index.ts | 5 + .../lib/service/onboarding/types.ts | 109 ++ .../features/ee/organizations/lib/utils.ts | 42 + .../pages/components/OtherTeamList.tsx | 2 +- .../pages/components/OtherTeamsListing.tsx | 2 +- .../settings/other-team-members-view.tsx | 5 +- .../organizations/pages/settings/privacy.tsx | 34 +- .../OrganizationRepository.test.ts} | 7 +- .../repositories/OrganizationRepository.ts} | 7 +- .../ee/organizations/types/schemas.ts | 2 + .../ee/payments/components/Payment.tsx | 2 +- .../features/ee/payments/pages/payment.tsx | 2 +- .../roundRobinManualReassignment.test.ts | 2 +- .../roundRobinManualReassignment.ts | 11 +- .../roundRobinReassignment.test.ts | 2 +- .../ee/round-robin/roundRobinReassignment.ts | 8 +- .../validateRoundRobinSlotAvailability.ts | 90 ++ packages/features/ee/sso/lib/sso.ts | 2 +- .../components/MakeTeamPrivateSwitch.tsx | 1 - .../components/TeamAvailabilityTimes.tsx | 2 +- .../ee/teams/components/TeamEventTypeForm.tsx | 2 +- .../ee/teams/components/TeamListItem.tsx | 2 +- .../deleteWorkflowRemindersOfRemovedMember.ts | 2 +- packages/features/ee/teams/lib/queries.ts | 6 +- .../ee/teams/pages/team-profile-view.tsx | 2 +- .../ee/teams/pages/team-settings-view.tsx | 15 +- .../repositories/TeamRepository.test.ts} | 176 ++- .../ee/teams/repositories/TeamRepository.ts} | 51 +- .../services}/teamService.alternative.test.ts | 8 +- .../services}/teamService.integration-test.ts | 2 +- .../ee/teams/services}/teamService.test.ts | 14 +- .../ee/teams/services}/teamService.ts | 8 +- .../workflows/api/scheduleEmailReminders.ts | 2 +- .../ee/workflows/api/scheduleSMSReminders.ts | 2 +- .../workflows/components/AddActionDialog.tsx | 15 +- .../components/WorkflowDetailsPage.tsx | 81 +- .../components/WorkflowStepContainer.tsx | 362 ++--- .../ee/workflows/lib/actionHelperFunctions.ts | 13 +- .../features/ee/workflows/lib/constants.ts | 14 + .../ee/workflows/lib/getAllWorkflows.ts | 34 +- .../features/ee/workflows/lib/getOptions.ts | 31 +- .../lib/reminders/aiPhoneCallManager.ts | 2 +- .../lib/reminders/emailReminderManager.ts | 155 ++- .../lib/reminders/reminderScheduler.ts | 168 ++- .../lib/reminders/smsReminderManager.ts | 8 +- .../lib/reminders/whatsappReminderManager.ts | 4 +- .../lib/repository/workflowReminder.ts | 16 + .../lib/service/WorkflowService.test.ts | 209 +++ .../workflows/lib/service/WorkflowService.ts | 234 ++++ .../features/ee/workflows/pages/index.tsx | 2 +- .../features/ee/workflows/pages/workflow.tsx | 58 +- .../repositories/WorkflowRepository.test.ts} | 4 +- .../repositories/WorkflowRepository.ts} | 416 +++++- packages/features/embed/lib/EmbedTabs.tsx | 2 +- packages/features/embed/lib/hooks/index.tsx | 2 +- .../components/ChildrenEventTypeSelect.tsx | 2 +- .../components/CreateEventTypeDialog.tsx | 2 +- .../components/CreateEventTypeForm.tsx | 2 +- .../components/tabs/apps/EventAppsTab.tsx | 20 +- .../MaxActiveBookingsPerBookerController.tsx | 4 +- .../tabs/workflows/EventWorkfowsTab.tsx | 7 +- .../eventtypes}/hooks/useCreateEventType.ts | 0 .../features/eventtypes/lib/defaultEvents.ts | 1 + .../eventtypes/lib/getEventTypeById.ts | 6 +- .../eventtypes/lib/getEventTypesByViewer.ts | 12 +- .../features/eventtypes/lib/getPublicEvent.ts | 4 +- .../repositories/EventRepository.ts} | 0 .../repositories}/eventTypeRepository.ts | 66 +- .../flags/components/AssignFeatureSheet.tsx | 208 +++ .../flags/components/FlagAdminList.tsx | 72 +- packages/features/flags/config.ts | 1 + .../features/flags/features.repository.ts | 32 + packages/features/flags/hooks/index.ts | 1 + .../features/form-builder/FormBuilder.tsx | 1 + packages/features/form-builder/schema.ts | 17 - packages/features/handleMarkNoShow.ts | 6 +- .../repositories}/hashedLinkRepository.ts | 0 .../hashedLink/services}/hashedLinkService.ts | 8 +- .../components/routing/RoutedToPerPeriod.tsx | 14 +- .../insights/server/routing-events.ts | 2 +- .../services}/InsightsBookingBaseService.ts | 11 +- .../services}/InsightsBookingDIService.ts | 0 ...InsightsBookingService.integration-test.ts | 2 +- .../services}/InsightsRoutingBaseService.ts | 3 +- .../services}/InsightsRoutingDIService.ts | 0 ...InsightsRoutingService.integration-test.ts | 2 +- .../handleInstantMeeting.test.ts | 19 +- .../instant-meeting/handleInstantMeeting.ts | 20 +- .../repositories/MembershipRepository.ts} | 42 +- .../membership/services}/membershipService.ts | 3 +- .../notifications/sendNotification.ts | 24 +- packages/features/pbac/README.md | 708 +++++----- .../pbac/domain/types/permission-registry.ts | 42 +- .../repositories/PermissionRepository.ts | 46 +- .../PermissionRepository.integration-test.ts | 324 +++++ .../pbac}/lib/entityPermissionUtils.server.ts | 0 .../permission-check.service.test.ts | 4 +- .../pbac/services/permission-check.service.ts | 2 +- .../profile/lib}/checkRegularUsername.ts | 5 +- .../profile/lib}/checkUsername.ts | 2 +- .../lib/createAProfileForAnExistingUser.ts | 13 +- .../features/profile/lib/getBranding.test.ts | 193 +++ packages/features/profile/lib/getBranding.ts | 98 ++ .../profile}/lib/hideBranding.ts | 44 +- .../repositories/ProfileRepository.ts} | 31 +- .../routing-forms/lib/getRoutedUrl.test.ts | 4 +- .../routing-forms/lib/getRoutedUrl.ts | 2 +- .../components/DateOverrideInputDialog.tsx | 11 +- .../schedules/components/DateOverrideList.tsx | 16 +- .../schedules/components/ScheduleListItem.tsx | 7 +- .../schedules}/lib/date-ranges.test.ts | 0 .../schedules}/lib/date-ranges.ts | 0 .../schedules}/lib/slots.test.ts | 2 +- .../{ => features/schedules}/lib/slots.ts | 10 +- packages/features/shell/SideBar.tsx | 2 +- packages/features/tasker/tasker.ts | 3 + .../tasks/analytics/sendAnalyticsEvent.ts | 2 +- packages/features/tasker/tasks/index.ts | 4 + .../tasker/tasks/scanWorkflowBody.test.ts | 6 +- .../formSubmissionValidation.ts | 96 ++ .../triggerFormSubmittedNoEventWebhook.ts | 60 +- ...riggerFormSubmittedNoEventWorkflow.test.ts | 177 +++ .../triggerFormSubmittedNoEventWorkflow.ts | 58 + .../components/AvailabilitySliderTable.tsx | 2 +- .../timezone-buddy/components/TimeDial.tsx | 2 +- .../components/LargeCalendar.tsx | 1 + .../components/UserTable/UserListTable.tsx | 6 +- .../features/users/lib/UserListTableUtils.ts | 93 ++ .../{userDeletionService.ts => deleteUser.ts} | 0 .../repositories/UserRepository.test.ts} | 3 +- .../users/repositories/UserRepository.ts} | 151 +- .../services}/userCreationService.test.ts | 60 +- .../users/services}/userCreationService.ts | 14 +- packages/features/watchlist/lib/dto/index.ts | 22 + .../features/watchlist/lib/dto/mappers.ts | 119 ++ packages/features/watchlist/lib/dto/types.ts | 106 ++ .../watchlist/lib/facade/WatchlistFeature.ts | 47 + .../checkIfFreeEmailDomain.test.ts | 63 + .../checkIfFreeEmailDomain.ts | 30 + .../freeEmailDomainCheck/freeEmailDomains.ts | 0 .../lib/interface/IAuditRepository.ts | 33 + .../watchlist/lib/interface/IAuditService.ts | 17 + .../lib/interface/IBlockingService.ts | 11 + .../lib/interface/IWatchlistRepositories.ts | 79 ++ .../lib/interface/IWatchlistService.ts | 9 + .../repository/GlobalWatchlistRepository.ts | 136 ++ .../OrganizationWatchlistRepository.ts | 143 ++ .../PrismaWatchlistAuditRepository.ts | 93 ++ .../lib/service/GlobalBlockingService.test.ts | 196 +++ .../lib/service/GlobalBlockingService.ts | 49 + .../OrganizationBlockingService.test.ts | 175 +++ .../service/OrganizationBlockingService.ts | 59 + .../watchlist/lib/service/SpamCheckService.ts | 63 + .../lib/service/WatchlistAuditService.ts | 44 + .../lib/service/WatchlistService.test.ts | 373 +++++ .../watchlist/lib/service/WatchlistService.ts | 87 ++ .../features/watchlist/lib/telemetry/index.ts | 3 + .../watchlist/lib/telemetry/no-op-span.ts | 9 + .../watchlist/lib/telemetry/sentry-span.ts | 11 + .../features/watchlist/lib/telemetry/types.ts | 11 + packages/features/watchlist/lib/types.ts | 61 + .../watchlist/lib/utils/normalization.test.ts | 97 ++ .../watchlist/lib/utils/normalization.ts | 93 ++ ...k-if-email-in-watchlist.controller.test.ts | 247 ++++ .../check-if-email-in-watchlist.controller.ts | 70 +- ...ck-if-users-are-blocked.controller.test.ts | 284 ++++ .../check-if-users-are-blocked.controller.ts | 74 +- ...list-all-system-entries.controller.test.ts | 327 +++++ .../list-all-system-entries.controller.ts | 31 + .../features/watchlist/watchlist.model.ts | 22 - .../watchlist.repository.interface.ts | 5 - .../watchlist/watchlist.repository.mock.ts | 19 - .../watchlist/watchlist.repository.ts | 95 -- packages/features/webhooks/lib/dto/types.ts | 2 +- .../lib/factory/FormPayloadBuilder.ts | 2 +- .../webhooks/lib/interface/repository.ts | 45 +- .../lib/repository/WebhookRepository.ts | 195 ++- .../features/webhooks/lib/scheduleTrigger.ts | 9 +- packages/features/webhooks/lib/sendPayload.ts | 7 +- .../lib/service/FormWebhookService.ts | 2 +- .../features/webhooks/pages/webhooks-view.tsx | 2 +- packages/lib/CalEventParser.ts | 2 +- .../lib => lib/bookings}/SystemField.ts | 0 .../getLabelValueMapFromResponses.ts | 4 +- packages/lib/csvUtils.ts | 93 -- packages/lib/dbReadResponseSchema.ts | 18 + packages/lib/emailSchema.ts | 10 + packages/lib/errorCodes.ts | 4 + packages/lib/eslint.config.mjs | 5 + .../checkIfFreeEmailDomain.test.ts | 41 - .../checkIfFreeEmailDomain.ts | 14 - packages/lib/restrictionSchedule.ts | 16 - .../transformers/getScheduleListItemData.ts | 26 + .../repository/PrismaAgentRepository.ts | 49 +- .../repository/PrismaApiKeyRepository.ts | 19 + .../repository/PrismaRoutingFormRepository.ts | 45 + .../PrismaRoutingFormRepositoryInterface.ts | 2 + .../server/repository/TeamRepository.test.ts | 141 -- .../repository/bookingReport.interface.ts | 25 + .../server/repository/bookingReport.test.ts | 130 ++ .../lib/server/repository/bookingReport.ts | 43 + .../lib/server/repository/formResponse.ts | 34 + .../repository/organizationOnboarding.ts | 4 + .../server/repository/watchlist.interface.ts | 67 + .../server/repository/watchlist.repository.ts | 211 +++ packages/lib/server/repository/webhook.ts | 194 --- .../server/repository/workflowRelations.ts | 94 ++ .../lib/server/repository/workflowStep.ts | 22 + packages/lib/server/service/ApiKeyService.ts | 47 + packages/lib/server/service/workflows.ts | 131 -- .../service/attribute/server/getAttributes.ts | 32 +- packages/lib/tsconfig.json | 2 +- packages/platform/atoms/CHANGELOG.md | 12 + .../availability/AvailabilitySettings.tsx | 11 +- .../AvailabilitySettingsPlatformWrapper.tsx | 1 + .../wrappers/CalendarViewPlatformWrapper.tsx | 107 +- .../create-schedule/CreateScheduleForm.tsx | 108 ++ .../platform/atoms/create-schedule/index.tsx | 1 + .../CreateSchedulePlatformWrapper.tsx | 69 + .../CreateEventTypePlatformWrapper.tsx | 2 +- .../wrappers/EventTypeWebWrapper.tsx | 10 +- .../hooks/schedules/useAtomCreateSchedule.ts | 48 + .../schedules/useAtomDuplicateSchedule.ts | 45 + .../hooks/schedules/useAtomGetAllSchedules.ts | 30 + .../schedules/useEnsureDefaultSchedule.ts | 22 + packages/platform/atoms/index.ts | 6 + .../platform/atoms/list-schedules/index.ts | 1 + .../wrappers/ListSchedulesPlatformWrapper.tsx | 129 ++ packages/platform/atoms/package.json | 2 +- .../[scheduleId].tsx} | 5 + .../base/src/pages/availability/index.tsx | 23 + .../examples/base/src/pages/booking.tsx | 2 +- .../availability-settings-atom.e2e.ts | 13 +- packages/platform/libraries/app-store.ts | 2 +- packages/platform/libraries/bookings.ts | 9 +- packages/platform/libraries/conferencing.ts | 2 +- packages/platform/libraries/event-types.ts | 2 + packages/platform/libraries/index.ts | 13 +- packages/platform/libraries/repositories.ts | 10 +- packages/platform/libraries/schedules.ts | 15 + packages/platform/libraries/slots.ts | 4 +- .../2024-08-13/inputs/cancel-booking.input.ts | 5 + .../booker-active-booking-limit.input.ts | 25 + .../inputs/create-event-type.input.ts | 54 +- .../inputs/email-settings.input.ts | 20 + .../event-types_2024_06_14/inputs/index.ts | 1 + .../inputs/update-event-type.input.ts | 63 +- ...eRecurrenceAndBookerActiveBookingsLimit.ts | 40 + .../booker-active-bookings-limit.output.ts | 21 + .../outputs/event-type.output.ts | 26 +- .../RequiresOneOfPropertiesWhenNotDisabled.ts | 49 + .../migration.sql | 26 + .../migration.sql | 2 + .../migration.sql | 5 + .../migration.sql | 9 + .../migration.sql | 2 + .../migration.sql | 3 + .../migration.sql | 9 + .../migration.sql | 49 + .../migration.sql | 2 + .../migration.sql | 14 + .../migration.sql | 4 + packages/prisma/schema.prisma | 131 +- packages/prisma/zod-utils.ts | 9 +- .../server/middlewares/sessionMiddleware.ts | 9 +- .../apps/routing-forms/deleteForm.handler.ts | 2 +- .../routing-forms/formMutation.handler.ts | 2 +- .../apps/routing-forms/formQuery.handler.ts | 2 +- .../apps/routing-forms/forms.handler.ts | 2 +- .../getAttributesForTeam.handler.ts | 2 +- .../getResponseWithFormFields.handler.ts | 4 +- .../loggedInViewer/stripeCustomer.handler.ts | 2 +- ...IfUserEmailVerificationRequired.handler.ts | 11 + .../routers/publicViewer/event.handler.ts | 2 +- .../server/routers/viewer/admin/_router.ts | 21 + .../admin/assignFeatureToTeam.handler.ts | 36 + .../admin/assignFeatureToTeam.schema.ts | 8 + .../admin/getTeamsForFeature.handler.ts | 80 ++ .../viewer/admin/getTeamsForFeature.schema.ts | 10 + .../admin/unassignFeatureFromTeam.handler.ts | 30 + .../admin/unassignFeatureFromTeam.schema.ts | 8 + .../admin/whitelistUserWorkflows.handler.ts | 2 +- .../viewer/aiVoiceAgent/listCalls.handler.ts | 2 +- .../apps/appCredentialsByType.handler.ts | 2 +- ...amMembersMatchingAttributeLogic.handler.ts | 2 +- .../viewer/availability/list.handler.ts | 5 +- .../availability/schedule/create.handler.ts | 4 +- .../schedule/duplicate.handler.ts | 4 +- .../team/listTeamAvailability.handler.ts | 6 +- .../routers/viewer/bookings/_router.tsx | 11 + .../viewer/bookings/addGuests.handler.ts | 41 +- .../viewer/bookings/confirm.handler.ts | 4 +- .../viewer/bookings/editLocation.handler.ts | 6 +- .../routers/viewer/bookings/get.handler.ts | 15 +- .../bookings/reportBooking.handler.test.ts | 344 +++++ .../viewer/bookings/reportBooking.handler.ts | 137 ++ .../viewer/bookings/reportBooking.schema.ts | 11 + .../bookings/requestReschedule.handler.ts | 6 +- .../calVideo/getCalVideoRecordings.handler.ts | 2 +- ...ownloadLinkOfCalVideoRecordings.handler.ts | 2 +- .../viewer/credits/buyCredits.handler.ts | 8 +- .../credits/downloadExpenseLog.handler.ts | 2 +- .../viewer/credits/getAllCredits.handler.ts | 4 +- .../delegationCredential/add.handler.ts | 2 +- .../delegationCredential/delete.handler.ts | 2 +- .../getAffectedMembersForDisable.handler.ts | 4 +- .../delegationCredential/list.handler.ts | 2 +- .../toggleEnabled.handler.ts | 4 +- .../delegationCredential/update.handler.ts | 2 +- .../viewer/delegationCredential/utils.ts | 2 +- .../__tests__/getUserEventGroups.test.ts | 64 +- .../viewer/eventTypes/__tests__/util.test.ts | 161 ++- .../routers/viewer/eventTypes/_router.ts | 24 +- .../eventTypes/getActiveOnOptions.handler.ts | 275 ++++ .../eventTypes/getActiveOnOptions.schema.ts | 8 + .../getEventTypesFromGroup.handler.ts | 2 +- .../eventTypes/getHashedLink.handler.ts | 4 +- .../eventTypes/getHashedLinks.handler.ts | 4 +- .../getTeamAndEventTypeOptions.handler.ts | 227 --- .../getTeamAndEventTypeOptions.schema.ts | 10 - .../eventTypes/getUserEventGroups.handler.ts | 4 +- .../viewer/eventTypes/heavy/create.handler.ts | 2 +- .../heavy/duplicate.handler.test.ts | 6 +- .../eventTypes/heavy/duplicate.handler.ts | 2 +- .../viewer/eventTypes/heavy/update.handler.ts | 6 +- .../eventTypes/usecases/EventGroupBuilder.ts | 4 +- .../server/routers/viewer/eventTypes/util.ts | 20 +- .../viewer/eventTypes/utils/transformUtils.ts | 4 +- .../routers/viewer/filterSegments/_router.tsx | 2 +- .../viewer/filterSegments/create.handler.ts | 4 +- .../viewer/filterSegments/delete.handler.ts | 4 +- .../viewer/filterSegments/list.handler.ts | 4 +- .../filterSegments/preference.handler.ts | 4 +- .../viewer/filterSegments/update.handler.ts | 4 +- .../routers/viewer/me/deleteMe.handler.ts | 2 +- .../server/routers/viewer/me/get.handler.ts | 5 +- .../routers/viewer/me/myStats.handler.ts | 2 +- .../routers/viewer/me/platformMe.handler.ts | 2 +- .../viewer/me/updateProfile.handler.ts | 6 +- .../routers/viewer/me/updateProfile.schema.ts | 1 + .../__tests__/createTeams.handler.test.ts | 52 +- .../routers/viewer/organizations/_router.tsx | 29 + .../viewer/organizations/adminGet.handler.ts | 2 +- .../organizations/adminVerify.handler.ts | 2 +- .../organizations/bulkDeleteUsers.handler.ts | 2 +- .../viewer/organizations/create.handler.ts | 46 +- .../organizations/createSelfHosted.handler.ts | 46 +- .../organizations/createTeams.handler.ts | 6 +- .../createWatchlistEntry.handler.test.ts | 303 ++++ .../createWatchlistEntry.handler.ts | 86 ++ .../createWatchlistEntry.schema.ts | 11 + .../createWithPaymentIntent.handler.ts | 25 +- .../createWithPaymentIntent.schema.ts | 8 + .../deleteWatchlistEntry.handler.test.ts | 194 +++ .../deleteWatchlistEntry.handler.ts | 77 ++ .../deleteWatchlistEntry.schema.ts | 7 + .../viewer/organizations/getBrand.handler.ts | 2 +- .../getWatchlistEntryDetails.handler.test.ts | 357 +++++ .../getWatchlistEntryDetails.handler.ts | 85 ++ .../getWatchlistEntryDetails.schema.ts | 7 + .../intentToCreateOrg.handler.test.ts | 171 ++- .../intentToCreateOrg.handler.ts | 46 +- .../organizations/intentToCreateOrg.schema.ts | 18 +- .../viewer/organizations/list.handler.ts | 2 +- .../organizations/listMembers.handler.test.ts | 2 +- .../organizations/listMembers.handler.ts | 2 +- .../listOtherTeamMembers.handler.ts | 4 +- .../organizations/listOtherTeams.handler.ts | 2 +- .../listWatchlistEntries.handler.ts | 89 ++ .../listWatchlistEntries.schema.ts | 16 + .../organizations/updateUser.handler.ts | 2 +- .../viewer/payments/chargeCard.handler.ts | 2 +- .../server/routers/viewer/pbac/_router.tsx | 52 +- ...rsMatchingAttributeLogicOfRoute.handler.ts | 6 +- .../viewer/routing-forms/response.handler.ts | 2 + .../slots/handleNotificationWhenNoSlots.ts | 4 +- .../viewer/slots/isAvailable.handler.ts | 2 +- .../trpc/server/routers/viewer/slots/util.ts | 20 +- .../viewer/teams/acceptOrLeave.handler.ts | 2 +- .../viewer/teams/changeMemberRole.handler.ts | 2 +- .../routers/viewer/teams/create.handler.ts | 2 +- .../viewer/teams/createInvite.handler.ts | 2 +- .../routers/viewer/teams/delete.handler.ts | 2 +- .../routers/viewer/teams/get.handler.test.ts | 4 +- .../routers/viewer/teams/get.handler.ts | 2 +- .../teams/getMemberAvailability.handler.ts | 2 +- .../viewer/teams/hasActiveTeamPlan.handler.ts | 2 +- .../viewer/teams/hasTeamPlan.handler.ts | 2 +- .../inviteMember/inviteMember.handler.ts | 2 +- .../viewer/teams/inviteMember/utils.ts | 6 +- .../teams/inviteMemberByToken.handler.ts | 2 +- .../viewer/teams/legacyListMembers.handler.ts | 2 +- .../routers/viewer/teams/list.handler.ts | 2 +- .../viewer/teams/listMembers.handler.ts | 6 +- .../routers/viewer/teams/publish.handler.ts | 2 +- .../removeMember/BaseRemoveMemberService.ts | 2 +- .../LegacyRemoveMemberService.test.ts | 6 +- .../__tests__/PBACRemoveMemberService.test.ts | 4 +- .../viewer/teams/resendInvitation.handler.ts | 19 +- .../getRoundRobinHostsToReasign.handler.ts | 2 +- .../roundRobinManualReassign.handler.ts | 2 +- .../roundRobin/roundRobinReassign.handler.ts | 2 +- .../viewer/teams/skipTeamTrials.handler.ts | 2 +- .../viewer/teams/skipTeamTrials.test.ts | 4 +- .../routers/viewer/teams/update.handler.ts | 2 +- .../routers/viewer/webhook/get.handler.ts | 5 +- .../viewer/webhook/getByViewer.handler.ts | 8 +- .../workflows/activateEventType.handler.ts | 4 +- .../viewer/workflows/delete.handler.test.ts | 4 +- .../viewer/workflows/delete.handler.ts | 2 +- .../viewer/workflows/filteredList.handler.tsx | 2 +- .../routers/viewer/workflows/get.handler.ts | 2 +- .../routers/viewer/workflows/get.schema.ts | 4 +- .../workflows/getAllActiveWorkflows.schema.ts | 3 +- .../workflows/getVerifiedEmails.handler.ts | 2 +- .../workflows/getVerifiedNumbers.handler.ts | 2 +- .../routers/viewer/workflows/list.handler.ts | 219 +-- .../routers/viewer/workflows/list.schema.ts | 11 +- .../viewer/workflows/update.handler.ts | 547 +++----- .../routers/viewer/workflows/update.schema.ts | 69 +- .../server/routers/viewer/workflows/util.ts | 143 +- .../ui/components/avatar/UserAvatarGroup.tsx | 2 +- .../avatar/UserAvatarGroupWithOrg.tsx | 2 +- .../editor/plugins/ToolbarPlugin.tsx | 16 +- .../navigation/tabs/VerticalTabItem.tsx | 1 + scripts/reassign-attributes.ts | 41 + tests/libs/__mocks__/videoClient.ts | 4 +- 879 files changed, 29830 insertions(+), 7565 deletions(-) create mode 100644 .yarn/versions/13de130d.yml create mode 100644 .yarn/versions/3a60c02f.yml create mode 100644 .yarn/versions/492e4e82.yml create mode 100644 .yarn/versions/c0a99150.yml create mode 100644 .yarn/versions/e11ed2da.yml create mode 100644 apps/api/v2/src/lib/modules/booking-cancel.module.ts create mode 100644 apps/api/v2/src/lib/modules/instant-booking.module.ts create mode 100644 apps/api/v2/src/lib/modules/recurring-booking.module.ts create mode 100644 apps/api/v2/src/lib/modules/regular-booking.module.ts create mode 100644 apps/api/v2/src/lib/services/booking-cancel.service.ts create mode 100644 apps/api/v2/src/lib/services/instant-booking-create.service.ts create mode 100644 apps/api/v2/src/lib/services/recurring-booking.service.ts create mode 100644 apps/api/v2/src/lib/services/regular-booking.service.ts rename apps/api/v2/src/modules/auth/guards/workflows/{is-workflow-in-team.ts => is-event-type-workflow-in-team.ts} (82%) create mode 100644 apps/api/v2/src/modules/auth/guards/workflows/is-routing-form-workflow-in-team.ts rename apps/api/v2/src/modules/workflows/inputs/{create-workflow.input.ts => create-event-type-workflow.input.ts} (86%) create mode 100644 apps/api/v2/src/modules/workflows/inputs/create-form-workflow.ts rename apps/api/v2/src/modules/workflows/inputs/{update-workflow.input.ts => update-event-type-workflow.input.ts} (64%) create mode 100644 apps/api/v2/src/modules/workflows/inputs/update-form-workflow.input.ts rename apps/api/v2/src/modules/workflows/outputs/{workflow.output.ts => base-workflow.output.ts} (50%) create mode 100644 apps/api/v2/src/modules/workflows/outputs/event-type-workflow.output.ts create mode 100644 apps/api/v2/src/modules/workflows/outputs/routing-form-workflow.output.ts create mode 100644 apps/api/v2/src/modules/workflows/services/team-event-type-workflows.service.ts rename apps/api/v2/src/modules/workflows/services/{team-workflows.service.ts => team-routing-form-workflows.service.ts} (50%) create mode 100644 apps/web/app/(booking-page-wrapper)/booking-successful/[uid]/page.tsx create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/FingerprintAnimation.tsx create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInModal.tsx create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInView.tsx create mode 100644 apps/web/app/(use-page-wrapper)/settings/organizations/new/resume/page.tsx create mode 100644 apps/web/components/dialog/ReportBookingDialog.tsx create mode 100644 apps/web/modules/bookings/components/BookingsCalendar.tsx create mode 100644 apps/web/modules/bookings/components/BookingsList.tsx create mode 100644 apps/web/modules/bookings/types.ts rename apps/web/modules/bookings/views/{bookings-listing-view.tsx => bookings-view.tsx} (82%) create mode 100644 apps/web/modules/settings/organizations/new/resume-view.tsx create mode 100644 apps/web/modules/settings/organizations/privacy/blocklist-table.tsx create mode 100644 apps/web/modules/settings/organizations/privacy/components/blocklist-entry-details-sheet.tsx create mode 100644 apps/web/modules/settings/organizations/privacy/components/create-blocklist-entry-modal.tsx create mode 100644 apps/web/playwright/organization/organization-creation-flows.e2e.ts delete mode 100644 apps/web/playwright/organization/organization-creation.e2e.ts create mode 100644 docs/images/deploy-northflank.svg create mode 100644 docs/platform/atoms/create-schedule.mdx create mode 100644 docs/platform/atoms/list-schedules.mdx create mode 100644 docs/self-hosting/deployments/northflank.mdx delete mode 100644 infra/README.md delete mode 100644 infra/docker/api/Dockerfile create mode 100644 packages/app-store/getVideoAdapters.ts create mode 100644 packages/app-store/googlecalendar/lib/__tests__/getFreeBusyResult.test.ts create mode 100644 packages/embeds/embed-core/src/embed-iframe/__tests__/utils.test.ts rename packages/{ => features/auth}/lib/getLocaleFromRequest.ts (100%) rename packages/{ => features/auth}/lib/hooks/useLastUsed.tsx (100%) rename packages/{lib => features/auth/signup/utils}/validateUsername.ts (100%) rename packages/{ => features/availability}/lib/getAggregatedAvailability/date-range-utils/filterRedundantDateRanges.test.ts (100%) rename packages/{ => features/availability}/lib/getAggregatedAvailability/date-range-utils/filterRedundantDateRanges.ts (95%) rename packages/{ => features/availability}/lib/getAggregatedAvailability/date-range-utils/mergeOverlappingDateRanges.test.ts (97%) rename packages/{ => features/availability}/lib/getAggregatedAvailability/date-range-utils/mergeOverlappingDateRanges.ts (92%) rename packages/{lib => features/availability/lib/getAggregatedAvailability}/getAggregatedAvailability.test.ts (100%) rename packages/{lib => features/availability/lib/getAggregatedAvailability}/getAggregatedAvailability.ts (87%) create mode 100644 packages/features/bookings/Booker/components/DecoyBookingSuccessCard.tsx create mode 100644 packages/features/bookings/Booker/components/hooks/useDecoyBooking.ts create mode 100644 packages/features/bookings/Booker/utils/areDifferentValidMonths.test.ts create mode 100644 packages/features/bookings/Booker/utils/areDifferentValidMonths.ts create mode 100644 packages/features/bookings/Booker/utils/getPrefetchMonthCount.test.ts create mode 100644 packages/features/bookings/Booker/utils/getPrefetchMonthCount.ts create mode 100644 packages/features/bookings/Booker/utils/isMonthChange.test.ts create mode 100644 packages/features/bookings/Booker/utils/isMonthChange.ts create mode 100644 packages/features/bookings/Booker/utils/isMonthViewPrefetchEnabled.test.ts create mode 100644 packages/features/bookings/Booker/utils/isMonthViewPrefetchEnabled.ts create mode 100644 packages/features/bookings/Booker/utils/isPrefetchNextMonthEnabled.test.ts create mode 100644 packages/features/bookings/Booker/utils/isPrefetchNextMonthEnabled.ts create mode 100644 packages/features/bookings/components/BookingSuccessCard.tsx create mode 100644 packages/features/bookings/di/BookingCancelService.container.ts create mode 100644 packages/features/bookings/di/BookingCancelService.module.ts create mode 100644 packages/features/bookings/di/InstantBookingCreateService.container.ts create mode 100644 packages/features/bookings/di/InstantBookingCreateService.module.ts create mode 100644 packages/features/bookings/di/RecurringBookingService.container.ts create mode 100644 packages/features/bookings/di/RecurringBookingService.module.ts rename packages/features/{di/bookings/containers => bookings/di}/RegularBookingService.container.ts (70%) rename packages/features/{di/bookings/modules => bookings/di}/RegularBookingService.module.ts (63%) rename packages/features/{di/bookings => bookings/di}/tokens.ts (78%) rename packages/{lib => features/bookings}/hooks/useBookerUrl.ts (100%) rename packages/{ => features/bookings}/lib/bookingSuccessRedirect.test.ts (100%) rename packages/{ => features/bookings}/lib/bookingSuccessRedirect.ts (99%) rename packages/{lib/bookings => features/bookings/lib}/buildEventUrlFromBooking.test.ts (96%) rename packages/{lib/bookings => features/bookings/lib}/buildEventUrlFromBooking.ts (92%) rename packages/{lib/intervalLimits/server => features/bookings/lib}/checkBookingLimits.ts (88%) rename packages/{lib/intervalLimits/server => features/bookings/lib}/checkDurationLimits.ts (81%) create mode 100644 packages/features/bookings/lib/client/decoyBookingStore.ts create mode 100644 packages/features/bookings/lib/dto/BookingCancel.d.ts rename packages/{lib/bookings => features/bookings/lib}/getAllUserBookings.ts (100%) rename packages/{lib/server => features/bookings/lib}/getLuckyUser.integration-test.ts (100%) create mode 100644 packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts create mode 100644 packages/features/bookings/lib/interfaces/IBookingCancelService.ts rename packages/{lib/server/repository/booking.test.ts => features/bookings/repositories/BookingRepository.test.ts} (99%) rename packages/{lib/server/repository/booking.ts => features/bookings/repositories/BookingRepository.ts} (90%) create mode 100644 packages/features/bookings/services/BookingAccessService.ts rename packages/{lib/intervalLimits/server => features/busyTimes/lib}/getBusyTimesFromLimits.ts (95%) rename packages/{lib => features/busyTimes/services}/getBusyTimes.test.ts (100%) rename packages/{lib => features/busyTimes/services}/getBusyTimes.ts (98%) create mode 100644 packages/features/calendar-cache/lib/getShouldServeCache.test.ts rename packages/{app-store => features/conferencing/lib}/videoClient.ts (87%) rename packages/{lib/server/repository/credential.ts => features/credentials/repositories/CredentialRepository.ts} (98%) rename packages/{lib/crmManager/tasker => features/crmManager}/crmScheduler.ts (100%) rename packages/{lib/server/repository => features/data-table}/__tests__/filterSegments/create.test.ts (93%) rename packages/{lib/server/repository => features/data-table}/__tests__/filterSegments/delete.test.ts (94%) rename packages/{lib/server/repository => features/data-table}/__tests__/filterSegments/get.test.ts (97%) rename packages/{lib/server/repository => features/data-table}/__tests__/filterSegments/update.test.ts (96%) rename packages/{lib/server/repository => features/data-table/repositories}/filterSegment.ts (100%) rename packages/{lib/server/repository => features/data-table/repositories}/filterSegment.type.ts (100%) rename packages/{lib/server/repository/__tests__/delegationCredential.test.ts => features/delegation-credentials/repositories/DelegationCredentialRepository.test.ts} (96%) rename packages/{lib/server/repository/delegationCredential.ts => features/delegation-credentials/repositories/DelegationCredentialRepository.ts} (98%) delete mode 100644 packages/features/di/bookings/containers/InstantBookingCreateService.container.ts delete mode 100644 packages/features/di/bookings/containers/RecurringBookingService.container.ts delete mode 100644 packages/features/di/bookings/modules/InstantBookingCreateService.module.ts delete mode 100644 packages/features/di/bookings/modules/RecurringBookingService.module.ts create mode 100644 packages/features/di/watchlist/Watchlist.tokens.ts create mode 100644 packages/features/di/watchlist/containers/SpamCheckService.container.ts create mode 100644 packages/features/di/watchlist/containers/watchlist.ts create mode 100644 packages/features/di/watchlist/modules/Watchlist.module.ts rename packages/{lib/__tests__ => features/ee/api-keys/lib}/autoLock.test.ts (99%) rename packages/{ => features/ee/api-keys}/lib/autoLock.ts (98%) rename packages/features/ee/billing/{stripe-billling-service.test.ts => stripe-billing-service.test.ts} (97%) rename packages/features/ee/billing/{stripe-billling-service.ts => stripe-billing-service.ts} (76%) rename packages/{lib/service/attribute/server => features/ee/dsync/lib}/assignValueToUser.test.ts (99%) rename packages/{lib/service/attribute/server => features/ee/dsync/lib}/assignValueToUser.ts (96%) rename packages/{lib/server/repository/__mocks__/organization.ts => features/ee/organizations/__mocks__/organizationMock.ts} (86%) rename packages/{lib/getBookerUrl => features/ee/organizations/lib}/getBookerBaseUrlSync.ts (100%) rename packages/{lib/getBookerUrl/server.ts => features/ee/organizations/lib/getBookerUrlServer.ts} (74%) rename packages/{lib/server => features/ee/organizations/lib}/getBrand.ts (100%) rename packages/{lib/getBookerUrl/client.test.ts => features/ee/organizations/lib/getTeamUrlSync.test.ts} (80%) rename packages/{lib/getBookerUrl/client.ts => features/ee/organizations/lib/getTeamUrlSync.ts} (74%) create mode 100644 packages/features/ee/organizations/lib/onboardingStore.test.ts delete mode 100644 packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts delete mode 100644 packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts create mode 100644 packages/features/ee/organizations/lib/server/orgCreationUtils.test.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/IOrganizationOnboardingService.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingFactory.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingSetup.md create mode 100644 packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/__tests__/BaseOnboardingService.test.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/__tests__/OrganizationOnboardingFactory.test.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/__tests__/SelfHostedOnboardingService.test.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/index.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/types.ts rename packages/{lib/server/repository/organization.test.ts => features/ee/organizations/repositories/OrganizationRepository.test.ts} (94%) rename packages/{lib/server/repository/organization.ts => features/ee/organizations/repositories/OrganizationRepository.ts} (97%) create mode 100644 packages/features/ee/round-robin/utils/validateRoundRobinSlotAvailability.ts rename packages/{lib/server/repository/team.test.ts => features/ee/teams/repositories/TeamRepository.test.ts} (57%) rename packages/{lib/server/repository/team.ts => features/ee/teams/repositories/TeamRepository.ts} (91%) rename packages/{lib/server/service => features/ee/teams/services}/teamService.alternative.test.ts (91%) rename packages/{lib/server/service/__tests__ => features/ee/teams/services}/teamService.integration-test.ts (99%) rename packages/{lib/server/service => features/ee/teams/services}/teamService.test.ts (95%) rename packages/{lib/server/service => features/ee/teams/services}/teamService.ts (97%) create mode 100644 packages/features/ee/workflows/lib/service/WorkflowService.test.ts create mode 100644 packages/features/ee/workflows/lib/service/WorkflowService.ts rename packages/{lib/server/repository/workflow.test.ts => features/ee/workflows/repositories/WorkflowRepository.test.ts} (94%) rename packages/{lib/server/repository/workflow.ts => features/ee/workflows/repositories/WorkflowRepository.ts} (57%) rename packages/{lib => features/eventtypes}/hooks/useCreateEventType.ts (100%) rename packages/{lib/server/repository/event.ts => features/eventtypes/repositories/EventRepository.ts} (100%) rename packages/{lib/server/repository => features/eventtypes/repositories}/eventTypeRepository.ts (95%) create mode 100644 packages/features/flags/components/AssignFeatureSheet.tsx rename packages/{lib/server/repository => features/hashedLink/repositories}/hashedLinkRepository.ts (100%) rename packages/{lib/server/service => features/hashedLink/services}/hashedLinkService.ts (96%) rename packages/{lib/server/service => features/insights/services}/InsightsBookingBaseService.ts (99%) rename packages/{lib/server/service => features/insights/services}/InsightsBookingDIService.ts (100%) rename packages/{lib/server/service/__tests__ => features/insights/services}/InsightsBookingService.integration-test.ts (99%) rename packages/{lib/server/service => features/insights/services}/InsightsRoutingBaseService.ts (99%) rename packages/{lib/server/service => features/insights/services}/InsightsRoutingDIService.ts (100%) rename packages/{lib/server/service/__tests__ => features/insights/services}/InsightsRoutingService.integration-test.ts (99%) rename packages/{lib/server/repository/membership.ts => features/membership/repositories/MembershipRepository.ts} (91%) rename packages/{lib/server/service => features/membership/services}/membershipService.ts (90%) create mode 100644 packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts rename packages/{ => features/pbac}/lib/entityPermissionUtils.server.ts (100%) rename packages/{lib/server => features/profile/lib}/checkRegularUsername.ts (84%) rename packages/{lib/server => features/profile/lib}/checkUsername.ts (83%) rename packages/{ => features/profile}/lib/createAProfileForAnExistingUser.ts (84%) create mode 100644 packages/features/profile/lib/getBranding.test.ts create mode 100644 packages/features/profile/lib/getBranding.ts rename packages/{ => features/profile}/lib/hideBranding.ts (71%) rename packages/{lib/server/repository/profile.ts => features/profile/repositories/ProfileRepository.ts} (96%) rename packages/{ => features/schedules}/lib/date-ranges.test.ts (100%) rename packages/{ => features/schedules}/lib/date-ranges.ts (100%) rename packages/{ => features/schedules}/lib/slots.test.ts (99%) rename packages/{ => features/schedules}/lib/slots.ts (95%) create mode 100644 packages/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation.ts create mode 100644 packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWorkflow.test.ts create mode 100644 packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWorkflow.ts create mode 100644 packages/features/users/lib/UserListTableUtils.ts rename packages/features/users/lib/{userDeletionService.ts => deleteUser.ts} (100%) rename packages/{lib/server/repository/user.test.ts => features/users/repositories/UserRepository.test.ts} (96%) rename packages/{lib/server/repository/user.ts => features/users/repositories/UserRepository.ts} (87%) rename packages/{lib/server/service => features/users/services}/userCreationService.test.ts (68%) rename packages/{lib/server/service => features/users/services}/userCreationService.ts (81%) create mode 100644 packages/features/watchlist/lib/dto/index.ts create mode 100644 packages/features/watchlist/lib/dto/mappers.ts create mode 100644 packages/features/watchlist/lib/dto/types.ts create mode 100644 packages/features/watchlist/lib/facade/WatchlistFeature.ts create mode 100644 packages/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain.test.ts create mode 100644 packages/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain.ts rename packages/{ => features/watchlist}/lib/freeEmailDomainCheck/freeEmailDomains.ts (100%) create mode 100644 packages/features/watchlist/lib/interface/IAuditRepository.ts create mode 100644 packages/features/watchlist/lib/interface/IAuditService.ts create mode 100644 packages/features/watchlist/lib/interface/IBlockingService.ts create mode 100644 packages/features/watchlist/lib/interface/IWatchlistRepositories.ts create mode 100644 packages/features/watchlist/lib/interface/IWatchlistService.ts create mode 100644 packages/features/watchlist/lib/repository/GlobalWatchlistRepository.ts create mode 100644 packages/features/watchlist/lib/repository/OrganizationWatchlistRepository.ts create mode 100644 packages/features/watchlist/lib/repository/PrismaWatchlistAuditRepository.ts create mode 100644 packages/features/watchlist/lib/service/GlobalBlockingService.test.ts create mode 100644 packages/features/watchlist/lib/service/GlobalBlockingService.ts create mode 100644 packages/features/watchlist/lib/service/OrganizationBlockingService.test.ts create mode 100644 packages/features/watchlist/lib/service/OrganizationBlockingService.ts create mode 100644 packages/features/watchlist/lib/service/SpamCheckService.ts create mode 100644 packages/features/watchlist/lib/service/WatchlistAuditService.ts create mode 100644 packages/features/watchlist/lib/service/WatchlistService.test.ts create mode 100644 packages/features/watchlist/lib/service/WatchlistService.ts create mode 100644 packages/features/watchlist/lib/telemetry/index.ts create mode 100644 packages/features/watchlist/lib/telemetry/no-op-span.ts create mode 100644 packages/features/watchlist/lib/telemetry/sentry-span.ts create mode 100644 packages/features/watchlist/lib/telemetry/types.ts create mode 100644 packages/features/watchlist/lib/types.ts create mode 100644 packages/features/watchlist/lib/utils/normalization.test.ts create mode 100644 packages/features/watchlist/lib/utils/normalization.ts create mode 100644 packages/features/watchlist/operations/check-if-email-in-watchlist.controller.test.ts create mode 100644 packages/features/watchlist/operations/check-if-users-are-blocked.controller.test.ts create mode 100644 packages/features/watchlist/operations/list-all-system-entries.controller.test.ts create mode 100644 packages/features/watchlist/operations/list-all-system-entries.controller.ts delete mode 100644 packages/features/watchlist/watchlist.model.ts delete mode 100644 packages/features/watchlist/watchlist.repository.interface.ts delete mode 100644 packages/features/watchlist/watchlist.repository.mock.ts delete mode 100644 packages/features/watchlist/watchlist.repository.ts rename packages/{features/bookings/lib => lib/bookings}/SystemField.ts (100%) rename packages/lib/{ => bookings}/getLabelValueMapFromResponses.ts (89%) create mode 100644 packages/lib/dbReadResponseSchema.ts delete mode 100644 packages/lib/freeEmailDomainCheck/checkIfFreeEmailDomain.test.ts delete mode 100644 packages/lib/freeEmailDomainCheck/checkIfFreeEmailDomain.ts delete mode 100644 packages/lib/restrictionSchedule.ts create mode 100644 packages/lib/schedules/transformers/getScheduleListItemData.ts delete mode 100644 packages/lib/server/repository/TeamRepository.test.ts create mode 100644 packages/lib/server/repository/bookingReport.interface.ts create mode 100644 packages/lib/server/repository/bookingReport.test.ts create mode 100644 packages/lib/server/repository/bookingReport.ts create mode 100644 packages/lib/server/repository/watchlist.interface.ts create mode 100644 packages/lib/server/repository/watchlist.repository.ts delete mode 100644 packages/lib/server/repository/webhook.ts create mode 100644 packages/lib/server/repository/workflowRelations.ts create mode 100644 packages/lib/server/repository/workflowStep.ts create mode 100644 packages/lib/server/service/ApiKeyService.ts delete mode 100644 packages/lib/server/service/workflows.ts create mode 100644 packages/platform/atoms/create-schedule/CreateScheduleForm.tsx create mode 100644 packages/platform/atoms/create-schedule/index.tsx create mode 100644 packages/platform/atoms/create-schedule/wrappers/CreateSchedulePlatformWrapper.tsx create mode 100644 packages/platform/atoms/hooks/schedules/useAtomCreateSchedule.ts create mode 100644 packages/platform/atoms/hooks/schedules/useAtomDuplicateSchedule.ts create mode 100644 packages/platform/atoms/hooks/schedules/useAtomGetAllSchedules.ts create mode 100644 packages/platform/atoms/hooks/schedules/useEnsureDefaultSchedule.ts create mode 100644 packages/platform/atoms/list-schedules/index.ts create mode 100644 packages/platform/atoms/list-schedules/wrappers/ListSchedulesPlatformWrapper.tsx rename packages/platform/examples/base/src/pages/{availability.tsx => availability/[scheduleId].tsx} (93%) create mode 100644 packages/platform/examples/base/src/pages/availability/index.tsx create mode 100644 packages/platform/types/event-types/event-types_2024_06_14/inputs/booker-active-booking-limit.input.ts create mode 100644 packages/platform/types/event-types/event-types_2024_06_14/inputs/email-settings.input.ts create mode 100644 packages/platform/types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit.ts create mode 100644 packages/platform/types/event-types/event-types_2024_06_14/outputs/booker-active-bookings-limit.output.ts create mode 100644 packages/platform/types/utils/RequiresOneOfPropertiesWhenNotDisabled.ts create mode 100644 packages/prisma/migrations/20250909093954_add_form_submitted_workflow_trigger/migration.sql create mode 100644 packages/prisma/migrations/20250909134440_add_form_submitted_no_event/migration.sql create mode 100644 packages/prisma/migrations/20250930135416_add_type_to_workflow/migration.sql create mode 100644 packages/prisma/migrations/20251005102651_add_onboarding_v3_feature_flag/migration.sql create mode 100644 packages/prisma/migrations/20251006111422_add_requires_booker_email_verification/migration.sql create mode 100644 packages/prisma/migrations/20251007090722_modify_onboarding_table_orgs/migration.sql create mode 100644 packages/prisma/migrations/20251010135752_billing_tables_add_dates/migration.sql create mode 100644 packages/prisma/migrations/20251013185902_add_booking_report/migration.sql create mode 100644 packages/prisma/migrations/20251014143620_add_watchlist_audit_relation/migration.sql create mode 100644 packages/prisma/migrations/20251015211003000_add_watchlist_admin_permissions/migration.sql create mode 100644 packages/prisma/migrations/20251017161715_cleanup_future_events/migration.sql create mode 100644 packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.handler.ts create mode 100644 packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.schema.ts create mode 100644 packages/trpc/server/routers/viewer/admin/getTeamsForFeature.handler.ts create mode 100644 packages/trpc/server/routers/viewer/admin/getTeamsForFeature.schema.ts create mode 100644 packages/trpc/server/routers/viewer/admin/unassignFeatureFromTeam.handler.ts create mode 100644 packages/trpc/server/routers/viewer/admin/unassignFeatureFromTeam.schema.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/reportBooking.handler.test.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/reportBooking.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/reportBooking.schema.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/getActiveOnOptions.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/getActiveOnOptions.schema.ts delete mode 100644 packages/trpc/server/routers/viewer/eventTypes/getTeamAndEventTypeOptions.handler.ts delete mode 100644 packages/trpc/server/routers/viewer/eventTypes/getTeamAndEventTypeOptions.schema.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.test.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.schema.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.test.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.schema.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.test.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.schema.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/listWatchlistEntries.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/listWatchlistEntries.schema.ts create mode 100644 scripts/reassign-attributes.ts diff --git a/.agents/knowledge-base.md b/.agents/knowledge-base.md index 11d1987d92108b..36d25bc53f2373 100644 --- a/.agents/knowledge-base.md +++ b/.agents/knowledge-base.md @@ -111,7 +111,7 @@ The event types page UI components are located in `apps/web/modules/event-types/ Changes to shared UI patterns (like tab layouts and button alignments) need to be checked across multiple views to maintain consistency: - Event types page layout: `apps/web/modules/event-types/views/event-types-listing-view.tsx` -- Bookings page layout: `apps/web/modules/bookings/views/bookings-listing-view.tsx` +- Bookings page layout: `apps/web/modules/bookings/views/bookings-view.tsx` - Common elements like tabs, search bars, and filter buttons should maintain consistent alignment across views ## When working on workflow triggers or similar enum-based features in the Cal.com codebase @@ -306,6 +306,224 @@ import { Button } from "@calcom/ui/components/button"; - **Tests**: Same as source file + `.test.ts` or `.spec.ts` - **Avoid**: Dot-suffixes like `.service.ts`, `.repository.ts` (except for tests, types, specs) +## Repository Method Conventions + +Repositories should follow consistent naming and design patterns to promote reusability and maintainability. + +### Method Naming Rules + +**1. Don't include the repository's entity name in method names** + +Method names should be concise and avoid redundancy since the repository class name already indicates the entity type. + +```typescript +// ✅ Good - Concise method names +class BookingRepository { + findById(id: string) { ... } + findByUserId(userId: string) { ... } + create(data: BookingCreateInput) { ... } + delete(id: string) { ... } +} + +// ❌ Bad - Redundant entity name in methods +class BookingRepository { + findBookingById(id: string) { ... } + findBookingByUserId(userId: string) { ... } + createBooking(data: BookingCreateInput) { ... } + deleteBooking(id: string) { ... } +} +``` + +**2. Use `include` or similar keywords for methods that fetch relational data** + +When a method retrieves additional related entities, make this explicit in the method name using keywords like `include`, `with`, or `andRelations`. + +```typescript +// ✅ Good - Clear indication of included relations +class EventTypeRepository { + findById(id: string) { + return prisma.eventType.findUnique({ + where: { id }, + }); + } + + findByIdIncludeHosts(id: string) { + return prisma.eventType.findUnique({ + where: { id }, + include: { + hosts: true, + }, + }); + } + + findByIdIncludeHostsAndSchedule(id: string) { + return prisma.eventType.findUnique({ + where: { id }, + include: { + hosts: true, + schedule: true, + }, + }); + } +} + +// ❌ Bad - Unclear what data is included +class EventTypeRepository { + findById(id: string) { + return prisma.eventType.findUnique({ + where: { id }, + include: { + hosts: true, + schedule: true, + }, + }); + } + + findByIdForReporting(id: string) { + return prisma.eventType.findUnique({ + where: { id }, + include: { + hosts: true, + }, + }); + } +} +``` + +**3. Keep methods generic and reusable - avoid use-case-specific names** + +Repository methods should be general-purpose and describe what data they return, not how or where it's used. This promotes code reuse across different features. + +```typescript +// ✅ Good - Generic, reusable methods +class BookingRepository { + findByUserIdIncludeAttendees(userId: string) { + return prisma.booking.findMany({ + where: { userId }, + include: { + attendees: true, + }, + }); + } + + findByDateRangeIncludeEventType(startDate: Date, endDate: Date) { + return prisma.booking.findMany({ + where: { + startTime: { gte: startDate }, + endTime: { lte: endDate }, + }, + include: { + eventType: true, + }, + }); + } +} + +// ❌ Bad - Use-case-specific method names +class BookingRepository { + findBookingsForReporting(userId: string) { + return prisma.booking.findMany({ + where: { userId }, + include: { + attendees: true, + }, + }); + } + + findBookingsForDashboard(startDate: Date, endDate: Date) { + return prisma.booking.findMany({ + where: { + startTime: { gte: startDate }, + endTime: { lte: endDate }, + }, + include: { + eventType: true, + }, + }); + } +} +``` + +**4. No business logic in repositories** + +Repositories should only handle data access. Business logic, validations, and complex transformations belong in the Service layer. + +```typescript +// ✅ Good - Repository only handles data access +class BookingRepository { + findByIdIncludeAttendees(id: string) { + return prisma.booking.findUnique({ + where: { id }, + include: { + attendees: true, + }, + }); + } + + updateStatus(id: string, status: BookingStatus) { + return prisma.booking.update({ + where: { id }, + data: { status }, + }); + } +} + +class BookingService { + async confirmBooking(bookingId: string) { + const booking = await this.bookingRepository.findByIdIncludeAttendees(bookingId); + + if (!booking) { + throw new Error("Booking not found"); + } + + if (booking.status !== "PENDING") { + throw new Error("Only pending bookings can be confirmed"); + } + + // Business logic: send confirmation emails + await this.emailService.sendConfirmationToAttendees(booking.attendees); + + // Update status through repository + return this.bookingRepository.updateStatus(bookingId, "CONFIRMED"); + } +} + +// ❌ Bad - Business logic in repository +class BookingRepository { + async confirmBooking(bookingId: string) { + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + attendees: true, + }, + }); + + if (!booking) { + throw new Error("Booking not found"); + } + + if (booking.status !== "PENDING") { + throw new Error("Only pending bookings can be confirmed"); + } + + // ❌ Business logic shouldn't be here + await sendEmailToAttendees(booking.attendees); + + return prisma.booking.update({ + where: { id: bookingId }, + data: { status: "CONFIRMED" }, + }); + } +} +``` + +### Summary + +- Method names should be concise: `findById` not `findBookingById` +- Use `include`/`with` keywords when fetching relations: `findByIdIncludeHosts` +- Keep methods generic and reusable: `findByUserIdIncludeAttendees` not `findBookingsForReporting` +- No business logic in repositories - that belongs in Services + ## When using Day.js ```typescript diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9fdb484f79179b..525c155e22702d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,7 +10,6 @@ /apps/web/lib/handleOrgRedirect.ts @calcom/Foundation /apps/web/middleware.ts @calcom/Foundation /deploy/**/* @calcom/Foundation -/infra/**/* @calcom/Foundation /packages/app-store/applecalendar/**/* @calcom/Foundation /packages/app-store/caldavcalendar/**/* @calcom/Foundation /packages/app-store/exchange2013calendar/**/* @calcom/Foundation @@ -25,10 +24,10 @@ /packages/embeds/**/* @calcom/Foundation /packages/features/auth/lib/next-auth-options.ts @calcom/Foundation /packages/features/bookings/lib/**/* @calcom/Foundation -/packages/lib/getAggregatedAvailability.ts @calcom/Foundation -/packages/lib/getUserAvailability.ts @calcom/Foundation -/packages/lib/server/getLuckyUser.ts @calcom/Foundation -/packages/lib/slots.ts @calcom/Foundation +/packages/features/availability/lib/getAggregatedAvailability/getAggregatedAvailability.ts @calcom/Foundation +/packages/features/availability/lib/getUserAvailability.ts @calcom/Foundation +/packages/features/bookings/lib/getLuckyUser.ts @calcom/Foundation +/packages/features/schedules/lib/slots.ts @calcom/Foundation /packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer /packages/platform/**/* @calcom/Platform @calcom/Foundation /packages/prisma/**/* @calcom/Foundation diff --git a/.vscode/settings.json b/.vscode/settings.json index c344645fab61f7..43ae1c6a83d9d7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "typescript.tsdk": "node_modules/typescript/lib", - "editor.formatOnSave": false, + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, diff --git a/.yarn/versions/13de130d.yml b/.yarn/versions/13de130d.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/3a60c02f.yml b/.yarn/versions/3a60c02f.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/492e4e82.yml b/.yarn/versions/492e4e82.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/c0a99150.yml b/.yarn/versions/c0a99150.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/c0a99150.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/.yarn/versions/e11ed2da.yml b/.yarn/versions/e11ed2da.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/README.md b/README.md index 75ba34a148f87b..bae70679f9e076 100644 --- a/README.md +++ b/README.md @@ -234,9 +234,10 @@ for Logger level to be set at info, for example. - If you don't want to create a local DB. Then you can also consider using services like railway.app or render. + If you don't want to create a local DB. Then you can also consider using services like railway.app, Northflank or render. - [Setup postgres DB with railway.app](https://docs.railway.app/guides/postgresql) + - [Setup postgres DB with Northflank](https://northflank.com/guides/deploy-postgres-database-on-northflank) - [Setup postgres DB with render](https://render.com/docs/databases) 1. Copy and paste your `DATABASE_URL` from `.env` to `.env.appStore`. @@ -390,6 +391,12 @@ Cal.com, Inc. does not provide official support for Docker, but we will accept f You can deploy Cal.com on [Railway](https://railway.app) using the button above. The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Cal.com on their platform. +### Northflank + +[![Deploy on Northflank](https://assets.northflank.com/deploy_to_northflank_smm_36700fb050.svg)](https://northflank.com/stacks/deploy-calcom) + +You can deploy Cal.com on [Northflank](https://northflank.com) using the button above. The team at Northflank also have a [detailed blog post](https://northflank.com/guides/deploy-calcom-with-northflank) on deploying Cal.com on their platform. + ### Vercel Currently Vercel Pro Plan is required to be able to Deploy this application with Vercel, due to limitations on the number of serverless functions on the free plan. diff --git a/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts b/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts index 86dfc51d0548ae..cad5fcf63372f5 100644 --- a/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts +++ b/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts @@ -4,7 +4,7 @@ import type { NextApiResponse, NextApiRequest } from "next"; import { createMocks } from "node-mocks-http"; import { describe, it, expect, vi } from "vitest"; -import { handleAutoLock } from "@calcom/lib/autoLock"; +import { handleAutoLock } from "@calcom/features/ee/api-keys/lib/autoLock"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { HttpError } from "@calcom/lib/http-error"; @@ -19,7 +19,7 @@ vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({ checkRateLimitAndThrowError: vi.fn(), })); -vi.mock("@calcom/lib/autoLock", () => ({ +vi.mock("@calcom/features/ee/api-keys/lib/autoLock", () => ({ handleAutoLock: vi.fn(), })); diff --git a/apps/api/v1/lib/helpers/rateLimitApiKey.ts b/apps/api/v1/lib/helpers/rateLimitApiKey.ts index b490c011bc61ff..444f0f62614451 100644 --- a/apps/api/v1/lib/helpers/rateLimitApiKey.ts +++ b/apps/api/v1/lib/helpers/rateLimitApiKey.ts @@ -1,6 +1,6 @@ import type { NextMiddleware } from "next-api-middleware"; -import { handleAutoLock } from "@calcom/lib/autoLock"; +import { handleAutoLock } from "@calcom/features/ee/api-keys/lib/autoLock"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { HttpError } from "@calcom/lib/http-error"; diff --git a/apps/api/v1/lib/helpers/verifyApiKey.test.ts b/apps/api/v1/lib/helpers/verifyApiKey.test.ts index fc296f24c3ff5a..8cb47d561fe98c 100644 --- a/apps/api/v1/lib/helpers/verifyApiKey.test.ts +++ b/apps/api/v1/lib/helpers/verifyApiKey.test.ts @@ -1,19 +1,42 @@ -import prismock from "../../../../../tests/libs/__mocks__/prisma"; - +/** + * Unit Tests for verifyApiKey middleware + * + * These tests verify the middleware logic without touching the database. + * All dependencies (repositories, utilities) are mocked. + */ import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; import { createMocks } from "node-mocks-http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService"; -import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService"; -import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys"; +import LicenseKeyService, { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService"; +import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository"; import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface"; -import prisma from "@calcom/prisma"; -import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; +import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService"; +import { UserPermissionRole } from "@calcom/prisma/enums"; +import { isAdminGuard } from "../utils/isAdmin"; +import { isLockedOrBlocked } from "../utils/isLockedOrBlocked"; +import { ScopeOfAdmin } from "../utils/scopeOfAdmin"; import { verifyApiKey } from "./verifyApiKey"; +vi.mock("@calcom/lib/server/service/ApiKeyService", () => ({ + ApiKeyService: vi.fn(), +})); + +vi.mock("@calcom/lib/server/repository/PrismaApiKeyRepository", () => ({ + PrismaApiKeyRepository: vi.fn(), +})); + +vi.mock("../utils/isAdmin", () => ({ + isAdminGuard: vi.fn(), +})); + +vi.mock("../utils/isLockedOrBlocked", () => ({ + isLockedOrBlocked: vi.fn(), +})); + vi.mock("@calcom/lib/crypto", () => ({ symmetricDecrypt: vi.fn().mockReturnValue("mocked-decrypted-value"), symmetricEncrypt: vi.fn().mockReturnValue("mocked-encrypted-value"), @@ -32,17 +55,29 @@ afterEach(() => { }); const mockDeploymentRepository: IDeploymentRepository = { - getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value + getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"), }; -describe("Verify API key", () => { +describe("Verify API key - Unit Tests", () => { let service: ILicenseKeyService; + let mockApiKeyService: ApiKeyService; beforeEach(async () => { service = await LicenseKeyService.create(mockDeploymentRepository); - vi.spyOn(service, "checkLicense"); + + vi.spyOn(LicenseKeySingleton, "getInstance").mockResolvedValue(service as LicenseKeyService); + + mockApiKeyService = { + verifyKeyByHashedKey: vi.fn(), + } as unknown as ApiKeyService; + + vi.mocked(ApiKeyService).mockImplementation(() => mockApiKeyService); + vi.mocked(PrismaApiKeyRepository).mockImplementation(() => ({} as unknown as PrismaApiKeyRepository)); + + vi.mocked(isAdminGuard).mockReset(); + vi.mocked(isLockedOrBlocked).mockReset(); }); it("should throw an error if the api key is not valid", async () => { @@ -98,22 +133,25 @@ describe("Verify API key", () => { query: { apiKey: "cal_test_key", }, - prisma, }); - const hashedKey = hashAPIKey("test_key"); - await prismock.apiKey.create({ - data: { - hashedKey, - user: { - create: { - email: "admin@example.com", - role: UserPermissionRole.ADMIN, - locked: false, - }, - }, + + vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({ + valid: true, + userId: 1, + user: { + role: UserPermissionRole.ADMIN, + locked: false, + email: "admin@example.com", }, }); + vi.mocked(isAdminGuard).mockResolvedValue({ + isAdmin: true, + scope: ScopeOfAdmin.SystemWide, + }); + + vi.mocked(isLockedOrBlocked).mockResolvedValue(false); + const middleware = { fn: verifyApiKey, }; @@ -139,40 +177,25 @@ describe("Verify API key", () => { query: { apiKey: "cal_test_key", }, - prisma, }); - const hashedKey = hashAPIKey("test_key"); - await prismock.apiKey.create({ - data: { - hashedKey, - user: { - create: { - email: "org-admin@acme.com", - role: UserPermissionRole.USER, - locked: false, - teams: { - create: { - accepted: true, - role: MembershipRole.OWNER, - team: { - create: { - name: "ACME", - isOrganization: true, - organizationSettings: { - create: { - isAdminAPIEnabled: true, - orgAutoAcceptEmail: "acme.com", - }, - }, - }, - }, - }, - }, - }, - }, + + vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({ + valid: true, + userId: 2, + user: { + role: UserPermissionRole.USER, + locked: false, + email: "org-admin@acme.com", }, }); + vi.mocked(isAdminGuard).mockResolvedValue({ + isAdmin: true, + scope: ScopeOfAdmin.OrgOwnerOrAdmin, + }); + + vi.mocked(isLockedOrBlocked).mockResolvedValue(false); + const middleware = { fn: verifyApiKey, }; @@ -198,22 +221,25 @@ describe("Verify API key", () => { query: { apiKey: "cal_test_key", }, - prisma, }); - const hashedKey = hashAPIKey("test_key"); - await prismock.apiKey.create({ - data: { - hashedKey, - user: { - create: { - email: "locked@example.com", - role: UserPermissionRole.USER, - locked: true, - }, - }, + + vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({ + valid: true, + userId: 3, + user: { + role: UserPermissionRole.USER, + locked: true, + email: "locked@example.com", }, }); + vi.mocked(isAdminGuard).mockResolvedValue({ + isAdmin: false, + scope: ScopeOfAdmin.SystemWide, + }); + + vi.mocked(isLockedOrBlocked).mockResolvedValue(true); + const middleware = { fn: verifyApiKey, }; diff --git a/apps/api/v1/lib/helpers/verifyApiKey.ts b/apps/api/v1/lib/helpers/verifyApiKey.ts index 75f24f01e39e9d..e7b92d0ba2055c 100644 --- a/apps/api/v1/lib/helpers/verifyApiKey.ts +++ b/apps/api/v1/lib/helpers/verifyApiKey.ts @@ -3,21 +3,15 @@ import type { NextMiddleware } from "next-api-middleware"; import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService"; import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys"; import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository"; import { DeploymentRepository } from "@calcom/lib/server/repository/deployment"; -import prisma from "@calcom/prisma"; +import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService"; +import { prisma } from "@calcom/prisma"; import { isAdminGuard } from "../utils/isAdmin"; import { isLockedOrBlocked } from "../utils/isLockedOrBlocked"; import { ScopeOfAdmin } from "../utils/scopeOfAdmin"; -// Used to check if the apiKey is not expired, could be extracted if reused. but not for now. -export const dateNotInPast = function (date: Date) { - const now = new Date(); - if (now.setHours(0, 0, 0, 0) > date.setHours(0, 0, 0, 0)) { - return true; - } -}; - // This verifies the apiKey and sets the user if it is valid. export const verifyApiKey: NextMiddleware = async (req, res, next) => { const deploymentRepo = new DeploymentRepository(prisma); @@ -32,24 +26,19 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => { const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", ""); const hashedKey = hashAPIKey(strippedApiKey); - const apiKey = await prisma.apiKey.findUnique({ - where: { hashedKey }, - include: { - user: { - select: { role: true, locked: true, email: true }, - }, - }, - }); - if (!apiKey) return res.status(401).json({ error: "Your API key is not valid." }); - if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) { - return res.status(401).json({ error: "This API key is expired." }); + + // Use service layer for API key verification + const apiKeyRepo = new PrismaApiKeyRepository(prisma); + const apiKeyService = new ApiKeyService({ apiKeyRepo }); + const result = await apiKeyService.verifyKeyByHashedKey(hashedKey); + + if (!result.valid) { + return res.status(401).json({ error: result.error }); } - if (!apiKey.userId || !apiKey.user) - return res.status(404).json({ error: "No user found for this API key." }); // save the user id in the request for later use - req.userId = apiKey.userId; - req.user = apiKey.user; + req.userId = result.userId!; + req.user = result.user!; const { isAdmin, scope } = await isAdminGuard(req); const userIsLockedOrBlocked = await isLockedOrBlocked(req); diff --git a/apps/api/v1/lib/utils/isLockedOrBlocked.ts b/apps/api/v1/lib/utils/isLockedOrBlocked.ts index 0786678a24500e..e0f3d423b00b35 100644 --- a/apps/api/v1/lib/utils/isLockedOrBlocked.ts +++ b/apps/api/v1/lib/utils/isLockedOrBlocked.ts @@ -1,9 +1,17 @@ import type { NextApiRequest } from "next"; +import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry"; import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller"; export async function isLockedOrBlocked(req: NextApiRequest) { const user = req.user; if (!user?.email) return false; - return user.locked || (await checkIfEmailIsBlockedInWatchlistController(user.email)); + return ( + user.locked || + (await checkIfEmailIsBlockedInWatchlistController({ + email: user.email, + organizationId: null, + span: sentrySpan, + })) + ); } diff --git a/apps/api/v1/lib/validations/user.ts b/apps/api/v1/lib/validations/user.ts index efbf1207576d44..c205e1e05e76be 100644 --- a/apps/api/v1/lib/validations/user.ts +++ b/apps/api/v1/lib/validations/user.ts @@ -1,7 +1,7 @@ import { z } from "zod"; +import { checkUsername } from "@calcom/features/profile/lib/checkUsername"; import { emailSchema } from "@calcom/lib/emailSchema"; -import { checkUsername } from "@calcom/lib/server/checkUsername"; import { iso8601 } from "@calcom/prisma/zod-utils"; import { UserSchema } from "@calcom/prisma/zod/modelSchema/UserSchema"; diff --git a/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts b/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts index f7d68b6bdafda6..2c222e6e30bc54 100644 --- a/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts +++ b/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts @@ -3,7 +3,7 @@ import type { NextApiRequest } from "next"; import { getRecordingsOfCalVideoByRoomName, getDownloadLinkOfCalVideoByRecordingId, -} from "@calcom/app-store/videoClient"; +} from "@calcom/features/conferencing/lib/videoClient"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import prisma from "@calcom/prisma"; diff --git a/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/_get.ts b/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/_get.ts index ee47e89fe9b5b0..d57b9711425b2a 100644 --- a/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/_get.ts +++ b/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/_get.ts @@ -3,7 +3,7 @@ import type { NextApiRequest } from "next"; import { getTranscriptsAccessLinkFromRecordingId, checkIfRoomNameMatchesInRecording, -} from "@calcom/app-store/videoClient"; +} from "@calcom/features/conferencing/lib/videoClient"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import prisma from "@calcom/prisma"; diff --git a/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts b/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts index 17ff122d5e8498..f4de9c9c73a05d 100644 --- a/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts +++ b/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts @@ -1,6 +1,6 @@ import type { NextApiRequest } from "next"; -import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient"; +import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/features/conferencing/lib/videoClient"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import prisma from "@calcom/prisma"; diff --git a/apps/api/v1/pages/api/bookings/_post.ts b/apps/api/v1/pages/api/bookings/_post.ts index 12caca5758aa58..3b1c1c57f23a66 100644 --- a/apps/api/v1/pages/api/bookings/_post.ts +++ b/apps/api/v1/pages/api/bookings/_post.ts @@ -1,7 +1,7 @@ import type { NextApiRequest } from "next"; +import { getRegularBookingService } from "@calcom/features/bookings/di/RegularBookingService.container"; import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi"; -import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; @@ -239,15 +239,17 @@ async function handler(req: NextApiRequest) { } try { - return await handleNewBooking( - { - bookingData: req.body, + const regularBookingService = getRegularBookingService(); + + return await regularBookingService.createBookingForApiV1({ + bookingData: req.body, + bookingMeta: { userId, hostname: req.headers.host || "", forcedSlug: req.headers["x-cal-force-slug"] as string | undefined, }, - getBookingDataSchemaForApi - ); + bookingDataSchemaGetter: getBookingDataSchemaForApi, + }); } catch (error: unknown) { const knownError = error as Error; if (knownError?.message === ErrorCode.NoAvailableUsersFound) { diff --git a/apps/api/v1/pages/api/connected-calendars/_get.ts b/apps/api/v1/pages/api/connected-calendars/_get.ts index 9329281a279e1f..fa4a8bd5c9013d 100644 --- a/apps/api/v1/pages/api/connected-calendars/_get.ts +++ b/apps/api/v1/pages/api/connected-calendars/_get.ts @@ -2,9 +2,9 @@ import type { NextApiRequest } from "next"; import type { UserWithCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars"; import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; -import { UserRepository } from "@calcom/lib/server/repository/user"; import prisma from "@calcom/prisma"; import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery"; diff --git a/apps/api/v1/pages/api/teams/[teamId]/_patch.ts b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts index d7fe9e33e220ae..b588c0147afb6f 100644 --- a/apps/api/v1/pages/api/teams/[teamId]/_patch.ts +++ b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts @@ -1,10 +1,10 @@ import type { NextApiRequest } from "next"; import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; +import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; -import { TeamRepository } from "@calcom/lib/server/repository/team"; import prisma from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; diff --git a/apps/api/v1/pages/api/users/[userId]/_delete.ts b/apps/api/v1/pages/api/users/[userId]/_delete.ts index 8ed534c49d0fa4..27c465ea079254 100644 --- a/apps/api/v1/pages/api/users/[userId]/_delete.ts +++ b/apps/api/v1/pages/api/users/[userId]/_delete.ts @@ -1,6 +1,6 @@ import type { NextApiRequest } from "next"; -import { deleteUser } from "@calcom/features/users/lib/userDeletionService"; +import { deleteUser } from "@calcom/features/users/lib/deleteUser"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import prisma from "@calcom/prisma"; diff --git a/apps/api/v1/pages/api/users/_post.ts b/apps/api/v1/pages/api/users/_post.ts index 001de340d5aa17..3d79edde381a12 100644 --- a/apps/api/v1/pages/api/users/_post.ts +++ b/apps/api/v1/pages/api/users/_post.ts @@ -1,8 +1,8 @@ import type { NextApiRequest } from "next"; +import { UserCreationService } from "@calcom/features/users/services/userCreationService"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; -import { UserCreationService } from "@calcom/lib/server/service/userCreationService"; import { CreationSource } from "@calcom/prisma/enums"; import { schemaUserCreateBodyParams } from "~/lib/validations/user"; diff --git a/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts b/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts index 582f01d830db68..8cf1fe62f725cc 100644 --- a/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts +++ b/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts @@ -9,7 +9,7 @@ import { buildBooking } from "@calcom/lib/test/builder"; import { getRecordingsOfCalVideoByRoomName, getDownloadLinkOfCalVideoByRecordingId, -} from "@calcom/app-store/videoClient"; +} from "@calcom/features/conferencing/lib/videoClient"; import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers"; @@ -22,7 +22,7 @@ type CustomNextApiResponse = NextApiResponse & Response; const adminUserId = 1; const memberUserId = 10; -vi.mock("@calcom/app-store/videoClient", () => { +vi.mock("@calcom/features/conferencing/lib/videoClient", () => { return { getRecordingsOfCalVideoByRoomName: vi.fn(), getDownloadLinkOfCalVideoByRecordingId: vi.fn(), diff --git a/apps/api/v1/test/lib/bookings/[id]/transcripts/[recordingId]/_get.test.ts b/apps/api/v1/test/lib/bookings/[id]/transcripts/[recordingId]/_get.test.ts index 9839308d0c9b93..3a9eebba2474b5 100644 --- a/apps/api/v1/test/lib/bookings/[id]/transcripts/[recordingId]/_get.test.ts +++ b/apps/api/v1/test/lib/bookings/[id]/transcripts/[recordingId]/_get.test.ts @@ -8,7 +8,7 @@ import { describe, expect, test, vi, afterEach } from "vitest"; import { getTranscriptsAccessLinkFromRecordingId, checkIfRoomNameMatchesInRecording, -} from "@calcom/app-store/videoClient"; +} from "@calcom/features/conferencing/lib/videoClient"; import { buildBooking } from "@calcom/lib/test/builder"; import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers"; @@ -19,7 +19,7 @@ import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[reco type CustomNextApiRequest = NextApiRequest & Request; type CustomNextApiResponse = NextApiResponse & Response; -vi.mock("@calcom/app-store/videoClient", () => { +vi.mock("@calcom/features/conferencing/lib/videoClient", () => { return { getTranscriptsAccessLinkFromRecordingId: vi.fn(), checkIfRoomNameMatchesInRecording: vi.fn(), diff --git a/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts b/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts index 6234bca548b354..053f5861dee58f 100644 --- a/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts +++ b/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts @@ -6,7 +6,7 @@ import { createMocks } from "node-mocks-http"; import { describe, expect, test, vi, afterEach } from "vitest"; import { buildBooking } from "@calcom/lib/test/builder"; -import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient"; +import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/features/conferencing/lib/videoClient"; import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers"; @@ -16,7 +16,7 @@ import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get"; type CustomNextApiRequest = NextApiRequest & Request; type CustomNextApiResponse = NextApiResponse & Response; -vi.mock("@calcom/app-store/videoClient", () => { +vi.mock("@calcom/features/conferencing/lib/videoClient", () => { return { getAllTranscriptsAccessLinkFromRoomName: vi.fn(), }; diff --git a/apps/api/v1/test/lib/bookings/_post.test.ts b/apps/api/v1/test/lib/bookings/_post.test.ts index 5f741182745b68..da25f3a596ea92 100644 --- a/apps/api/v1/test/lib/bookings/_post.test.ts +++ b/apps/api/v1/test/lib/bookings/_post.test.ts @@ -66,7 +66,7 @@ vi.mock("@calcom/features/webhooks/lib/sendOrSchedulePayload", () => ({ })); const mockFindOriginalRescheduledBooking = vi.fn(); -vi.mock("@calcom/lib/server/repository/booking", () => ({ +vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({ BookingRepository: vi.fn().mockImplementation(() => ({ findOriginalRescheduledBooking: mockFindOriginalRescheduledBooking, })), @@ -133,7 +133,7 @@ vi.mock("@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers", ( }), })); -vi.mock("@calcom/lib/server/repository/profile", () => ({ +vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({ ProfileRepository: { findManyForUser: vi.fn().mockResolvedValue([]), buildPersonalProfileFromUser: vi.fn().mockReturnValue({ diff --git a/apps/api/v1/test/lib/users/_post.test.ts b/apps/api/v1/test/lib/users/_post.test.ts index 7aabe2fd03e85d..09a9c34a1b6b4e 100644 --- a/apps/api/v1/test/lib/users/_post.test.ts +++ b/apps/api/v1/test/lib/users/_post.test.ts @@ -1,9 +1,13 @@ -import prismock from "../../../../../../tests/libs/__mocks__/prisma"; - +/** + * Unit Tests for POST /api/users + * + * These tests verify the API endpoint logic without touching the database. + * All dependencies (UserCreationService) are mocked. + */ import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; import { createMocks } from "node-mocks-http"; -import { describe, test, expect, vi } from "vitest"; +import { describe, test, expect, vi, beforeEach } from "vitest"; import handler from "../../../pages/api/users/_post"; @@ -18,9 +22,41 @@ vi.mock("@calcom/lib/server/i18n", () => { }; }); +vi.mock("@calcom/features/profile/lib/checkUsername", () => ({ + checkUsername: vi.fn().mockResolvedValue({ + available: true, + premium: false, + }), +})); + +vi.mock("@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller", () => ({ + checkIfEmailIsBlockedInWatchlistController: vi.fn().mockResolvedValue(false), +})); + +const mockCreate = vi.fn(); +vi.mock("@calcom/features/users/repositories/UserRepository", () => ({ + UserRepository: vi.fn().mockImplementation(() => ({ + create: mockCreate, + })), +})); + +vi.mock("@calcom/lib/auth/hashPassword", () => ({ + hashPassword: vi.fn().mockResolvedValue("hashed-password"), +})); + vi.stubEnv("CALCOM_LICENSE_KEY", undefined); -describe("POST /api/users", () => { +describe("POST /api/users - Unit Tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreate.mockResolvedValue({ + id: 1, + email: "test@example.com", + username: "test", + locked: false, + }); + }); + test("should throw 401 if not system-wide admin", async () => { const { req, res } = createMocks({ method: "POST", @@ -34,7 +70,9 @@ describe("POST /api/users", () => { await handler(req, res); expect(res.statusCode).toBe(401); + expect(mockCreate).not.toHaveBeenCalled(); }); + test("should throw a 400 if no email is provided", async () => { const { req, res } = createMocks({ method: "POST", @@ -47,7 +85,9 @@ describe("POST /api/users", () => { await handler(req, res); expect(res.statusCode).toBe(400); + expect(mockCreate).not.toHaveBeenCalled(); }); + test("should throw a 400 if no username is provided", async () => { const { req, res } = createMocks({ method: "POST", @@ -60,15 +100,24 @@ describe("POST /api/users", () => { await handler(req, res); expect(res.statusCode).toBe(400); + expect(mockCreate).not.toHaveBeenCalled(); }); + test("should create user successfully", async () => { + mockCreate.mockResolvedValue({ + id: 1, + email: "test@example.com", + username: "testuser123", + locked: false, + organizationId: null, + }); + const { req, res } = createMocks({ method: "POST", body: { email: "test@example.com", - username: "test", + username: "testuser123", }, - prisma: prismock, }); req.isSystemWideAdmin = true; @@ -76,57 +125,19 @@ describe("POST /api/users", () => { expect(res.statusCode).toBe(200); - const userQuery = await prismock.user.findFirst({ - where: { - email: "test@example.com", - }, - }); - - expect(userQuery).toEqual( + expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ email: "test@example.com", - username: "test", + username: "testuser123", locked: false, - organizationId: null, }) ); - }); - test("should auto lock user if email is in watchlist", async () => { - const { req, res } = createMocks({ - method: "POST", - body: { - email: "test@example.com", - username: "test", - }, - prisma: prismock, - }); - req.isSystemWideAdmin = true; - - await prismock.watchlist.create({ - data: { - type: "EMAIL", - value: "test@example.com", - severity: "CRITICAL", - createdById: 1, - }, - }); - - await handler(req, res); - - expect(res.statusCode).toBe(200); - - const userQuery = await prismock.user.findFirst({ - where: { - email: "test@example.com", - }, - }); - - expect(userQuery).toEqual( + const responseData = JSON.parse(res._getData()); + expect(responseData.user).toEqual( expect.objectContaining({ email: "test@example.com", - username: "test", - locked: true, + username: "testuser123", organizationId: null, }) ); diff --git a/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts b/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts index b67f45ca19cc6e..935073d6529adb 100644 --- a/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts +++ b/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts @@ -1,38 +1,52 @@ -import prismock from "../../../../../../tests/libs/__mocks__/prisma"; - import type { NextApiRequest } from "next"; -import { describe, expect, it, beforeEach } from "vitest"; +import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; + +import { getWatchlistFeature } from "@calcom/features/di/watchlist/containers/watchlist"; +import type { WatchlistFeature } from "@calcom/features/watchlist/lib/facade/WatchlistFeature"; import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked"; +vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => ({ + getWatchlistFeature: vi.fn(), +})); + describe("isLockedOrBlocked", () => { - beforeEach(async () => { - await prismock.watchlist.createMany({ - data: [ - { - type: "DOMAIN", - value: "spam.com", - createdById: 1, - }, - { - type: "DOMAIN", - value: "blocked.com", - createdById: 1, - }, - ], - }); + let mockWatchlistFeature: WatchlistFeature; + + beforeEach(() => { + const mockGlobalBlocking = { + isBlocked: vi.fn(), + }; + const mockOrgBlocking = { + isBlocked: vi.fn(), + }; + + mockWatchlistFeature = { + globalBlocking: mockGlobalBlocking, + orgBlocking: mockOrgBlocking, + } as unknown as WatchlistFeature; + + vi.mocked(getWatchlistFeature).mockResolvedValue(mockWatchlistFeature); + }); + + afterEach(() => { + vi.resetAllMocks(); }); it("should return false if no user in request", async () => { const req = { userId: null, user: null } as unknown as NextApiRequest; const result = await isLockedOrBlocked(req); expect(result).toBe(false); + + expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled(); }); it("should return false if user has no email", async () => { const req = { userId: 123, user: { email: null } } as unknown as NextApiRequest; const result = await isLockedOrBlocked(req); expect(result).toBe(false); + + expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled(); }); it("should return true if user is locked", async () => { @@ -46,6 +60,8 @@ describe("isLockedOrBlocked", () => { const result = await isLockedOrBlocked(req); expect(result).toBe(true); + + expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled(); }); it("should return true if user email domain is watchlisted", async () => { @@ -57,8 +73,12 @@ describe("isLockedOrBlocked", () => { }, } as unknown as NextApiRequest; + vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: true }); + const result = await isLockedOrBlocked(req); expect(result).toBe(true); + + expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@blocked.com"); }); it("should return false if user is not locked and email domain is not watchlisted", async () => { @@ -70,8 +90,12 @@ describe("isLockedOrBlocked", () => { }, } as unknown as NextApiRequest; + vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: false }); + const result = await isLockedOrBlocked(req); expect(result).toBe(false); + + expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@example.com"); }); it("should handle email domains case-insensitively", async () => { @@ -83,7 +107,11 @@ describe("isLockedOrBlocked", () => { }, } as unknown as NextApiRequest; + vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: true }); + const result = await isLockedOrBlocked(req); expect(result).toBe(true); + + expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@blocked.com"); }); }); diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts index d645d46aae8a4e..bfd8d69e4e3048 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts @@ -6,6 +6,9 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service"; import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module"; import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { InstantBookingModule } from "@/lib/modules/instant-booking.module"; +import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module"; +import { RegularBookingModule } from "@/lib/modules/regular-booking.module"; import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository"; import { AppsRepository } from "@/modules/apps/apps.repository"; import { BillingModule } from "@/modules/billing/billing.module"; @@ -35,6 +38,9 @@ import { Module } from "@nestjs/common"; SchedulesModule_2024_04_15, EventTypesModule_2024_06_14, ProfilesModule, + RegularBookingModule, + RecurringBookingModule, + InstantBookingModule, ], providers: [ TokensRepository, diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts index ceea40c9087af9..fe798022c2d632 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts @@ -21,7 +21,7 @@ import { randomString } from "test/utils/randomString"; import { withApiAuth } from "test/utils/withApiAuth"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; -import { handleNewBooking } from "@calcom/platform-libraries"; +import { type RegularBookingCreateResult } from "@calcom/platform-libraries/bookings"; import type { ApiSuccessResponse, ApiErrorResponse } from "@calcom/platform-types"; import type { User } from "@calcom/prisma/client"; @@ -41,7 +41,7 @@ describe("Bookings Endpoints 2024-04-15", () => { let eventTypeId: number; - let createdBooking: Awaited>; + let createdBooking: RegularBookingCreateResult; beforeAll(async () => { const moduleRef = await withApiAuth( @@ -131,8 +131,7 @@ describe("Bookings Endpoints 2024-04-15", () => { .send(body) .expect(201) .then(async (response) => { - const responseBody: ApiSuccessResponse>> = - response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); expect(responseBody.data.userPrimaryEmail).toBeDefined(); @@ -231,8 +230,7 @@ describe("Bookings Endpoints 2024-04-15", () => { .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) .expect(201) .then(async (response) => { - const responseBody: ApiSuccessResponse>> = - response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); expect(responseBody.data.userPrimaryEmail).toBeDefined(); @@ -289,8 +287,7 @@ describe("Bookings Endpoints 2024-04-15", () => { .send(body) .expect(201) .then(async (response) => { - const responseBody: ApiSuccessResponse>> = - response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); expect(responseBody.data.userPrimaryEmail).toBeDefined(); @@ -387,8 +384,7 @@ describe("Bookings Endpoints 2024-04-15", () => { .send(body) .expect(201) .then(async (response) => { - const responseBody: ApiSuccessResponse>> = - response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); expect(responseBody.data.userPrimaryEmail).toBeDefined(); diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts index 9b2c06301a9f7e..f02fdff352faa7 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts @@ -7,6 +7,9 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service"; import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key"; import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions"; +import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service"; +import { RecurringBookingService } from "@/lib/services/recurring-booking.service"; +import { RegularBookingService } from "@/lib/services/regular-booking.service"; import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; @@ -45,11 +48,8 @@ import { v4 as uuidv4 } from "uuid"; import { X_CAL_CLIENT_ID, X_CAL_PLATFORM_EMBED } from "@calcom/platform-constants"; import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants"; import { - handleNewRecurringBooking, - handleNewBooking, BookingResponse, HttpError, - handleInstantMeeting, handleMarkNoShow, getAllUserBookings, getBookingInfo, @@ -58,6 +58,7 @@ import { ErrorCode, } from "@calcom/platform-libraries"; import { CreationSource } from "@calcom/platform-libraries"; +import { type InstantBookingCreateResult } from "@calcom/platform-libraries/bookings"; import { GetBookingsInput_2024_04_15, CancelBookingInput_2024_04_15, @@ -109,7 +110,10 @@ export class BookingsController_2024_04_15 { private readonly apiKeyRepository: ApiKeysRepository, private readonly platformBookingsService: PlatformBookingsService, private readonly usersRepository: UsersRepository, - private readonly usersService: UsersService + private readonly usersService: UsersService, + private readonly regularBookingService: RegularBookingService, + private readonly recurringBookingService: RecurringBookingService, + private readonly instantBookingCreateService: InstantBookingCreateService ) {} @Get("/") @@ -187,17 +191,19 @@ export class BookingsController_2024_04_15 { const { orgSlug, locationUrl } = body; try { const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed); - const booking = await handleNewBooking({ + const booking = await this.regularBookingService.createBooking({ bookingData: bookingRequest.body, - userId: bookingRequest.userId, - hostname: bookingRequest.headers?.host || "", - forcedSlug: orgSlug, - platformClientId: bookingRequest.platformClientId, - platformRescheduleUrl: bookingRequest.platformRescheduleUrl, - platformCancelUrl: bookingRequest.platformCancelUrl, - platformBookingUrl: bookingRequest.platformBookingUrl, - platformBookingLocation: bookingRequest.platformBookingLocation, - areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + bookingMeta: { + userId: bookingRequest.userId, + hostname: bookingRequest.headers?.host || "", + forcedSlug: orgSlug, + platformClientId: bookingRequest.platformClientId, + platformRescheduleUrl: bookingRequest.platformRescheduleUrl, + platformCancelUrl: bookingRequest.platformCancelUrl, + platformBookingUrl: bookingRequest.platformBookingUrl, + platformBookingLocation: bookingRequest.platformBookingLocation, + areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + }, }); if (booking.userId && booking.uid && booking.startTime) { void (await this.billingService.increaseUsageByUserId(booking.userId, { @@ -315,15 +321,17 @@ export class BookingsController_2024_04_15 { const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed); - const createdBookings: BookingResponse[] = await handleNewRecurringBooking({ + const createdBookings: BookingResponse[] = await this.recurringBookingService.createBooking({ bookingData: bookingRequest.body, - userId: bookingRequest.userId, - hostname: bookingRequest.headers?.host || "", - platformClientId: bookingRequest.platformClientId, - platformRescheduleUrl: bookingRequest.platformRescheduleUrl, - platformCancelUrl: bookingRequest.platformCancelUrl, - platformBookingUrl: bookingRequest.platformBookingUrl, - platformBookingLocation: bookingRequest.platformBookingLocation, + bookingMeta: { + userId: bookingRequest.userId, + hostname: bookingRequest.headers?.host || "", + platformClientId: bookingRequest.platformClientId, + platformRescheduleUrl: bookingRequest.platformRescheduleUrl, + platformCancelUrl: bookingRequest.platformCancelUrl, + platformBookingUrl: bookingRequest.platformBookingUrl, + platformBookingLocation: bookingRequest.platformBookingLocation, + }, }); createdBookings.forEach(async (booking) => { @@ -351,14 +359,15 @@ export class BookingsController_2024_04_15 { @Body() body: CreateBookingInput_2024_04_15, @Headers(X_CAL_CLIENT_ID) clientId?: string, @Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string - ): Promise>>> { + ): Promise> { const oAuthClientId = clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId)); req.userId = (await this.getOwnerId(req)) ?? -1; try { - const instantMeeting = await handleInstantMeeting( - await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed) - ); + const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed); + const instantMeeting = await this.instantBookingCreateService.createBooking({ + bookingData: bookingReq.body, + }); if (instantMeeting.userId && instantMeeting.bookingUid) { const now = new Date(); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts index d0d37f9e979247..3359fa8d0caa8d 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts @@ -16,6 +16,9 @@ import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_0 import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { InstantBookingModule } from "@/lib/modules/instant-booking.module"; +import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module"; +import { RegularBookingModule } from "@/lib/modules/regular-booking.module"; import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository"; import { AppsRepository } from "@/modules/apps/apps.repository"; import { BillingModule } from "@/modules/billing/billing.module"; @@ -58,6 +61,9 @@ import { Module } from "@nestjs/common"; TeamsEventTypesModule, MembershipsModule, ProfilesModule, + RegularBookingModule, + RecurringBookingModule, + InstantBookingModule, ], providers: [ TokensRepository, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts index 64ed229bcf76ba..42260bf1b4e450 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts @@ -87,7 +87,7 @@ import { @DocsTags("Bookings") @ApiHeader({ name: "cal-api-version", - description: `Must be set to ${VERSION_2024_08_13}`, + description: `Must be set to ${VERSION_2024_08_13}. If not set to this value, the endpoint will default to an older version.`, example: VERSION_2024_08_13, required: true, schema: { @@ -133,6 +133,8 @@ export class BookingsController_2024_08_13 { If you are creating a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. `, }) @ApiBody({ @@ -188,6 +190,8 @@ export class BookingsController_2024_08_13 { If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. `, }) async getBooking( @@ -206,7 +210,10 @@ export class BookingsController_2024_08_13 { @UseGuards(BookingUidGuard) @ApiOperation({ summary: "Get all the recordings for the booking", - description: `Fetches all the recordings for the booking \`:bookingUid\``, + description: `Fetches all the recordings for the booking \`:bookingUid\` + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise { const recordings = await this.calVideoService.getRecordings(bookingUid); @@ -221,7 +228,10 @@ export class BookingsController_2024_08_13 { @UseGuards(BookingUidGuard) @ApiOperation({ summary: "Get all the transcripts download links for the booking", - description: `Fetches all the transcripts download links for the booking \`:bookingUid\``, + description: `Fetches all the transcripts download links for the booking \`:bookingUid\` + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) async getBookingTranscripts(@Param("bookingUid") bookingUid: string): Promise { const transcripts = await this.calVideoService.getTranscripts(bookingUid); @@ -236,7 +246,10 @@ export class BookingsController_2024_08_13 { @UseGuards(ApiAuthGuard) @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) @Permissions([BOOKING_READ]) - @ApiOperation({ summary: "Get all bookings" }) + @ApiOperation({ + summary: "Get all bookings", + description: `Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.`, + }) async getBookings( @Query() queryParams: GetBookingsInput_2024_08_13, @GetUser() user: ApiAuthGuardUser @@ -263,7 +276,10 @@ export class BookingsController_2024_08_13 { @ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER) @ApiOperation({ summary: "Reschedule a booking", - description: "Reschedule a booking or seated booking", + description: `Reschedule a booking or seated booking + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) @ApiBody({ schema: { @@ -315,6 +331,8 @@ export class BookingsController_2024_08_13 { If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. `, }) @ApiBody({ @@ -350,7 +368,10 @@ export class BookingsController_2024_08_13 { @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) @ApiOperation({ summary: "Mark a booking absence", - description: "The provided authorization header refers to the owner of the booking.", + description: `The provided authorization header refers to the owner of the booking. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) async markNoShow( @Param("bookingUid") bookingUid: string, @@ -372,8 +393,10 @@ export class BookingsController_2024_08_13 { @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) @ApiOperation({ summary: "Reassign a booking to auto-selected host", - description: - "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.", + description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) async reassignBooking( @Param("bookingUid") bookingUid: string, @@ -394,8 +417,10 @@ export class BookingsController_2024_08_13 { @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) @ApiOperation({ summary: "Reassign a booking to a specific host", - description: - "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.", + description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) async reassignBookingToUser( @Param("bookingUid") bookingUid: string, @@ -423,7 +448,10 @@ export class BookingsController_2024_08_13 { @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) @ApiOperation({ summary: "Confirm a booking", - description: "The provided authorization header refers to the owner of the booking.", + description: `The provided authorization header refers to the owner of the booking. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) async confirmBooking( @Param("bookingUid") bookingUid: string, @@ -444,7 +472,10 @@ export class BookingsController_2024_08_13 { @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) @ApiOperation({ summary: "Decline a booking", - description: "The provided authorization header refers to the owner of the booking.", + description: `The provided authorization header refers to the owner of the booking. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) async declineBooking( @Param("bookingUid") bookingUid: string, @@ -465,8 +496,10 @@ export class BookingsController_2024_08_13 { @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) @ApiOperation({ summary: "Get 'Add to Calendar' links for a booking", - description: - "Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.", + description: `Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) @HttpCode(HttpStatus.OK) async getCalendarLinks(@Param("bookingUid") bookingUid: string): Promise { @@ -485,6 +518,7 @@ export class BookingsController_2024_08_13 { @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) @ApiOperation({ summary: "Get booking references", + description: `Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.`, }) @HttpCode(HttpStatus.OK) async getBookingReferences( diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts index 4cc21e3b59cd8a..dc57ee99c07f4d 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts @@ -2494,7 +2494,7 @@ describe("Bookings Endpoints 2024-08-13", () => { .expect(400); expect(response.body.error.message).toEqual( - `Missing attendee phone number - it is required by the event type. Pass it as \"attendee.phoneNumber\" string in the request.` + `Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" string in the request.` ); }); @@ -3026,13 +3026,154 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); }); + + describe("event type with max bookings count per booker", () => { + it("should not allow booking more than maximumActiveBookings per attendee", async () => { + const eventTypeIdWithMaxBookerBookings = await eventTypesRepositoryFixture.create( + { + slug: `max-bookings-count-per-booker-${randomString(10)}`, + length: 60, + title: "Event Type with max bookings count per booker", + maxActiveBookingsPerBooker: 1, + }, + user.id + ); + + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2040, 0, 13, 10, 0, 0)).toISOString(), + eventTypeId: eventTypeIdWithMaxBookerBookings.id, + attendee: { + name: "alice", + email: "alice@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + + const response2 = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + + expect(response2.body.error.message).toBe( + "Attendee with this email can't book because the maximum number of active bookings has been reached." + ); + + const body2: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2040, 0, 13, 12, 0, 0)).toISOString(), + eventTypeId: eventTypeIdWithMaxBookerBookings.id, + attendee: { + name: "bob", + email: "bob@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + }; + + const response3 = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body2) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBody2: CreateBookingOutput_2024_08_13 = response3.body; + expect(responseBody2.status).toEqual(SUCCESS_STATUS); + expect(responseBody2.data).toBeDefined(); + expect(responseDataIsBooking(responseBody2.data)).toBe(true); + + if (responseDataIsBooking(responseBody2.data)) { + const data: BookingOutput_2024_08_13 = responseBody2.data; + expect(data.id).toBeDefined(); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + + it("should not allow booking more than maximumActiveBookings per attendee and offer rescheduling", async () => { + const eventTypeIdWithMaxBookerBookings = await eventTypesRepositoryFixture.create( + { + slug: `max-bookings-count-per-booker-with-reschedule-${randomString(10)}`, + length: 60, + title: "Event Type with max bookings count per booker with reschedule", + maxActiveBookingsPerBooker: 1, + maxActiveBookingPerBookerOfferReschedule: true, + }, + user.id + ); + + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2040, 0, 13, 14, 0, 0)).toISOString(), + eventTypeId: eventTypeIdWithMaxBookerBookings.id, + attendee: { + name: "charlie", + email: "charlie@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + + const response2 = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + + expect(response2.body.error.message).toBe( + `Attendee with this email can't book because the maximum number of active bookings has been reached. You can reschedule your existing booking (${responseBody.data.uid}) to a new timeslot instead.` + ); + }); + }); }); }); - function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 { + function responseDataIsRecurranceBooking(data: unknown): data is RecurringBookingOutput_2024_08_13 { return ( !Array.isArray(data) && typeof data === "object" && + data !== null && data && "id" in data && "recurringBookingUid" in data @@ -3048,11 +3189,11 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); - function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { - return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data !== null && data && "id" in data; } - function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] { + function responseDataIsRecurringBooking(data: unknown): data is RecurringBookingOutput_2024_08_13[] { return Array.isArray(data); } }); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index b3a91219243983..b9d94bc8f0cb4d 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -6,6 +6,9 @@ import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servi import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service"; import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; import { getPagination } from "@/lib/pagination/pagination"; +import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service"; +import { RecurringBookingService } from "@/lib/services/recurring-booking.service"; +import { RegularBookingService } from "@/lib/services/regular-booking.service"; import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator"; import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; import { BillingService } from "@/modules/billing/services/billing.service"; @@ -37,10 +40,8 @@ import { DateTime } from "luxon"; import { z } from "zod"; import { - handleNewRecurringBooking, getTranslation, getAllUserBookings, - handleInstantMeeting, handleCancelBooking, roundRobinReassignment, roundRobinManualReassignment, @@ -48,7 +49,6 @@ import { confirmBookingHandler, getCalendarLinks, } from "@calcom/platform-libraries"; -import { handleNewBooking } from "@calcom/platform-libraries"; import { CreateBookingInput_2024_08_13, CreateBookingInput, @@ -109,7 +109,10 @@ export class BookingsService_2024_08_13 { private readonly teamsEventTypesRepository: TeamsEventTypesRepository, private readonly membershipsRepository: MembershipsRepository, private readonly membershipsService: MembershipsService, - private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13 + private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13, + private readonly regularBookingService: RegularBookingService, + private readonly recurringBookingService: RecurringBookingService, + private readonly instantBookingCreateService: InstantBookingCreateService ) {} async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) { @@ -464,7 +467,9 @@ export class BookingsService_2024_08_13 { } const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType); - const booking = await handleInstantMeeting(bookingRequest); + const booking = await this.instantBookingCreateService.createBooking({ + bookingData: bookingRequest.body, + }); const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent( booking.bookingId @@ -482,17 +487,19 @@ export class BookingsService_2024_08_13 { eventType: EventTypeWithOwnerAndTeam ) { const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType); - const bookings = await handleNewRecurringBooking({ + const bookings = await this.recurringBookingService.createBooking({ bookingData: bookingRequest.body, - userId: bookingRequest.userId, - hostname: bookingRequest.headers?.host || "", - platformClientId: bookingRequest.platformClientId, - platformRescheduleUrl: bookingRequest.platformRescheduleUrl, - platformCancelUrl: bookingRequest.platformCancelUrl, - platformBookingUrl: bookingRequest.platformBookingUrl, - platformBookingLocation: bookingRequest.platformBookingLocation, - noEmail: bookingRequest.noEmail, - areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + bookingMeta: { + userId: bookingRequest.userId, + hostname: bookingRequest.headers?.host || "", + platformClientId: bookingRequest.platformClientId, + platformRescheduleUrl: bookingRequest.platformRescheduleUrl, + platformCancelUrl: bookingRequest.platformCancelUrl, + platformBookingUrl: bookingRequest.platformBookingUrl, + platformBookingLocation: bookingRequest.platformBookingLocation, + noEmail: bookingRequest.noEmail, + areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + }, }); const ids = bookings.map((booking) => booking.id || 0); return this.outputService.getOutputRecurringBookings(ids); @@ -505,16 +512,18 @@ export class BookingsService_2024_08_13 { userIsEventTypeAdminOrOwner: boolean ) { const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType); - const bookings = await handleNewRecurringBooking({ + const bookings = await this.recurringBookingService.createBooking({ bookingData: bookingRequest.body, - userId: bookingRequest.userId, - hostname: bookingRequest.headers?.host || "", - platformClientId: bookingRequest.platformClientId, - platformRescheduleUrl: bookingRequest.platformRescheduleUrl, - platformCancelUrl: bookingRequest.platformCancelUrl, - platformBookingUrl: bookingRequest.platformBookingUrl, - platformBookingLocation: bookingRequest.platformBookingLocation, - areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + bookingMeta: { + userId: bookingRequest.userId, + hostname: bookingRequest.headers?.host || "", + platformClientId: bookingRequest.platformClientId, + platformRescheduleUrl: bookingRequest.platformRescheduleUrl, + platformCancelUrl: bookingRequest.platformCancelUrl, + platformBookingUrl: bookingRequest.platformBookingUrl, + platformBookingLocation: bookingRequest.platformBookingLocation, + areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + }, }); return this.outputService.getOutputCreateRecurringSeatedBookings( bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" })), @@ -528,16 +537,18 @@ export class BookingsService_2024_08_13 { eventType: EventTypeWithOwnerAndTeam ) { const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType); - const booking = await handleNewBooking({ + const booking = await this.regularBookingService.createBooking({ bookingData: bookingRequest.body, - userId: bookingRequest.userId, - hostname: bookingRequest.headers?.host || "", - platformClientId: bookingRequest.platformClientId, - platformRescheduleUrl: bookingRequest.platformRescheduleUrl, - platformCancelUrl: bookingRequest.platformCancelUrl, - platformBookingUrl: bookingRequest.platformBookingUrl, - platformBookingLocation: bookingRequest.platformBookingLocation, - areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + bookingMeta: { + userId: bookingRequest.userId, + hostname: bookingRequest.headers?.host || "", + platformClientId: bookingRequest.platformClientId, + platformRescheduleUrl: bookingRequest.platformRescheduleUrl, + platformCancelUrl: bookingRequest.platformCancelUrl, + platformBookingUrl: bookingRequest.platformBookingUrl, + platformBookingLocation: bookingRequest.platformBookingLocation, + areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + }, }); if (!booking.uid) { @@ -560,16 +571,18 @@ export class BookingsService_2024_08_13 { ) { const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType); try { - const booking = await handleNewBooking({ + const booking = await this.regularBookingService.createBooking({ bookingData: bookingRequest.body, - userId: bookingRequest.userId, - hostname: bookingRequest.headers?.host || "", - platformClientId: bookingRequest.platformClientId, - platformRescheduleUrl: bookingRequest.platformRescheduleUrl, - platformCancelUrl: bookingRequest.platformCancelUrl, - platformBookingUrl: bookingRequest.platformBookingUrl, - platformBookingLocation: bookingRequest.platformBookingLocation, - areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + bookingMeta: { + userId: bookingRequest.userId, + hostname: bookingRequest.headers?.host || "", + platformClientId: bookingRequest.platformClientId, + platformRescheduleUrl: bookingRequest.platformRescheduleUrl, + platformCancelUrl: bookingRequest.platformCancelUrl, + platformBookingUrl: bookingRequest.platformBookingUrl, + platformBookingLocation: bookingRequest.platformBookingLocation, + areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + }, }); if (!booking.uid) { @@ -755,16 +768,18 @@ export class BookingsService_2024_08_13 { bookingUid, body ); - const booking = await handleNewBooking({ + const booking = await this.regularBookingService.createBooking({ bookingData: bookingRequest.body, - userId: bookingRequest.userId, - hostname: bookingRequest.headers?.host || "", - platformClientId: bookingRequest.platformClientId, - platformRescheduleUrl: bookingRequest.platformRescheduleUrl, - platformCancelUrl: bookingRequest.platformCancelUrl, - platformBookingUrl: bookingRequest.platformBookingUrl, - platformBookingLocation: bookingRequest.platformBookingLocation, - areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + bookingMeta: { + userId: bookingRequest.userId, + hostname: bookingRequest.headers?.host || "", + platformClientId: bookingRequest.platformClientId, + platformRescheduleUrl: bookingRequest.platformRescheduleUrl, + platformCancelUrl: bookingRequest.platformCancelUrl, + platformBookingUrl: bookingRequest.platformBookingUrl, + platformBookingLocation: bookingRequest.platformBookingLocation, + areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + }, }); if (!booking.uid) { throw new Error("Booking missing uid"); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/errors.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/errors.service.ts index 0563358baf3b79..beeadd63950dc7 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/errors.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/errors.service.ts @@ -48,6 +48,19 @@ export class ErrorsBookingsService_2024_08_13 { throw new BadRequestException("Attempting to book a meeting in the past."); } else if (error.message === "hosts_unavailable_for_booking") { throw new BadRequestException(hostsUnavaile); + } else if (error.message === "booker_limit_exceeded_error") { + throw new BadRequestException( + "Attendee with this email can't book because the maximum number of active bookings has been reached." + ); + } else if (error.message === "booker_limit_exceeded_error_reschedule") { + const errorData = + "data" in error ? (error.data as { rescheduleUid: string }) : { rescheduleUid: undefined }; + let message = + "Attendee with this email can't book because the maximum number of active bookings has been reached."; + if (errorData?.rescheduleUid) { + message += ` You can reschedule your existing booking (${errorData.rescheduleUid}) to a new timeslot instead.`; + } + throw new BadRequestException(message); } } throw error; diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts index de185c48d62e63..b2a931e08c2ba1 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -817,7 +817,7 @@ export class InputBookingsService_2024_08_13 { // for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it. return { uid: bookingUid, - cancellationReason: "", + cancellationReason: inputBooking.cancellationReason || "", allRemainingBookings: false, seatReferenceUid: inputBooking.seatUid, }; diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts index 7a08114549febc..d7cc75cd79b92c 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts @@ -137,7 +137,11 @@ export class EventTypesService_2024_04_15 { !bookingFields.find((field) => field.type === "email") && !bookingFields.find((field) => field.type === "phone") ) { - bookingFields.push({ ...systemBeforeFieldEmail, type: BaseField.email, editable: Editable.system }); + bookingFields.push({ + ...systemBeforeFieldEmail, + type: BaseField.email, + editable: Editable.systemButOptional, + }); } await updateEventType({ diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts index 1e4f5e2509f4cc..8ac36da0d51e25 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts @@ -31,16 +31,18 @@ import { FrequencyInput, } from "@calcom/platform-enums"; import { SchedulingType } from "@calcom/platform-libraries"; -import type { - ApiSuccessResponse, - CreateEventTypeInput_2024_06_14, - EventTypeOutput_2024_06_14, - GuestsDefaultFieldOutput_2024_06_14, - NameDefaultFieldInput_2024_06_14, - NotesDefaultFieldInput_2024_06_14, - SplitNameDefaultFieldOutput_2024_06_14, - UpdateEventTypeInput_2024_06_14, +import { + type ApiSuccessResponse, + type CreateEventTypeInput_2024_06_14, + type EventTypeOutput_2024_06_14, + type GuestsDefaultFieldOutput_2024_06_14, + type NameDefaultFieldInput_2024_06_14, + type NotesDefaultFieldInput_2024_06_14, + type SplitNameDefaultFieldOutput_2024_06_14, + type UpdateEventTypeInput_2024_06_14, } from "@calcom/platform-types"; +import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit"; +import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled"; import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client"; const orderBySlug = (a: { slug: string }, b: { slug: string }) => { @@ -1442,6 +1444,177 @@ describe("Event types Endpoints", () => { .expect(200); }); + describe("bookerActiveBookingsLimit", () => { + describe("negative tests", () => { + it("should not create an event type with bookerActiveBookingsLimit and recurrence", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class with bookerActiveBookingsLimit", + slug: "coding-class-booker-active-bookings-limit", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + bookerActiveBookingsLimit: { + maximumActiveBookings: 2, + offerReschedule: true, + }, + recurrence: { + frequency: FrequencyInput.weekly, + interval: 2, + occurrences: 10, + }, + }; + + const response = await request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .set("Authorization", `Bearer ${apiKeyString}`) + .send(body) + .expect(400); + + expect( + response.body.error.message.includes(FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE) + ).toBe(true); + }); + + it("should not allow creating an event type with bookerActiveBookingsLimit disabled:false", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class with bookerActiveBookingsLimit disabled:false", + slug: "coding-class-booker-active-bookings-limit-disabled", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + // note(Lauris): disabled false means that it is enabled so it should have maximumActiveBookings and / or offerReschedule provided + bookerActiveBookingsLimit: { + disabled: false, + }, + }; + + const response = await request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .set("Authorization", `Bearer ${apiKeyString}`) + .send(body) + .expect(400); + + expect(response.body.error.message.includes(REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR)).toBe(true); + }); + }); + + describe("positive tests", () => { + let eventTypeWithBookerActiveBookingsLimitId: number; + + it("should create an event type with bookerActiveBookingsLimit", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class with bookerActiveBookingsLimit", + slug: "coding-class-booker-active-bookings-limit", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + bookerActiveBookingsLimit: { + maximumActiveBookings: 2, + offerReschedule: true, + }, + }; + + return request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .set("Authorization", `Bearer ${apiKeyString}`) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const createdEventType = responseBody.data; + expect(createdEventType).toHaveProperty("id"); + expect(createdEventType.title).toEqual(body.title); + expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit); + eventTypeWithBookerActiveBookingsLimitId = createdEventType.id; + }); + }); + + it("should update an event type with bookerActiveBookingsLimit", async () => { + const body: UpdateEventTypeInput_2024_06_14 = { + bookerActiveBookingsLimit: { + disabled: true, + }, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/event-types/${eventTypeWithBookerActiveBookingsLimitId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .set("Authorization", `Bearer ${apiKeyString}`) + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const updatedEventType = responseBody.data; + expect(updatedEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit); + eventTypeWithBookerActiveBookingsLimitId = updatedEventType.id; + }); + }); + + it("should create an event type with bookerActiveBookingsLimit and recurrence disabled", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class with bookerActiveBookingsLimit and recurrence disabled", + slug: "coding-class-booker-active-bookings-limit-recurrence-disabled", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + bookerActiveBookingsLimit: { + maximumActiveBookings: 2, + offerReschedule: true, + }, + recurrence: { + disabled: true, + }, + }; + + return request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .set("Authorization", `Bearer ${apiKeyString}`) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const createdEventType = responseBody.data; + expect(createdEventType).toHaveProperty("id"); + expect(createdEventType.title).toEqual(body.title); + expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit); + eventTypeWithBookerActiveBookingsLimitId = createdEventType.id; + }); + }); + + it("should create an event type with recurrence and bookerActiveBookingsLimit disabled", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class with bookerActiveBookingsLimit disabled and recurrence", + slug: "coding-class-booker-active-bookings-limit-disabled-and-recurrence", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + bookerActiveBookingsLimit: { + disabled: true, + }, + recurrence: { + frequency: FrequencyInput.weekly, + interval: 2, + occurrences: 10, + }, + }; + + return request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .set("Authorization", `Bearer ${apiKeyString}`) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const createdEventType = responseBody.data; + expect(createdEventType).toHaveProperty("id"); + expect(createdEventType.title).toEqual(body.title); + expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit); + eventTypeWithBookerActiveBookingsLimitId = createdEventType.id; + }); + }); + }); + }); + afterAll(async () => { await oauthClientRepositoryFixture.delete(oAuthClient.id); await teamRepositoryFixture.delete(organization.id); diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts index b338e3774a8549..b4c8d88a2a29e7 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts @@ -61,7 +61,7 @@ import { @DocsTags("Event Types") @ApiHeader({ name: "cal-api-version", - description: `Must be set to ${VERSION_2024_06_14}`, + description: `Must be set to ${VERSION_2024_06_14}. If not set to this value, the endpoint will default to an older version.`, example: VERSION_2024_06_14, required: true, schema: { @@ -80,7 +80,10 @@ export class EventTypesController_2024_06_14 { @Permissions([EVENT_TYPE_WRITE]) @UseGuards(ApiAuthGuard) @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) - @ApiOperation({ summary: "Create an event type" }) + @ApiOperation({ + summary: "Create an event type", + description: `Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.`, + }) async createEventType( @Body() body: CreateEventTypeInput_2024_06_14, @GetUser() user: UserWithProfile @@ -102,7 +105,10 @@ export class EventTypesController_2024_06_14 { @Permissions([EVENT_TYPE_READ]) @UseGuards(ApiAuthGuard) @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) - @ApiOperation({ summary: "Get an event type" }) + @ApiOperation({ + summary: "Get an event type", + description: `Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.`, + }) async getEventTypeById( @Param("eventTypeId") eventTypeId: string, @GetUser() user: UserWithProfile @@ -122,8 +128,10 @@ export class EventTypesController_2024_06_14 { @Get("/") @ApiOperation({ summary: "Get all event types", - description: - "Hidden event types are returned only if authentication is provided and it belongs to the event type owner.", + description: `Hidden event types are returned only if authentication is provided and it belongs to the event type owner. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) @UseGuards(OptionalApiAuthGuard) @ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER) @@ -149,7 +157,10 @@ export class EventTypesController_2024_06_14 { @UseGuards(ApiAuthGuard) @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Update an event type" }) + @ApiOperation({ + summary: "Update an event type", + description: `Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.`, + }) async updateEventType( @Param("eventTypeId", ParseIntPipe) eventTypeId: number, @Body() body: UpdateEventTypeInput_2024_06_14, @@ -173,7 +184,10 @@ export class EventTypesController_2024_06_14 { @Permissions([EVENT_TYPE_WRITE]) @UseGuards(ApiAuthGuard) @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) - @ApiOperation({ summary: "Delete an event type" }) + @ApiOperation({ + summary: "Delete an event type", + description: `Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.`, + }) async deleteEventType( @Param("eventTypeId") eventTypeId: number, @GetUser("id") userId: number diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts index c05e7c765b26fc..c904901e89000e 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts @@ -28,7 +28,11 @@ import { UserWithProfile } from "@/modules/users/users.repository"; import { Injectable, BadRequestException } from "@nestjs/common"; import { getApps, getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store"; -import { validateCustomEventName, EventTypeMetaDataSchema } from "@calcom/platform-libraries/event-types"; +import { + validateCustomEventName, + EventTypeMetaDataSchema, + EventTypeMetadata, +} from "@calcom/platform-libraries/event-types"; import { CreateEventTypeInput_2024_06_14, DestinationCalendar_2024_06_14, @@ -67,11 +71,13 @@ export class InputEventTypesService_2024_06_14 { eventName: transformedBody.eventName, }); - transformedBody.destinationCalendar && - (await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar)); + if (transformedBody.destinationCalendar) { + await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar); + } - transformedBody.useEventTypeDestinationCalendarEmail && - (await this.validateInputUseDestinationCalendarEmail(user.id)); + if (transformedBody.useEventTypeDestinationCalendarEmail) { + await this.validateInputUseDestinationCalendarEmail(user.id); + } return transformedBody; } @@ -93,11 +99,13 @@ export class InputEventTypesService_2024_06_14 { eventName: transformedBody.eventName, }); - transformedBody.destinationCalendar && - (await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar)); + if (transformedBody.destinationCalendar) { + await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar); + } - transformedBody.useEventTypeDestinationCalendarEmail && - (await this.validateInputUseDestinationCalendarEmail(user.id)); + if (transformedBody.useEventTypeDestinationCalendarEmail) { + await this.validateInputUseDestinationCalendarEmail(user.id); + } return transformedBody; } @@ -119,6 +127,7 @@ export class InputEventTypesService_2024_06_14 { customName, useDestinationCalendarEmail, disableGuests, + bookerActiveBookingsLimit, ...rest } = inputEventType; const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy); @@ -130,6 +139,17 @@ export class InputEventTypesService_2024_06_14 { ? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests) : bookingFields; + const maxActiveBookingsPerBooker = bookerActiveBookingsLimit + ? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit) + : {}; + + const metadata: EventTypeMetadata = { + bookerLayouts: this.transformInputBookerLayouts(bookerLayouts), + requiresConfirmationThreshold: + confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined, + multipleDuration: lengthInMinutesOptions, + }; + const eventType = { ...rest, length: lengthInMinutes, @@ -140,12 +160,7 @@ export class InputEventTypesService_2024_06_14 { ? this.transformInputIntervalLimits(bookingLimitsDuration) : undefined, ...this.transformInputBookingWindow(bookingWindow), - metadata: { - bookerLayouts: this.transformInputBookerLayouts(bookerLayouts), - requiresConfirmationThreshold: - confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined, - multipleDuration: lengthInMinutesOptions, - }, + metadata, requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined, requiresConfirmationWillBlockSlot: confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined, @@ -154,11 +169,27 @@ export class InputEventTypesService_2024_06_14 { ...this.transformInputSeatOptions(seats), eventName: customName, useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail, + ...maxActiveBookingsPerBooker, }; return eventType; } + transformInputBookerActiveBookingsLimit( + bookerActiveBookingsLimit: CreateEventTypeInput_2024_06_14["bookerActiveBookingsLimit"] + ) { + if (!bookerActiveBookingsLimit || bookerActiveBookingsLimit.disabled) { + return { + maxActiveBookingsPerBooker: null, + maxActiveBookingPerBookerOfferReschedule: false, + }; + } + return { + maxActiveBookingsPerBooker: bookerActiveBookingsLimit?.maximumActiveBookings, + maxActiveBookingPerBookerOfferReschedule: bookerActiveBookingsLimit?.offerReschedule, + }; + } + async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) { const { lengthInMinutes, @@ -176,10 +207,11 @@ export class InputEventTypesService_2024_06_14 { customName, useDestinationCalendarEmail, disableGuests, + bookerActiveBookingsLimit, ...rest } = inputEventType; const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId); - const metadataTransformed = !!eventTypeDb?.metadata + const metadataTransformed = eventTypeDb?.metadata ? EventTypeMetaDataSchema.parse(eventTypeDb.metadata) : {}; @@ -190,6 +222,18 @@ export class InputEventTypesService_2024_06_14 { ? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests) : bookingFields; + const maxActiveBookingsPerBooker = bookerActiveBookingsLimit + ? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit) + : {}; + + const metadata: EventTypeMetadata = { + ...metadataTransformed, + bookerLayouts: this.transformInputBookerLayouts(bookerLayouts), + requiresConfirmationThreshold: + confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined, + multipleDuration: lengthInMinutesOptions, + }; + const eventType = { ...rest, length: lengthInMinutes, @@ -202,13 +246,7 @@ export class InputEventTypesService_2024_06_14 { ? this.transformInputIntervalLimits(bookingLimitsDuration) : undefined, ...this.transformInputBookingWindow(bookingWindow), - metadata: { - ...metadataTransformed, - bookerLayouts: this.transformInputBookerLayouts(bookerLayouts), - requiresConfirmationThreshold: - confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined, - multipleDuration: lengthInMinutesOptions, - }, + metadata, recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined, requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined, requiresConfirmationWillBlockSlot: @@ -217,6 +255,7 @@ export class InputEventTypesService_2024_06_14 { ...this.transformInputSeatOptions(seats), eventName: customName, useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail, + ...maxActiveBookingsPerBooker, }; return eventType; @@ -319,7 +358,7 @@ export class InputEventTypesService_2024_06_14 { transformInputBookingWindow(inputBookingWindow: CreateEventTypeInput_2024_06_14["bookingWindow"]) { const res = transformFutureBookingLimitsApiToInternal(inputBookingWindow); - return !!res ? res : {}; + return res ? res : {}; } transformInputBookerLayouts(inputBookerLayouts: CreateEventTypeInput_2024_06_14["bookerLayouts"]) { @@ -369,7 +408,7 @@ export class InputEventTypesService_2024_06_14 { requiresConfirmationDb = eventTypeDb?.requiresConfirmation ?? false; } - const seatsPerTimeSlotFinal = !!seatsPerTimeSlot ? seatsPerTimeSlot : seatsPerTimeSlotDb; + const seatsPerTimeSlotFinal = seatsPerTimeSlot ? seatsPerTimeSlot : seatsPerTimeSlotDb; const seatsEnabledFinal = !!seatsPerTimeSlotFinal && seatsPerTimeSlotFinal > 0; const locationsFinal = locations !== undefined ? locations : locationsDb; @@ -394,6 +433,7 @@ export class InputEventTypesService_2024_06_14 { ); } } + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformLocations(locations: any) { if (!locations) return []; diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts index b0db6cc1ecc7f8..d53d14e4e037fd 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts @@ -92,6 +92,8 @@ type Input = Pick< | "calVideoSettings" | "hidden" | "bookingRequiresAuthentication" + | "maxActiveBookingsPerBooker" + | "maxActiveBookingPerBookerOfferReschedule" >; @Injectable() @@ -165,6 +167,7 @@ export class OutputEventTypesService_2024_06_14 { periodEndDate: databaseEventType.periodEndDate, } as TransformFutureBookingsLimitSchema_2024_06_14); const destinationCalendar = this.transformDestinationCalendar(databaseEventType.destinationCalendar); + const bookerActiveBookingsLimit = this.transformBookerActiveBookingsLimit(databaseEventType); return { id, @@ -210,9 +213,29 @@ export class OutputEventTypesService_2024_06_14 { calVideoSettings, hidden, bookingRequiresAuthentication, + bookerActiveBookingsLimit, }; } + transformBookerActiveBookingsLimit(databaseEventType: Input) { + const noMaxActiveBookingsPerBooker = + !databaseEventType.maxActiveBookingsPerBooker && databaseEventType.maxActiveBookingsPerBooker !== 0; + const noMaxActiveBookingPerBookerOfferReschedule = + !databaseEventType.maxActiveBookingPerBookerOfferReschedule; + + if (noMaxActiveBookingsPerBooker && noMaxActiveBookingPerBookerOfferReschedule) { + return { + disabled: true, + }; + } + + return { + maximumActiveBookings: databaseEventType.maxActiveBookingsPerBooker ?? undefined, + offerReschedule: databaseEventType.maxActiveBookingPerBookerOfferReschedule ?? undefined, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformLocations(locationDb: any) { if (!locationDb) return []; @@ -239,6 +262,7 @@ export class OutputEventTypesService_2024_06_14 { }; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformBookingFields(bookingFields: any) { if (!bookingFields) return []; @@ -273,6 +297,7 @@ export class OutputEventTypesService_2024_06_14 { return this.transformBookingFields(defaultBookingFields); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformRecurringEvent(recurringEvent: any) { if (!recurringEvent) return null; const recurringEventParsed = parseRecurringEvent(recurringEvent); @@ -280,6 +305,7 @@ export class OutputEventTypesService_2024_06_14 { return transformRecurrenceInternalToApi(recurringEventParsed); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformMetadata(metadata: any) { if (!metadata) return {}; return EventTypeMetaDataSchema.parse(metadata); @@ -301,6 +327,7 @@ export class OutputEventTypesService_2024_06_14 { }); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformIntervalLimits(bookingLimits: any) { const bookingLimitsParsed = parseBookingLimit(bookingLimits); return transformIntervalLimitsInternalToApi(bookingLimitsParsed); @@ -327,6 +354,7 @@ export class OutputEventTypesService_2024_06_14 { ); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformEventTypeColor(eventTypeColor: any) { if (!eventTypeColor) return undefined; const parsedeventTypeColor = parseEventTypeColor(eventTypeColor); diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformed/event-type.tranformed.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformed/event-type.tranformed.ts index 65a83998732fc2..c613b6d71cc6db 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformed/event-type.tranformed.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformed/event-type.tranformed.ts @@ -38,6 +38,8 @@ export type InputEventTransformed_2024_06_14 = Omit< bookingFields?: ReturnType; durationLimits?: ReturnType; recurringEvent?: ReturnType; + maxActiveBookingsPerBooker?: number; + maxActiveBookingPerBookerOfferReschedule?: boolean; eventTypeColor?: ReturnType; useEventTypeDestinationCalendarEmail?: boolean; } & Partial< diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformers/internal-to-api/booking-fields.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformers/internal-to-api/booking-fields.ts index 73ab5e9009516c..c83bcc4a3a926c 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformers/internal-to-api/booking-fields.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformers/internal-to-api/booking-fields.ts @@ -556,7 +556,7 @@ export const systemBeforeFieldEmail: EmailSystemField = { type: "email", name: "email", required: true, - editable: "system", + editable: "system-but-optional", sources: [ { label: "Default", diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts index 0cced597528140..b4c8da13bc5eb7 100644 --- a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts +++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts @@ -40,7 +40,7 @@ import { @DocsTags("Schedules") @ApiHeader({ name: "cal-api-version", - description: `Must be set to ${VERSION_2024_06_11}`, + description: `Must be set to ${VERSION_2024_06_11}. If not set to this value, the endpoint will default to an older version.`, example: VERSION_2024_06_11, required: true, schema: { @@ -69,6 +69,8 @@ export class SchedulesController_2024_06_11 { After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint. When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. `, }) async createSchedule( @@ -91,7 +93,10 @@ export class SchedulesController_2024_06_11 { }) @ApiOperation({ summary: "Get default schedule", - description: "Get the default schedule of the authenticated user.", + description: `Get the default schedule of the authenticated user. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise { const schedule = await this.schedulesService.getUserScheduleDefault(user.id); @@ -104,7 +109,10 @@ export class SchedulesController_2024_06_11 { @Get("/:scheduleId") @Permissions([SCHEDULE_READ]) - @ApiOperation({ summary: "Get a schedule" }) + @ApiOperation({ + summary: "Get a schedule", + description: `Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.`, + }) async getSchedule( @GetUser() user: UserWithProfile, @Param("scheduleId") scheduleId: number @@ -121,7 +129,10 @@ export class SchedulesController_2024_06_11 { @Permissions([SCHEDULE_READ]) @ApiOperation({ summary: "Get all schedules", - description: "Get all schedules of the authenticated user.", + description: `Get all schedules of the authenticated user. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) async getSchedules(@GetUser() user: UserWithProfile): Promise { const schedules = await this.schedulesService.getUserSchedules(user.id); @@ -134,7 +145,10 @@ export class SchedulesController_2024_06_11 { @Patch("/:scheduleId") @Permissions([SCHEDULE_WRITE]) - @ApiOperation({ summary: "Update a schedule" }) + @ApiOperation({ + summary: "Update a schedule", + description: `Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.`, + }) async updateSchedule( @GetUser() user: UserWithProfile, @Body() bodySchedule: UpdateScheduleInput_2024_06_11, @@ -155,7 +169,10 @@ export class SchedulesController_2024_06_11 { @Delete("/:scheduleId") @HttpCode(HttpStatus.OK) @Permissions([SCHEDULE_WRITE]) - @ApiOperation({ summary: "Delete a schedule" }) + @ApiOperation({ + summary: "Delete a schedule", + description: `Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.`, + }) async deleteSchedule( @GetUser("id") userId: number, @Param("scheduleId") scheduleId: number diff --git a/apps/api/v2/src/lib/modules/booking-cancel.module.ts b/apps/api/v2/src/lib/modules/booking-cancel.module.ts new file mode 100644 index 00000000000000..4151789caee90f --- /dev/null +++ b/apps/api/v2/src/lib/modules/booking-cancel.module.ts @@ -0,0 +1,10 @@ +import { BookingCancelService } from "@/lib/services/booking-cancel.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [BookingCancelService], + exports: [BookingCancelService], +}) +export class BookingCancelModule {} diff --git a/apps/api/v2/src/lib/modules/instant-booking.module.ts b/apps/api/v2/src/lib/modules/instant-booking.module.ts new file mode 100644 index 00000000000000..e7b3e652448dff --- /dev/null +++ b/apps/api/v2/src/lib/modules/instant-booking.module.ts @@ -0,0 +1,8 @@ +import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service"; +import { Module } from "@nestjs/common"; + +@Module({ + providers: [InstantBookingCreateService], + exports: [InstantBookingCreateService], +}) +export class InstantBookingModule {} diff --git a/apps/api/v2/src/lib/modules/recurring-booking.module.ts b/apps/api/v2/src/lib/modules/recurring-booking.module.ts new file mode 100644 index 00000000000000..bcb5d43f821e6d --- /dev/null +++ b/apps/api/v2/src/lib/modules/recurring-booking.module.ts @@ -0,0 +1,10 @@ +import { RegularBookingModule } from "@/lib/modules/regular-booking.module"; +import { RecurringBookingService } from "@/lib/services/recurring-booking.service"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [RegularBookingModule], + providers: [RecurringBookingService], + exports: [RecurringBookingService], +}) +export class RecurringBookingModule {} diff --git a/apps/api/v2/src/lib/modules/regular-booking.module.ts b/apps/api/v2/src/lib/modules/regular-booking.module.ts new file mode 100644 index 00000000000000..01618100d1016a --- /dev/null +++ b/apps/api/v2/src/lib/modules/regular-booking.module.ts @@ -0,0 +1,32 @@ +import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository"; +import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; +import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; +import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository"; +import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; +import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; +import { CacheService } from "@/lib/services/cache.service"; +import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service"; +import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; +import { LuckyUserService } from "@/lib/services/lucky-user.service"; +import { RegularBookingService } from "@/lib/services/regular-booking.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [ + PrismaAttributeRepository, + PrismaBookingRepository, + PrismaFeaturesRepository, + PrismaHostRepository, + PrismaOOORepository, + PrismaUserRepository, + CacheService, + CheckBookingAndDurationLimitsService, + CheckBookingLimitsService, + LuckyUserService, + RegularBookingService, + ], + exports: [RegularBookingService], +}) +export class RegularBookingModule {} diff --git a/apps/api/v2/src/lib/services/booking-cancel.service.ts b/apps/api/v2/src/lib/services/booking-cancel.service.ts new file mode 100644 index 00000000000000..1457ad2468b85e --- /dev/null +++ b/apps/api/v2/src/lib/services/booking-cancel.service.ts @@ -0,0 +1,15 @@ +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +import { BookingCancelService as BaseBookingCancelService } from "@calcom/features/bookings/lib/handleCancelBooking"; + +@Injectable() +export class BookingCancelService extends BaseBookingCancelService { + constructor( + prismaWriteService: PrismaWriteService, + ) { + super({ + prismaClient: prismaWriteService.prisma, + }); + } +} diff --git a/apps/api/v2/src/lib/services/instant-booking-create.service.ts b/apps/api/v2/src/lib/services/instant-booking-create.service.ts new file mode 100644 index 00000000000000..e09e7f6ab1a549 --- /dev/null +++ b/apps/api/v2/src/lib/services/instant-booking-create.service.ts @@ -0,0 +1,6 @@ +import { Injectable } from "@nestjs/common"; + +import { InstantBookingCreateService as BaseInstantBookingCreateService } from "@calcom/platform-libraries/bookings"; + +@Injectable() +export class InstantBookingCreateService extends BaseInstantBookingCreateService {} diff --git a/apps/api/v2/src/lib/services/recurring-booking.service.ts b/apps/api/v2/src/lib/services/recurring-booking.service.ts new file mode 100644 index 00000000000000..d40b53db4536ec --- /dev/null +++ b/apps/api/v2/src/lib/services/recurring-booking.service.ts @@ -0,0 +1,13 @@ +import { RegularBookingService } from "@/lib/services/regular-booking.service"; +import { Injectable } from "@nestjs/common"; + +import { RecurringBookingService as BaseRecurringBookingService } from "@calcom/platform-libraries/bookings"; + +@Injectable() +export class RecurringBookingService extends BaseRecurringBookingService { + constructor(regularBookingService: RegularBookingService) { + super({ + regularBookingService, + }); + } +} diff --git a/apps/api/v2/src/lib/services/regular-booking.service.ts b/apps/api/v2/src/lib/services/regular-booking.service.ts new file mode 100644 index 00000000000000..f18775301b12cc --- /dev/null +++ b/apps/api/v2/src/lib/services/regular-booking.service.ts @@ -0,0 +1,46 @@ +import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository"; +import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; +import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; +import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository"; +import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; +import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; +import { CacheService } from "@/lib/services/cache.service"; +import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service"; +import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; +import { LuckyUserService } from "@/lib/services/lucky-user.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +import { RegularBookingService as BaseRegularBookingService } from "@calcom/platform-libraries/bookings"; +import type { PrismaClient } from "@calcom/prisma"; + +@Injectable() +export class RegularBookingService extends BaseRegularBookingService { + constructor( + cacheService: CacheService, + checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService, + prismaWriteService: PrismaWriteService, + bookingRepository: PrismaBookingRepository, + featuresRepository: PrismaFeaturesRepository, + checkBookingLimitsService: CheckBookingLimitsService, + luckyUserService: LuckyUserService, + hostRepository: PrismaHostRepository, + oooRepository: PrismaOOORepository, + userRepository: PrismaUserRepository, + attributeRepository: PrismaAttributeRepository + ) { + super({ + cacheService, + checkBookingAndDurationLimitsService, + prismaClient: prismaWriteService.prisma as unknown as PrismaClient, + bookingRepository, + featuresRepository, + checkBookingLimitsService, + luckyUserService, + hostRepository, + oooRepository, + userRepository, + attributeRepository, + }); + } +} diff --git a/apps/api/v2/src/modules/atoms/controllers/atoms.schedules.controller.ts b/apps/api/v2/src/modules/atoms/controllers/atoms.schedules.controller.ts index a30a94fbaa8654..227a2decd60eda 100644 --- a/apps/api/v2/src/modules/atoms/controllers/atoms.schedules.controller.ts +++ b/apps/api/v2/src/modules/atoms/controllers/atoms.schedules.controller.ts @@ -11,6 +11,7 @@ import { Param, ParseIntPipe, Patch, + Post, Query, UseGuards, Version, @@ -21,8 +22,16 @@ import { ApiExcludeController as DocsExcludeController, ApiOperation, } from "@nestjs/swagger"; +import { z } from "zod"; import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { + GetAvailabilityListHandlerReturn, + DuplicateScheduleHandlerReturn, +} from "@calcom/platform-libraries/schedules"; +import { getAvailabilityListHandler, duplicateScheduleHandler } from "@calcom/platform-libraries/schedules"; +import type { CreateScheduleHandlerReturn, CreateScheduleSchema } from "@calcom/platform-libraries/schedules"; +import { createScheduleHandler } from "@calcom/platform-libraries/schedules"; import { FindDetailedScheduleByIdReturnType } from "@calcom/platform-libraries/schedules"; import { ApiResponse, UpdateAtomScheduleDto } from "@calcom/platform-types"; @@ -63,6 +72,22 @@ export class AtomsSchedulesController { data: schedule, }; } + + @Get("/schedules/all") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + @Permissions([SCHEDULE_READ]) + async getAllUserSchedules( + @GetUser() user: UserWithProfile + ): Promise>> { + const userSchedules = await getAvailabilityListHandler({ ctx: { user } }); + + return { + status: SUCCESS_STATUS, + data: userSchedules, + }; + } + @Patch("schedules/:scheduleId") @Permissions([SCHEDULE_WRITE]) @UseGuards(ApiAuthGuard) @@ -83,4 +108,36 @@ export class AtomsSchedulesController { data: updatedSchedule, }; } + + @Post("schedules/create") + @Permissions([SCHEDULE_WRITE]) + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Create atom schedule" }) + async createSchedule( + @GetUser() user: UserWithProfile, + @Body() bodySchedule: z.infer + ): Promise> { + const createdSchedule = await createScheduleHandler({ input: bodySchedule, ctx: { user } }); + + return { + status: SUCCESS_STATUS, + data: createdSchedule, + }; + } + + @Post("schedules/:scheduleId/duplicate") + @Permissions([SCHEDULE_WRITE]) + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Duplicate existing schedule" }) + async duplicateExistingSchedule( + @GetUser() user: UserWithProfile, + @Param("scheduleId", ParseIntPipe) scheduleId: number + ): Promise>> { + const duplicatedSchedule = await duplicateScheduleHandler({ ctx: { user }, input: { scheduleId } }); + + return { + status: SUCCESS_STATUS, + data: duplicatedSchedule, + }; + } } 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/event-types/organizations-member-team-admin-event-types.e2e-spec.ts b/apps/api/v2/src/modules/organizations/event-types/organizations-member-team-admin-event-types.e2e-spec.ts index 43edaf2530ec97..db232ff21f1d3b 100644 --- a/apps/api/v2/src/modules/organizations/event-types/organizations-member-team-admin-event-types.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/event-types/organizations-member-team-admin-event-types.e2e-spec.ts @@ -355,6 +355,11 @@ describe("Organizations Event Types Endpoints", () => { darkThemeHex: "#292929", lightThemeHex: "#fafafa", }, + emailSettings: { + disableEmailsToAttendees: true, + disableEmailsToHosts: true, + }, + rescheduleWithSameRoundRobinHost: true, }; return request(app.getHttpServer()) @@ -385,6 +390,7 @@ describe("Organizations Event Types Endpoints", () => { expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage); expect(data.color).toEqual(body.color); expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234"); + expect(data.emailSettings).toEqual(body.emailSettings); collectiveEventType = responseBody.data; }); }); @@ -600,6 +606,11 @@ describe("Organizations Event Types Endpoints", () => { const body: UpdateTeamEventTypeInput_2024_06_14 = { hosts: newHosts, successRedirectUrl: "https://new-url-success.com", + emailSettings: { + disableEmailsToAttendees: false, + disableEmailsToHosts: false, + }, + rescheduleWithSameRoundRobinHost: false, }; return request(app.getHttpServer()) @@ -614,6 +625,8 @@ describe("Organizations Event Types Endpoints", () => { expect(eventType.successRedirectUrl).toEqual("https://new-url-success.com"); expect(eventType.title).toEqual(collectiveEventType.title); expect(eventType.hosts.length).toEqual(1); + expect(eventType.emailSettings).toEqual(body.emailSettings); + expect(eventType.rescheduleWithSameRoundRobinHost).toEqual(body.rescheduleWithSameRoundRobinHost); evaluateHost(eventType.hosts[0], newHosts[0]); }); }); diff --git a/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts b/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts index c5267015f4fbc4..0d6699a6fd6f58 100644 --- a/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts +++ b/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts @@ -8,10 +8,12 @@ import { UsersRepository } from "@/modules/users/users.repository"; import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { SchedulingType } from "@calcom/platform-libraries"; +import { EventTypeMetadata } from "@calcom/platform-libraries/event-types"; import { CreateTeamEventTypeInput_2024_06_14, UpdateTeamEventTypeInput_2024_06_14, HostPriority, + EmailSettings_2024_06_14, } from "@calcom/platform-types"; export type TransformedCreateTeamEventTypeInput = Awaited< @@ -49,14 +51,16 @@ export class InputOrganizationsEventTypesService { eventName: transformedBody.eventName, }); - transformedBody.destinationCalendar && - (await this.inputEventTypesService.validateInputDestinationCalendar( + if (transformedBody.destinationCalendar) { + await this.inputEventTypesService.validateInputDestinationCalendar( userId, transformedBody.destinationCalendar - )); + ); + } - transformedBody.useEventTypeDestinationCalendarEmail && - (await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId)); + if (transformedBody.useEventTypeDestinationCalendarEmail) { + await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId); + } return transformedBody; } @@ -83,14 +87,16 @@ export class InputOrganizationsEventTypesService { eventName: transformedBody.eventName, }); - transformedBody.destinationCalendar && - (await this.inputEventTypesService.validateInputDestinationCalendar( + if (transformedBody.destinationCalendar) { + await this.inputEventTypesService.validateInputDestinationCalendar( userId, transformedBody.destinationCalendar - )); + ); + } - transformedBody.useEventTypeDestinationCalendarEmail && - (await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId)); + if (transformedBody.useEventTypeDestinationCalendarEmail) { + await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId); + } return transformedBody; } @@ -110,7 +116,7 @@ export class InputOrganizationsEventTypesService { teamId: number, inputEventType: CreateTeamEventTypeInput_2024_06_14 ) { - const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType; + const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType; const eventType = this.inputEventTypesService.transformInputCreateEventType(rest); @@ -123,11 +129,15 @@ export class InputOrganizationsEventTypesService { const children = await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId); - const metadata = + let metadata = rest.schedulingType === "MANAGED" ? { managedEventConfig: {}, ...eventType.metadata } : eventType.metadata; + if (emailSettings) { + metadata = this.addEmailSettingsToMetadata(emailSettings, metadata); + } + const teamEventType = { ...eventType, // note(Lauris): we don't populate hosts for managed event-types because they are handled by the children @@ -145,12 +155,42 @@ export class InputOrganizationsEventTypesService { return teamEventType; } + private addEmailSettingsToMetadata( + emailSettings: EmailSettings_2024_06_14, + metadata: NonNullable + ) { + if ( + emailSettings?.disableEmailsToAttendees === undefined && + emailSettings?.disableEmailsToHosts === undefined + ) { + return metadata; + } + + const clonedMetadata = structuredClone(metadata); + + if (!clonedMetadata.disableStandardEmails) { + clonedMetadata.disableStandardEmails = {}; + } + if (!clonedMetadata.disableStandardEmails.all) { + clonedMetadata.disableStandardEmails.all = {}; + } + + if (emailSettings?.disableEmailsToAttendees !== undefined) { + clonedMetadata.disableStandardEmails.all.attendee = emailSettings.disableEmailsToAttendees; + } + if (emailSettings?.disableEmailsToHosts !== undefined) { + clonedMetadata.disableStandardEmails.all.host = emailSettings.disableEmailsToHosts; + } + + return clonedMetadata; + } + async transformInputUpdateTeamEventType( eventTypeId: number, teamId: number, inputEventType: UpdateTeamEventTypeInput_2024_06_14 ) { - const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType; + const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType; const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId); const dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId); @@ -164,6 +204,12 @@ export class InputOrganizationsEventTypesService { ? await this.getChildEventTypesForManagedEventTypeUpdate(eventTypeId, inputEventType, teamId) : undefined; + let metadata = eventType.metadata; + + if (emailSettings) { + metadata = this.addEmailSettingsToMetadata(emailSettings, metadata); + } + const teamEventType = { ...eventType, // note(Lauris): we don't populate hosts for managed event-types because they are handled by the children @@ -175,6 +221,7 @@ export class InputOrganizationsEventTypesService { assignAllTeamMembers, children, locations: locations ? this.transformInputTeamLocations(locations) : undefined, + metadata, }; return teamEventType; diff --git a/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts b/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts index 67cf769e1eb2ef..06776e5e301ef1 100644 --- a/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts +++ b/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts @@ -4,6 +4,7 @@ import { UsersRepository } from "@/modules/users/users.repository"; import { Injectable } from "@nestjs/common"; import { SchedulingType } from "@calcom/platform-libraries"; +import { EventTypeMetadata } from "@calcom/platform-libraries/event-types"; import type { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types"; import type { Team, @@ -84,6 +85,9 @@ type Input = Pick< | "calVideoSettings" | "hidden" | "bookingRequiresAuthentication" + | "rescheduleWithSameRoundRobinHost" + | "maxActiveBookingPerBookerOfferReschedule" + | "maxActiveBookingsPerBooker" >; @Injectable() @@ -95,7 +99,12 @@ export class OutputOrganizationsEventTypesService { ) {} async getResponseTeamEventType(databaseEventType: Input, isOrgTeamEvent: boolean) { - const { teamId, userId, parentId, assignAllTeamMembers } = databaseEventType; + const metadata = this.outputEventTypesService.transformMetadata(databaseEventType.metadata); + + const emailSettings = this.transformEmailSettings(metadata); + + const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost } = + databaseEventType; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType( 0, @@ -117,6 +126,7 @@ export class OutputOrganizationsEventTypesService { ? this.getResponseSchedulingType(databaseEventType.schedulingType) : databaseEventType.schedulingType, assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined, + emailSettings, team: { id: teamId, name: databaseEventType?.team?.name, @@ -128,6 +138,7 @@ export class OutputOrganizationsEventTypesService { darkBrandColor: databaseEventType?.team?.darkBrandColor, theme: databaseEventType?.team?.theme, }, + rescheduleWithSameRoundRobinHost, }; } @@ -194,6 +205,23 @@ export class OutputOrganizationsEventTypesService { return transformedHosts; } + + private transformEmailSettings(metadata: EventTypeMetadata) { + if (!metadata?.disableStandardEmails?.all) { + return undefined; + } + + const { attendee, host } = metadata.disableStandardEmails.all; + + if (attendee !== undefined || host !== undefined) { + return { + disableEmailsToAttendees: attendee ?? false, + disableEmailsToHosts: host ?? false, + }; + } + + return undefined; + } } function getPriorityLabel(priority: number): keyof typeof HostPriority { 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..89744b284f9512 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,44 @@ 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, + FORM_SUBMITTED_NO_EVENT, OnAfterEventTriggerDto, OnBeforeEventTriggerDto, + OnFormSubmittedNoEventTriggerDto, + 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 +73,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 +101,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 +190,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 +263,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 +312,9 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => { if (createdWorkflowId) { try { await workflowsRepositoryFixture.delete(createdWorkflowId); - } catch (error) {} + } catch { + /* empty */ + } } await userRepositoryFixture.deleteByEmail(user.email); @@ -235,18 +329,227 @@ 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: GetEventTypeWorkflowOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + 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.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 routing form workflow with allowed actions and offset trigger", async () => { + const validWorkflow = structuredClone( + sampleCreateWorkflowRoutingFormDto + ) as unknown as CreateFormWorkflowDto; + 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}.

", + }, + }, + ]; + + validWorkflow.trigger = { + type: FORM_SUBMITTED_NO_EVENT, + offset: { + value: 1, + unit: DAY, + }, + }; + return request(app.getHttpServer()) + .post(`${basePath}/routing-form`) + .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) + .send(validWorkflow) .expect(201) .then((response) => { - const responseBody: GetWorkflowOutput = response.body; + const responseBody: GetRoutingFormWorkflowOutput = 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.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(validWorkflow.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" ); - expect(responseBody.data.trigger.type).toEqual(sampleCreateWorkflowDto.trigger.type); - expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowDto.steps.length); + + const trigger = validWorkflow.trigger as OnFormSubmittedNoEventTriggerDto; + expect(responseBody.data.trigger?.type).toEqual(trigger.type); + expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit); + expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value); + }); + }); + + 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 +563,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 +572,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 +598,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 +641,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 +680,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 +694,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 +735,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 +768,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 +880,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 +899,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 +913,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/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts index 27c4e0b4e75d32..fba616befd544d 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts @@ -25,7 +25,12 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository. import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { randomString } from "test/utils/randomString"; -import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants"; +import { + CAL_API_VERSION_HEADER, + SUCCESS_STATUS, + VERSION_2024_09_04, + ERROR_STATUS, +} from "@calcom/platform-constants"; import type { CreateScheduleInput_2024_06_11, ReserveSlotOutput_2024_09_04 as ReserveSlotOutputData_2024_09_04, @@ -48,6 +53,7 @@ describe("Slots 2024-09-04 Endpoints", () => { const teammateEmailOne = `slots-2024-09-04-user-1-team-slots-${randomString()}`; let teammateApiKeyString: string; const teammateEmailTwo = `slots-2024-09-04-user-2-team-slots-${randomString()}`; + let teammateTwoApiKeyString: string; const outsiderEmail = `slots-2024-09-04-unrelated-team-slots-${randomString()}`; let outsider: User; @@ -61,6 +67,8 @@ describe("Slots 2024-09-04 Endpoints", () => { let collectiveEventTypeSlug: string; let collectiveEventTypeWithoutHostsId: number; let roundRobinEventTypeId: number; + let roundRobinEventTypeWithoutFixedHostsId: number; + let roundRobinEventTypeWithFixedAndNonFixedHostsId: number; let collectiveBookingId: number; let roundRobinBookingId: number; let fullyBookedRoundRobinBookingIdOne: number; @@ -115,6 +123,12 @@ describe("Slots 2024-09-04 Endpoints", () => { const { keyString } = await apiKeysRepositoryFixture.createApiKey(teammateOne.id, null); teammateApiKeyString = keyString; + const { keyString: keyStringForTeammateTwo } = await apiKeysRepositoryFixture.createApiKey( + teammateTwo.id, + null + ); + teammateTwoApiKeyString = keyStringForTeammateTwo; + const { keyString: unrelatedUserKeyString } = await apiKeysRepositoryFixture.createApiKey( outsider.id, null @@ -217,6 +231,67 @@ describe("Slots 2024-09-04 Endpoints", () => { }); roundRobinEventTypeId = roundRobinEventType.id; + const roundRobinEventTypeWithoutFixedHosts = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "ROUND_ROBIN", + team: { + connect: { id: team.id }, + }, + title: "RR Event Type Without Fixed Hosts", + slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`, + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + users: { + connect: [{ id: teammateOne.id }, { id: teammateTwo.id }], + }, + hosts: { + create: [ + { + userId: teammateOne.id, + isFixed: false, + }, + { + userId: teammateTwo.id, + isFixed: false, + }, + ], + }, + }); + + roundRobinEventTypeWithoutFixedHostsId = roundRobinEventTypeWithoutFixedHosts.id; + + const roundRobinEventTypeWithFixedAndNonFixedHosts = + await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "ROUND_ROBIN", + team: { + connect: { id: team.id }, + }, + title: "RR Event Type With Fixed and Non-Fixed Hosts", + slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`, + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + users: { + connect: [{ id: teammateOne.id }, { id: teammateTwo.id }], + }, + hosts: { + create: [ + { + userId: teammateOne.id, + isFixed: true, + }, + { + userId: teammateTwo.id, + isFixed: false, + }, + ], + }, + }); + + roundRobinEventTypeWithFixedAndNonFixedHostsId = roundRobinEventTypeWithFixedAndNonFixedHosts.id; + const userSchedule: CreateScheduleInput_2024_06_11 = { name: "working time", timeZone: "Europe/Rome", @@ -541,6 +616,135 @@ describe("Slots 2024-09-04 Endpoints", () => { bookingsRepositoryFixture.deleteById(bookingTwo.id); }); + it("should reserve all available slots for round robin event type with non-fixed hosts", async () => { + const now = "2049-09-05T12:00:00.000Z"; + const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate(); + advanceTo(newDate); + + const slotStartTime = "2050-09-05T10:00:00.000Z"; + + const reserveResponseOne = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` }) + .send({ + eventTypeId: roundRobinEventTypeWithoutFixedHostsId, + slotStart: slotStartTime, + reservationDuration: 10, + }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(201); + + const reserveResponseOneBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponseOne.body; + expect(reserveResponseOneBody.status).toEqual(SUCCESS_STATUS); + const responseReservedSlotOne: ReserveSlotOutputData_2024_09_04 = reserveResponseOneBody.data; + expect(responseReservedSlotOne.reservationUid).toBeDefined(); + if (!responseReservedSlotOne.reservationUid) { + throw new Error("Reserved slot one uid is undefined"); + } + + const dbSlotOne = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotOne.reservationUid); + expect(dbSlotOne).toBeDefined(); + if (dbSlotOne) { + const dbReleaseAt = DateTime.fromJSDate(dbSlotOne.releaseAt, { zone: "UTC" }).toISO(); + const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO(); + expect(dbReleaseAt).toEqual(expectedReleaseAt); + } + + const reserveResponseTwo = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .set({ Authorization: `Bearer cal_test_${teammateTwoApiKeyString}` }) + .send({ + eventTypeId: roundRobinEventTypeWithoutFixedHostsId, + slotStart: slotStartTime, + reservationDuration: 10, + }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(201); + + const reserveResponseTwoBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponseTwo.body; + expect(reserveResponseTwoBody.status).toEqual(SUCCESS_STATUS); + const responseReservedSlotTwo: ReserveSlotOutputData_2024_09_04 = reserveResponseTwoBody.data; + expect(responseReservedSlotTwo.reservationUid).toBeDefined(); + if (!responseReservedSlotTwo.reservationUid) { + throw new Error("Reserved slot two uid is undefined"); + } + + const dbSlotTwo = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotTwo.reservationUid); + expect(dbSlotTwo).toBeDefined(); + if (dbSlotTwo) { + const dbReleaseAt = DateTime.fromJSDate(dbSlotTwo.releaseAt, { zone: "UTC" }).toISO(); + const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO(); + expect(dbReleaseAt).toEqual(expectedReleaseAt); + } + + const reserveResponseThree = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .set({ Authorization: `Bearer cal_test_${outsiderApiKeyString}` }) + .send({ + eventTypeId: roundRobinEventTypeWithoutFixedHostsId, + slotStart: slotStartTime, + reservationDuration: 10, + }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04); + + expect(reserveResponseThree.status).toEqual(403); + expect(reserveResponseThree.body.status).toEqual(ERROR_STATUS); + + await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotOne.reservationUid); + await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotTwo.reservationUid); + clear(); + }); + + it("should reserve available slot for round robin event type with fixed and non-fixed hosts and should not be able to reserve another slot", async () => { + const now = "2049-09-05T12:00:00.000Z"; + const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate(); + advanceTo(newDate); + + const slotStartTime = "2050-09-05T10:00:00.000Z"; + + const reserveResponseOne = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` }) + .send({ + eventTypeId: roundRobinEventTypeWithFixedAndNonFixedHostsId, + slotStart: slotStartTime, + reservationDuration: 10, + }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(201); + + const reserveResponseBodyOne: ReserveSlotOutputResponse_2024_09_04 = reserveResponseOne.body; + expect(reserveResponseBodyOne.status).toEqual(SUCCESS_STATUS); + const responseReservedSlotOne: ReserveSlotOutputData_2024_09_04 = reserveResponseBodyOne.data; + expect(responseReservedSlotOne.reservationUid).toBeDefined(); + if (!responseReservedSlotOne.reservationUid) { + throw new Error("Reserved slot uid is undefined"); + } + + const dbSlotOne = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotOne.reservationUid); + expect(dbSlotOne).toBeDefined(); + if (dbSlotOne) { + const dbReleaseAt = DateTime.fromJSDate(dbSlotOne.releaseAt, { zone: "UTC" }).toISO(); + const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO(); + expect(dbReleaseAt).toEqual(expectedReleaseAt); + } + + const reserveResponseTwo = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ + eventTypeId: roundRobinEventTypeWithFixedAndNonFixedHostsId, + slotStart: slotStartTime, + }) + .set({ Authorization: `Bearer cal_test_${teammateTwoApiKeyString}` }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04); + + expect(reserveResponseTwo.status).toEqual(422); + expect(reserveResponseTwo.body.status).toEqual(ERROR_STATUS); + + await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotOne.reservationUid); + clear(); + }); + afterAll(async () => { await userRepositoryFixture.deleteByEmail(teammateOne.email); await userRepositoryFixture.deleteByEmail(teammateTwo.email); diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts index ae160e1dc865e5..d46a7d2124abe4 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts @@ -48,7 +48,7 @@ import { ApiResponse } from "@calcom/platform-types"; @DocsTags("Slots") @ApiHeader({ name: "cal-api-version", - description: `Must be set to ${VERSION_2024_09_04}`, + description: `Must be set to ${VERSION_2024_09_04}. If not set to this value, the endpoint will default to an older version.`, example: VERSION_2024_09_04, required: true, schema: { @@ -86,6 +86,8 @@ export class SlotsController_2024_09_04 { - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long. - format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value. - bookingUidToReschedule: When rescheduling an existing booking, provide the booking's unique identifier to exclude its time slot from busy time calculations. This ensures the original booking time appears as available for rescheduling. + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. `, }) @ApiQuery({ @@ -256,7 +258,10 @@ export class SlotsController_2024_09_04 { @ApiOperation({ summary: "Reserve a slot", description: `Make a slot not available for others to book for a certain period of time. If you authenticate using oAuth credentials, api key or access token - then you can also specify custom duration for how long the slot should be reserved for (defaults to 5 minutes).`, + then you can also specify custom duration for how long the slot should be reserved for (defaults to 5 minutes). + + Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint. + `, }) @ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER) @ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER) @@ -278,6 +283,7 @@ export class SlotsController_2024_09_04 { @Get("/reservations/:uid") @ApiOperation({ summary: "Get reserved slot", + description: `Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.`, }) async getReservedSlot(@Param("uid") uid: string): Promise { const reservedSlot = await this.slotsService.getReservedSlot(uid); @@ -293,6 +299,7 @@ export class SlotsController_2024_09_04 { @Patch("/reservations/:uid") @ApiOperation({ summary: "Update a reserved slot", + description: `Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.`, }) @HttpCode(HttpStatus.OK) async updateReservedSlot( @@ -312,6 +319,7 @@ export class SlotsController_2024_09_04 { @Delete("/reservations/:uid") @ApiOperation({ summary: "Delete a reserved slot", + description: `Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.`, }) @HttpCode(HttpStatus.OK) @DocsResponse({ diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts index 6e34882a19e3b1..4b1dcad021defa 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts @@ -23,6 +23,8 @@ import { DateTime } from "luxon"; import { z } from "zod"; import { SlotFormat } from "@calcom/platform-enums"; +import { SchedulingType } from "@calcom/platform-libraries"; +import { validateRoundRobinSlotAvailability } from "@calcom/platform-libraries/slots"; import type { GetSlotsInput_2024_09_04, GetSlotsInputWithRouting_2024_09_04, @@ -147,13 +149,26 @@ export class SlotsService_2024_09_04 { } const nonSeatedEventAlreadyBooked = !eventType.seatsPerTimeSlot && booking; - if (nonSeatedEventAlreadyBooked) { + const isRoundRobinEvent = eventType.schedulingType === SchedulingType.ROUND_ROBIN; + + if (nonSeatedEventAlreadyBooked && !isRoundRobinEvent) { throw new UnprocessableEntityException(`Can't reserve a slot if the event is already booked.`); } - const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION; + if (isRoundRobinEvent) { + try { + await validateRoundRobinSlotAvailability(input.eventTypeId, startDate, endDate, eventType.hosts); + } catch (error) { + if (error instanceof Error) { + throw new UnprocessableEntityException(error?.message); + } + throw error; + } + } else { + await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO()); + } - await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO()); + const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION; if (eventType.userId) { const slot = await this.slotsRepository.createSlot( diff --git a/apps/api/v2/src/modules/teams/memberships/inputs/get-team-memberships.input.ts b/apps/api/v2/src/modules/teams/memberships/inputs/get-team-memberships.input.ts index 2b2c08cb30b2b5..b817f2134a0b4d 100644 --- a/apps/api/v2/src/modules/teams/memberships/inputs/get-team-memberships.input.ts +++ b/apps/api/v2/src/modules/teams/memberships/inputs/get-team-memberships.input.ts @@ -1,9 +1,10 @@ -import { GetUsersInput } from "@/modules/users/inputs/get-users.input"; import { ApiPropertyOptional } from "@nestjs/swagger"; import { Transform } from "class-transformer"; import { ArrayMaxSize, ArrayNotEmpty, IsEmail, IsOptional } from "class-validator"; -export class GetTeamMembershipsInput extends GetUsersInput { +import { SkipTakePagination } from "@calcom/platform-types"; + +export class GetTeamMembershipsInput extends SkipTakePagination { @IsOptional() @Transform(({ value }) => { if (value == null) return undefined; diff --git a/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts b/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts index 829a10be139bba..0c726a297aa4c4 100644 --- a/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts +++ b/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts @@ -1,8 +1,8 @@ import { Locales } from "@/lib/enums/locales"; import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { Transform } from "class-transformer"; -import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject } from "class-validator"; +import { Transform, Type } from "class-transformer"; +import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject, IsNumber } from "class-validator"; import { ValidateMetadata } from "@calcom/platform-types"; @@ -17,6 +17,8 @@ export class CreateManagedUserInput { @ApiProperty({ example: "Alice Smith", description: "Managed user's name is used in emails" }) name!: string; + @Type(() => Number) + @IsNumber() @IsOptional() @IsIn([12, 24], { message: "timeFormat must be a number either 12 or 24" }) @ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be a number 12 or 24" }) diff --git a/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts b/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts index e1cb0fc064a9bc..42f9417730b52e 100644 --- a/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts +++ b/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts @@ -2,6 +2,7 @@ import { Locales } from "@/lib/enums/locales"; import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone"; import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input"; import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; import { IsEnum, IsIn, IsNumber, IsObject, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator"; import { ValidateMetadata } from "@calcom/platform-types"; @@ -17,6 +18,8 @@ export class UpdateManagedUserInput { @ApiPropertyOptional() name?: string; + @Type(() => Number) + @IsNumber() @IsOptional() @IsIn([12, 24]) @ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be 12 or 24" }) 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..7ec8ee909cbf88 --- /dev/null +++ b/apps/api/v2/src/modules/workflows/inputs/create-form-workflow.ts @@ -0,0 +1,106 @@ +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_SUBMITTED_NO_EVENT, + FORM_WORKFLOW_TRIGGER_TYPES, + OnFormSubmittedNoEventTriggerDto, + 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, + OnFormSubmittedNoEventTriggerDto, + 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) }, + { $ref: getSchemaPath(OnFormSubmittedNoEventTriggerDto) }, + ], + }) + @ValidateNested() + @Type(() => RoutingFormWorkflowTriggerDto, { + keepDiscriminatorProperty: true, + discriminator: { + property: "type", + subTypes: [ + { value: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED }, + { value: OnFormSubmittedNoEventTriggerDto, name: FORM_SUBMITTED_NO_EVENT }, + ], + }, + }) + trigger!: OnFormSubmittedTriggerDto | OnFormSubmittedNoEventTriggerDto; + + @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..d25295917e8e19 --- /dev/null +++ b/apps/api/v2/src/modules/workflows/inputs/update-form-workflow.input.ts @@ -0,0 +1,97 @@ +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, + OnFormSubmittedNoEventTriggerDto, + FORM_SUBMITTED, + FORM_SUBMITTED_NO_EVENT, + FORM_WORKFLOW_TRIGGER_TYPES, + RoutingFormWorkflowTriggerDto, +} from "./workflow-trigger.input"; + +@ApiExtraModels( + OnFormSubmittedTriggerDto, + OnFormSubmittedNoEventTriggerDto, + 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) }, + { $ref: getSchemaPath(OnFormSubmittedNoEventTriggerDto) }, + ], + }) + @IsOptional() + @ValidateNested() + @Type(() => RoutingFormWorkflowTriggerDto, { + keepDiscriminatorProperty: true, + discriminator: { + property: "type", + subTypes: [ + { value: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED }, + { value: OnFormSubmittedNoEventTriggerDto, name: FORM_SUBMITTED_NO_EVENT }, + ], + }, + }) + trigger?: OnFormSubmittedTriggerDto | OnFormSubmittedNoEventTriggerDto; + + @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..5c1b970641cb81 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,31 @@ 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 FORM_SUBMITTED_NO_EVENT = "formSubmittedNoEvent"; 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, FORM_SUBMITTED_NO_EVENT] 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 +44,8 @@ export const WORKFLOW_TRIGGER_TYPES = [ RESCHEDULE_EVENT, AFTER_HOSTS_CAL_VIDEO_NO_SHOW, AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + FORM_SUBMITTED, + FORM_SUBMITTED_NO_EVENT, BOOKING_REJECTED, BOOKING_REQUESTED, BOOKING_PAYMENT_INITIATED, @@ -39,6 +61,8 @@ 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, + [FORM_SUBMITTED_NO_EVENT]: WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT, [BOOKING_REJECTED]: WorkflowTriggerEvents.BOOKING_REJECTED, [BOOKING_REQUESTED]: WorkflowTriggerEvents.BOOKING_REQUESTED, [BOOKING_PAYMENT_INITIATED]: WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED, @@ -46,6 +70,19 @@ export const WORKFLOW_TRIGGER_TO_ENUM = { [BOOKING_PAID]: WorkflowTriggerEvents.BOOKING_PAID, } as const; +export const ENUM_ROUTING_FORM_WORFLOW_TRIGGERS = [ + WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED_NO_EVENT], + WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED], +]; + +export const ENUM_OFFSET_WORFLOW_TRIGGERS = [ + WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED_NO_EVENT], + WORKFLOW_TRIGGER_TO_ENUM[BEFORE_EVENT], + WORKFLOW_TRIGGER_TO_ENUM[AFTER_EVENT], + WORKFLOW_TRIGGER_TO_ENUM[AFTER_GUESTS_CAL_VIDEO_NO_SHOW], + WORKFLOW_TRIGGER_TO_ENUM[AFTER_HOSTS_CAL_VIDEO_NO_SHOW], +]; + export const ENUM_TO_WORKFLOW_TRIGGER = { [WorkflowTriggerEvents.BEFORE_EVENT]: BEFORE_EVENT, [WorkflowTriggerEvents.EVENT_CANCELLED]: EVENT_CANCELLED, @@ -54,6 +91,8 @@ 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.FORM_SUBMITTED_NO_EVENT]: FORM_SUBMITTED_NO_EVENT, [WorkflowTriggerEvents.BOOKING_REJECTED]: BOOKING_REJECTED, [WorkflowTriggerEvents.BOOKING_REQUESTED]: BOOKING_REQUESTED, [WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED]: BOOKING_PAYMENT_INITIATED, @@ -61,6 +100,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 +125,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 +139,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 { @@ -166,7 +222,8 @@ export class OnNoShowUpdateTriggerDto { export class TriggerOffsetDTO { @ApiProperty({ - description: "Offset before/after the trigger time; required for BEFORE_EVENT and AFTER_EVENT only", + description: + "Offset before/after the trigger time; required for BEFORE_EVENT, AFTER_EVENT, and FORM_SUBMITTED_NO_EVENT", type: WorkflowTriggerOffsetDto, }) @ValidateNested() @@ -213,3 +270,31 @@ 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; +} + +export class OnFormSubmittedNoEventTriggerDto extends TriggerOffsetDTO { + @ApiProperty({ + description: "Trigger type for the workflow", + example: FORM_SUBMITTED_NO_EVENT, + }) + @IsString() + @IsIn([FORM_SUBMITTED_NO_EVENT]) + type: typeof FORM_SUBMITTED_NO_EVENT = FORM_SUBMITTED_NO_EVENT; +} + +export const OffsetTriggerDTOInstances = [ + OnFormSubmittedNoEventTriggerDto, + OnBeforeEventTriggerDto, + OnAfterEventTriggerDto, + OnAfterCalVideoGuestsNoShowTriggerDto, + OnAfterEventTriggerDto, +]; +export type OffsetTriggerDTOInstancesType = InstanceType<(typeof OffsetTriggerDTOInstances)[number]>; 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..d44184ae05c6a2 --- /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/FORM_SUBMITTED_NO_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..3d67bd0699656c 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,14 +16,13 @@ import { STEP_ACTIONS_TO_ENUM, TemplateType, TextWorkflowMessageDto, + UpdateWorkflowStepDto, WHATSAPP_ATTENDEE, WHATSAPP_NUMBER, - WorkflowPhoneNumberStepDto, - WorkflowPhoneWhatsAppNumberStepDto, } from "../inputs/workflow-step.input"; import { - OnAfterEventTriggerDto, - OnBeforeEventTriggerDto, + OffsetTriggerDTOInstances, + OffsetTriggerDTOInstancesType, TIME_UNIT_TO_ENUM, WORKFLOW_TRIGGER_TO_ENUM, } from "../inputs/workflow-trigger.input"; @@ -37,6 +31,37 @@ import { export class WorkflowsInputService { constructor(private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository) {} + private _isOffsetTrigger( + trigger: UpdateEventTypeWorkflowDto["trigger"] | UpdateFormWorkflowDto["trigger"] + ): trigger is OffsetTriggerDTOInstancesType { + return OffsetTriggerDTOInstances.some((Instance) => trigger instanceof Instance); + } + + 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 +70,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 +116,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 +140,85 @@ 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 timeUnitForZod = this._isOffsetTrigger(updateDto.trigger) + ? updateDto?.trigger?.offset?.unit ?? currentData.timeUnit ?? null + : undefined; + + const time = this._isOffsetTrigger(updateDto.trigger) + ? 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..f810662a8b4127 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,48 +1,223 @@ -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"; import { - AFTER_EVENT, - AFTER_GUESTS_CAL_VIDEO_NO_SHOW, - AFTER_HOSTS_CAL_VIDEO_NO_SHOW, - BEFORE_EVENT, ENUM_TO_TIME_UNIT, ENUM_TO_WORKFLOW_TRIGGER, HOUR, + OnAfterCalVideoGuestsNoShowTriggerDto, + OnAfterCalVideoHostsNoShowTriggerDto, + OnAfterEventTriggerDto, + OnBeforeEventTriggerDto, + OnCancelTriggerDto, + OnCreationTriggerDto, + OnFormSubmittedTriggerDto, + OnFormSubmittedNoEventTriggerDto, + OnNoShowUpdateTriggerDto, + OnPaidTriggerDto, + OnPaymentInitiatedTriggerDto, + OnRejectedTriggerDto, + OnRequestedTriggerDto, + OnRescheduleTriggerDto, WORKFLOW_TRIGGER_TO_ENUM, + FORM_WORKFLOW_TRIGGER_TYPES, + ENUM_ROUTING_FORM_WORFLOW_TRIGGERS, + ENUM_OFFSET_WORFLOW_TRIGGERS, } from "../inputs/workflow-trigger.input"; +export type TriggerDtoType = + | OnAfterEventTriggerDto + | OnBeforeEventTriggerDto + | OnCreationTriggerDto + | OnRescheduleTriggerDto + | OnCancelTriggerDto + | OnAfterCalVideoGuestsNoShowTriggerDto + | OnFormSubmittedTriggerDto + | OnFormSubmittedNoEventTriggerDto + | 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 { + return FORM_ALLOWED_STEP_ACTIONS.some((formAction) => formAction === action); + } + _isFormAllowedTrigger( + trigger: WorkflowType["trigger"] + ): trigger is (typeof ENUM_ROUTING_FORM_WORFLOW_TRIGGERS)[number] { + return FORM_WORKFLOW_TRIGGER_TYPES.some( + (formTrigger) => WORKFLOW_TRIGGER_TO_ENUM[formTrigger] === trigger + ); + } + + private _isOffsetTrigger( + trigger: WorkflowType["trigger"] + ): trigger is (typeof ENUM_OFFSET_WORFLOW_TRIGGERS)[number] { + return ENUM_OFFSET_WORFLOW_TRIGGERS.some((offsetTrigger) => offsetTrigger === trigger); + } + + /** + * 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] + return this._isFormAllowedStepAction(action) + ? ({ + ...baseAction, + action: action, + } satisfies RoutingFormWorkflowStepOutputDto) + : ({ + ...baseAction, + action: action, + } satisfies EventTypeWorkflowStepOutputDto); + } + + toRoutingFormOutputDto(workflow: WorkflowType): RoutingFormWorkflowOutput | void { + if (workflow.type === "ROUTING_FORM" && this._isFormAllowedTrigger(workflow.trigger)) { + const activation: WorkflowFormActivationDto = { + isActiveOnAllRoutingForms: workflow.isActiveOnAll, + activeOnRoutingFormIds: + workflow.activeOnRoutingForms?.map((relation) => relation.routingFormId) ?? [], + }; + + const trigger: TriggerDtoType = this._isOffsetTrigger(workflow.trigger) ? { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger], offset: { @@ -52,68 +227,50 @@ export class WorkflowsOutputService { } : { 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; - } + 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" && !this._isFormAllowedTrigger(workflow.trigger)) { + const activation: WorkflowActivationDto = { + isActiveOnAllEventTypes: workflow.isActiveOnAll, + activeOnEventTypeIds: workflow.activeOn?.map((relation) => relation.eventTypeId) ?? [], + }; + + const trigger: TriggerEventTypeDtoType = this._isOffsetTrigger(workflow.trigger) + ? { + 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/app/(booking-page-wrapper)/booking-successful/[uid]/page.tsx b/apps/web/app/(booking-page-wrapper)/booking-successful/[uid]/page.tsx new file mode 100644 index 00000000000000..357918cb63b0fc --- /dev/null +++ b/apps/web/app/(booking-page-wrapper)/booking-successful/[uid]/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useParams } from "next/navigation"; + +import dayjs from "@calcom/dayjs"; +import { DecoyBookingSuccessCard } from "@calcom/features/bookings/Booker/components/DecoyBookingSuccessCard"; +import { useDecoyBooking } from "@calcom/features/bookings/Booker/components/hooks/useDecoyBooking"; + +export default function BookingSuccessful() { + const params = useParams(); + + const uid = params?.uid as string; + const bookingData = useDecoyBooking(uid); + + if (!bookingData) { + return null; + } + + const { booking } = bookingData; + + // Format the data for the BookingSuccessCard + const startTime = booking.startTime ? dayjs(booking.startTime) : null; + const endTime = booking.endTime ? dayjs(booking.endTime) : null; + const timeZone = booking.booker?.timeZone || booking.host?.timeZone || dayjs.tz.guess(); + + const formattedDate = startTime ? startTime.tz(timeZone).format("dddd, MMMM D, YYYY") : ""; + const formattedTime = startTime ? startTime.tz(timeZone).format("h:mm A") : ""; + const formattedEndTime = endTime ? endTime.tz(timeZone).format("h:mm A") : ""; + const formattedTimeZone = timeZone; + + const hostName = booking.host?.name || null; + const hostEmail = null; // Email not stored for spam decoy bookings + const attendeeName = booking.booker?.name || null; + const attendeeEmail = booking.booker?.email || null; + + return ( + + ); +} diff --git a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/pageWithCachedData.tsx b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/pageWithCachedData.tsx index 1594ec72f7ebd7..73655d5c830232 100644 --- a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/pageWithCachedData.tsx +++ b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/pageWithCachedData.tsx @@ -10,7 +10,7 @@ import { getBookingForReschedule, type GetBookingType } from "@calcom/features/b import { getOrgFullOrigin, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings"; import type { TeamData } from "@calcom/features/ee/teams/lib/getTeamData"; -import { shouldHideBrandingForTeamEvent } from "@calcom/lib/hideBranding"; +import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding"; import { loadTranslations } from "@calcom/lib/server/i18n"; import slugify from "@calcom/lib/slugify"; import { BookingStatus, RedirectType } from "@calcom/prisma/enums"; diff --git a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/queries.ts b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/queries.ts index 759b76de20a8d8..fb608f3867d440 100644 --- a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/queries.ts +++ b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/queries.ts @@ -13,8 +13,8 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; -import { TeamRepository } from "@calcom/lib/server/repository/team"; -import { UserRepository } from "@calcom/lib/server/repository/user"; +import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import type { SchedulingType } from "@calcom/prisma/enums"; diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx index 3e2045682002e2..e9ae0e7de08f17 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx @@ -6,9 +6,10 @@ import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable"; -import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; +import { getScheduleListItemData } from "@calcom/lib/schedules/transformers/getScheduleListItemData"; import { MembershipRole } from "@calcom/prisma/enums"; import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router"; @@ -56,15 +57,7 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => { // This is because the data is cached and as a result the data is converted to a string const availabilities = { ...cachedAvailabilities, - schedules: cachedAvailabilities.schedules.map((schedule) => ({ - ...schedule, - availability: schedule.availability.map((avail) => ({ - ...avail, - startTime: new Date(avail.startTime), - endTime: new Date(avail.endTime), - date: avail.date ? new Date(avail.date) : null, - })), - })), + schedules: cachedAvailabilities.schedules.map((schedule) => getScheduleListItemData(schedule)), }; const organizationId = session?.user?.profile?.organizationId ?? session?.user.org?.id; diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx index 21379a8a6f07a6..dfb17af3eeb75b 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx @@ -12,7 +12,7 @@ import { MembershipRole } from "@calcom/prisma/enums"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; import { validStatuses } from "~/bookings/lib/validStatuses"; -import BookingsList from "~/bookings/views/bookings-listing-view"; +import BookingsList from "~/bookings/views/bookings-view"; const querySchema = z.object({ status: z.enum(validStatuses), diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx index d17fa76c1ff60f..bca38f4981d2d8 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx @@ -4,8 +4,8 @@ import { unstable_cache } from "next/cache"; import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; -import { TeamRepository } from "@calcom/lib/server/repository/team"; -import { TeamService } from "@calcom/lib/server/service/teamService"; +import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; +import { TeamService } from "@calcom/features/ee/teams/services/teamService"; import prisma from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; diff --git a/apps/web/app/(use-page-wrapper)/apps/(homepage)/page.tsx b/apps/web/app/(use-page-wrapper)/apps/(homepage)/page.tsx index bd18bbbdbb06b2..4eaa198fb4ab53 100644 --- a/apps/web/app/(use-page-wrapper)/apps/(homepage)/page.tsx +++ b/apps/web/app/(use-page-wrapper)/apps/(homepage)/page.tsx @@ -3,7 +3,7 @@ import { cookies, headers } from "next/headers"; import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { UserRepository } from "@calcom/lib/server/repository/user"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import prisma from "@calcom/prisma"; import type { AppCategories } from "@calcom/prisma/enums"; diff --git a/apps/web/app/(use-page-wrapper)/getting-started/[[...step]]/page.tsx b/apps/web/app/(use-page-wrapper)/getting-started/[[...step]]/page.tsx index 8f00834918d63a..24667ec84398a7 100644 --- a/apps/web/app/(use-page-wrapper)/getting-started/[[...step]]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/getting-started/[[...step]]/page.tsx @@ -6,7 +6,7 @@ import { redirect } from "next/navigation"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { APP_NAME } from "@calcom/lib/constants"; -import { UserRepository } from "@calcom/lib/server/repository/user"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import prisma from "@calcom/prisma"; import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router"; diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/organizations/[id]/edit/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/organizations/[id]/edit/page.tsx index b8c4ee8a0578f3..fb9f4cd6c93192 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/organizations/[id]/edit/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/organizations/[id]/edit/page.tsx @@ -5,7 +5,7 @@ import { z } from "zod"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; import { OrgForm } from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgEditPage"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; -import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; +import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; const orgIdSchema = z.object({ id: z.coerce.number() }); diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/users/[id]/edit/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/users/[id]/edit/page.tsx index 4114e904cfda7f..b28763d847b2ed 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/users/[id]/edit/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/users/[id]/edit/page.tsx @@ -5,7 +5,7 @@ import { z } from "zod"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; import { UsersEditView } from "@calcom/features/ee/users/pages/users-edit-view"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; -import { UserRepository } from "@calcom/lib/server/repository/user"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import prisma from "@calcom/prisma"; const userIdSchema = z.object({ id: z.coerce.number() }); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index 792ec1f01bd1a7..43190417291565 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -98,7 +98,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { ] : []), { - name: "privacy", + name: "privacy_and_security", href: "/settings/organizations/privacy", }, @@ -170,7 +170,14 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { // The following keys are assigned to admin only const adminRequiredKeys = ["admin"]; const organizationRequiredKeys = ["organization"]; -const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"]; +const organizationAdminKeys = [ + "privacy", + "privacy_and_security", + "OAuth Clients", + "SSO", + "directory_sync", + "delegation_credential", +]; export interface SettingsPermissions { canViewRoles?: boolean; @@ -221,8 +228,7 @@ const useTabs = ({ }); } - // Add pbac menu item only if feature flag is enabled AND user has permission to view roles - // This prevents showing the menu item when user has no organization permissions + // Add pbac menu item - show opt-in page if not enabled, regular page if enabled if (isPbacEnabled) { if (permissions?.canViewRoles) { newArray.push({ @@ -244,6 +250,14 @@ const useTabs = ({ href: "/settings/organizations/billing", }); } + // Show roles page (modal will appear if PBAC not enabled) + if (permissions?.canUpdateOrganization) { + newArray.push({ + name: "roles", + href: "/settings/organizations/roles", + isBadged: true, // Show "New" badge + }); + } } return { @@ -604,17 +618,23 @@ const SettingsSidebarContainer = ({
{tab.children?.map((child, index) => ( - +
+ + {child.isBadged && ( + + New + + )} +
))}
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/[id]/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/[id]/page.tsx index 173de93ab2ea6e..c161793e881fc6 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/[id]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/[id]/page.tsx @@ -4,7 +4,7 @@ import { getTranslate, _generateMetadata } from "app/_utils"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view"; import { APP_NAME } from "@calcom/lib/constants"; -import { WebhookRepository } from "@calcom/lib/server/repository/webhook"; +import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository"; export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) => await _generateMetadata( @@ -20,7 +20,8 @@ const Page = async ({ params: _params }: PageProps) => { const params = await _params; const id = typeof params?.id === "string" ? params.id : undefined; - const webhook = await WebhookRepository.findByWebhookId(id); + const webhookRepository = WebhookRepository.getInstance(); + const webhook = await webhookRepository.findByWebhookId(id); return ( await _generateMetadata( - (t) => t("privacy"), + (t) => t("privacy_and_security"), (t) => t("privacy_organization_description"), undefined, undefined, @@ -19,9 +19,8 @@ export const generateMetadata = async () => ); const Page = async () => { - const t = await getTranslate(); - const session = await validateUserHasOrg(); + const t = await getTranslate(); if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { return redirect("/settings/profile"); @@ -42,13 +41,31 @@ const Page = async () => { }, }); + const watchlistPermissions = await getResourcePermissions({ + userId: session.user.id, + teamId: session.user.profile.organizationId, + resource: Resource.Watchlist, + userRole: session.user.org.role, + fallbackRoles: { + read: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + create: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + delete: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }); + if (!canRead) { return redirect("/settings/profile"); } return ( - - + + ); }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/FingerprintAnimation.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/FingerprintAnimation.tsx new file mode 100644 index 00000000000000..da28eb661f9d74 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/FingerprintAnimation.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { motion } from "framer-motion"; + +interface FingerprintAnimationProps { + isHovered: boolean; +} + +export function FingerprintAnimation({ isHovered }: FingerprintAnimationProps) { + return ( +
+ + {/*Fingerprint*/} + + {/*Clip path to contain animated scanning line*/} + + + + + {/* Animated scanning line */} + + + + + + + + + + + + +
+ ); +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInModal.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInModal.tsx new file mode 100644 index 00000000000000..efaea7790382bf --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInModal.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { Dialog, DialogContent } from "@calcom/ui/components/dialog"; +import { Icon } from "@calcom/ui/components/icon"; +import { showToast } from "@calcom/ui/components/toast"; + +import { FingerprintAnimation } from "./FingerprintAnimation"; + +interface PbacOptInModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + revalidateRolesPath: () => Promise; +} + +export function PbacOptInModal({ open, onOpenChange, revalidateRolesPath }: PbacOptInModalProps) { + const router = useRouter(); + const { t } = useLocale(); + const [isButtonHovered, setIsButtonHovered] = useState(false); + + const enablePbacMutation = trpc.viewer.pbac.enablePbac.useMutation({ + onSuccess: async () => { + showToast(t("pbac_enabled_success"), "success"); + await revalidateRolesPath(); + onOpenChange(false); + router.refresh(); + }, + onError: (error) => { + showToast(error.message || t("pbac_enabled_error"), "error"); + }, + }); + + const handleOptIn = () => { + enablePbacMutation.mutate(); + }; + + return ( + + +
+
+ + +
+

{t("pbac_opt_in_title")}

+

{t("pbac_opt_in_description")}

+
+
+ +
+
+
+ +
+
+

{t("pbac_opt_in_custom_roles_title")}

+

{t("pbac_opt_in_custom_roles_desc")}

+
+
+ +
+
+ +
+
+

+ {t("pbac_opt_in_granular_permissions_title")} +

+

{t("pbac_opt_in_granular_permissions_desc")}

+
+
+ +
+
+ +
+
+

+ {t("pbac_opt_in_team_assignment_title")} +

+

{t("pbac_opt_in_team_assignment_desc")}

+
+
+ +
+
+ +
+
+

+ {t("pbac_opt_in_enhanced_security_title")} +

+

{t("pbac_opt_in_enhanced_security_desc")}

+
+
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInView.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInView.tsx new file mode 100644 index 00000000000000..b5031379aa43d9 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/PbacOptInView.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; + +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import { PbacOptInModal } from "./PbacOptInModal"; +import { RolesList } from "./RolesList"; + +type Role = { + id: string; + name: string; + description?: string; + teamId?: number; + color?: string; + createdAt: Date; + updatedAt: Date; + type: "SYSTEM" | "CUSTOM"; + permissions: { + id: string; + resource: string; + action: string; + }[]; +}; + +interface PbacOptInViewProps { + revalidateRolesPath: () => Promise; + systemRoles: Role[]; + teamId: number; +} + +export function PbacOptInView({ revalidateRolesPath, systemRoles, teamId }: PbacOptInViewProps) { + const [open, setOpen] = useState(true); + const { t } = useLocale(); + + return ( + <> + + + + + + ); +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx index 97a0382843a269..dcd08c327e1da6 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx @@ -1,6 +1,7 @@ import { _generateMetadata, getTranslate } from "app/_utils"; import { unstable_cache } from "next/cache"; import { notFound } from "next/navigation"; +import { revalidatePath } from "next/cache"; import type { AppFlags } from "@calcom/features/flags/config"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; @@ -13,6 +14,7 @@ import { prisma } from "@calcom/prisma"; import { validateUserHasOrg } from "../actions/validateUserHasOrg"; import { CreateRoleCTA } from "./_components/CreateRoleCta"; +import { PbacOptInView } from "./_components/PbacOptInView"; import { RolesList } from "./_components/RolesList"; import { roleSearchParamsCache } from "./_components/searchParams"; @@ -65,6 +67,11 @@ export const generateMetadata = async () => "/settings/organizations/roles" ); +async function revalidateRolesPath() { + "use server"; + revalidatePath("/settings/organizations/roles"); +} + const Page = async ({ searchParams }: { searchParams: Record }) => { const t = await getTranslate(); const session = await validateUserHasOrg(); @@ -76,7 +83,18 @@ const Page = async ({ searchParams }: { searchParams: Record role.type === "SYSTEM" + ); + return ( + + ); } roleSearchParamsCache.parse(searchParams); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/(main-page)/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/(main-page)/page.tsx index d8cee42e1e1fb7..9160d5d47b6bcc 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/(main-page)/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/(main-page)/page.tsx @@ -3,7 +3,7 @@ import { redirect } from "next/navigation"; import { OtherTeamsListing } from "@calcom/features/ee/organizations/pages/components/OtherTeamsListing"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; -import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; +import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; import { validateUserHasOrg } from "../../../actions/validateUserHasOrg"; diff --git a/apps/web/app/(use-page-wrapper)/settings/organizations/new/resume/page.tsx b/apps/web/app/(use-page-wrapper)/settings/organizations/new/resume/page.tsx new file mode 100644 index 00000000000000..3f4153df16f768 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/organizations/new/resume/page.tsx @@ -0,0 +1,22 @@ +import { _generateMetadata } from "app/_utils"; + +import ResumeOnboardingPage, { LayoutWrapper } from "~/settings/organizations/new/resume-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("resume_onboarding"), + (t) => t("resume_onboarding_description"), + undefined, + undefined, + "/settings/organizations/new/resume" + ); + +const ServerPage = async () => { + return ( + + + + ); +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/video/meeting-not-started/[uid]/page.tsx b/apps/web/app/(use-page-wrapper)/video/meeting-not-started/[uid]/page.tsx index bbfb461dd2dd33..18507908fc9f3e 100644 --- a/apps/web/app/(use-page-wrapper)/video/meeting-not-started/[uid]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/video/meeting-not-started/[uid]/page.tsx @@ -5,7 +5,7 @@ import { cookies, headers } from "next/headers"; import { notFound } from "next/navigation"; import { z } from "zod"; -import { BookingRepository } from "@calcom/lib/server/repository/booking"; +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { prisma } from "@calcom/prisma"; import { buildLegacyCtx } from "@lib/buildLegacyCtx"; diff --git a/apps/web/app/(use-page-wrapper)/workflows/page.tsx b/apps/web/app/(use-page-wrapper)/workflows/page.tsx index cca1bd675053f2..1601dee104d820 100644 --- a/apps/web/app/(use-page-wrapper)/workflows/page.tsx +++ b/apps/web/app/(use-page-wrapper)/workflows/page.tsx @@ -2,7 +2,7 @@ // import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; // import { buildLegacyRequest } from "@lib/buildLegacyCtx"; // import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery"; -// import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; +// import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; import LegacyPage from "@calcom/features/ee/workflows/pages/index"; const Page = async () => { diff --git a/apps/web/app/api/availability/calendar/route.ts b/apps/web/app/api/availability/calendar/route.ts index e0a6bdca532578..2ac9b534e0ff76 100644 --- a/apps/web/app/api/availability/calendar/route.ts +++ b/apps/web/app/api/availability/calendar/route.ts @@ -10,7 +10,7 @@ import { getCalendarCredentials, getConnectedCalendars } from "@calcom/features/ import { HttpError } from "@calcom/lib/http-error"; import notEmpty from "@calcom/lib/notEmpty"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; -import { UserRepository } from "@calcom/lib/server/repository/user"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import prisma from "@calcom/prisma"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; diff --git a/apps/web/app/api/cancel/route.ts b/apps/web/app/api/cancel/route.ts index 46da34f7e7d1da..c2eaabc8e52817 100644 --- a/apps/web/app/api/cancel/route.ts +++ b/apps/web/app/api/cancel/route.ts @@ -26,11 +26,20 @@ async function handler(req: NextRequest) { cookieStore.delete("calcom.csrf_token"); const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + const result = await handleCancelBooking({ bookingData, userId: session?.user?.id || -1, }); + // const bookingCancelService = getBookingCancelService(); + // const result = await bookingCancelService.cancelBooking({ + // bookingData: bookingData, + // bookingMeta: { + // userId: session?.user?.id || -1, + // }, + // }); + const statusCode = result.success ? 200 : 400; return NextResponse.json(result, { status: statusCode }); diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts index 9de6a7353a584d..bd3e07fda64345 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -8,7 +8,7 @@ import { CalendarCacheEventService } from "@calcom/features/calendar-subscriptio import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; -import { BookingRepository } from "@calcom/lib/server/repository/booking"; +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { prisma } from "@calcom/prisma"; import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; diff --git a/apps/web/app/api/cron/credentials/route.ts b/apps/web/app/api/cron/credentials/route.ts index c771e4ec8152b0..1ce4dc1ef86446 100644 --- a/apps/web/app/api/cron/credentials/route.ts +++ b/apps/web/app/api/cron/credentials/route.ts @@ -6,11 +6,11 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; +import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { CredentialRepository } from "@calcom/lib/server/repository/credential"; -import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential"; import { defaultResponderForAppDir } from "../../defaultResponderForAppDir"; diff --git a/apps/web/app/api/cron/selected-calendars/route.ts b/apps/web/app/api/cron/selected-calendars/route.ts index b311a63dbe02c8..22a91932c4fb74 100644 --- a/apps/web/app/api/cron/selected-calendars/route.ts +++ b/apps/web/app/api/cron/selected-calendars/route.ts @@ -12,7 +12,7 @@ import { findUniqueDelegationCalendarCredential } from "@calcom/app-store/delega import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { CredentialRepository } from "@calcom/lib/server/repository/credential"; +import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; import type { Ensure } from "@calcom/types/utils"; diff --git a/apps/web/app/api/recorded-daily-video/route.ts b/apps/web/app/api/recorded-daily-video/route.ts index b7ab18df15b282..c968a8eadb0712 100644 --- a/apps/web/app/api/recorded-daily-video/route.ts +++ b/apps/web/app/api/recorded-daily-video/route.ts @@ -17,7 +17,7 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import { getAllTranscriptsAccessLinkFromMeetingId, submitBatchProcessorTranscriptionJob, -} from "@calcom/app-store/videoClient"; +} from "@calcom/features/conferencing/lib/videoClient"; import { generateVideoToken } from "@calcom/lib/videoTokens"; import prisma from "@calcom/prisma"; import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking"; diff --git a/apps/web/app/api/support/conversation/route.ts b/apps/web/app/api/support/conversation/route.ts index 671bd39b27da2c..5df9f5fe3dffda 100644 --- a/apps/web/app/api/support/conversation/route.ts +++ b/apps/web/app/api/support/conversation/route.ts @@ -9,8 +9,8 @@ import { intercom } from "@calcom/features/ee/support/lib/intercom/intercom"; import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { MembershipRepository } from "@calcom/lib/server/repository/membership"; -import { UserRepository } from "@calcom/lib/server/repository/user"; +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { prisma } from "@calcom/prisma"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; diff --git a/apps/web/app/api/teams/api/create/route.ts b/apps/web/app/api/teams/api/create/route.ts index 670fd044b5730b..9699897de7a077 100644 --- a/apps/web/app/api/teams/api/create/route.ts +++ b/apps/web/app/api/teams/api/create/route.ts @@ -5,6 +5,7 @@ import type Stripe from "stripe"; import { z } from "zod"; import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository"; +import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { HttpError } from "@calcom/lib/http-error"; @@ -57,6 +58,9 @@ async function handler(request: NextRequest) { }); if (checkoutSessionSubscription) { + const { subscriptionStart } = + StripeBillingService.extractSubscriptionDates(checkoutSessionSubscription); + const internalBillingService = new InternalTeamBilling(finalizedTeam); await internalBillingService.saveTeamBilling({ teamId: finalizedTeam.id, @@ -66,6 +70,7 @@ async function handler(request: NextRequest) { // TODO: Implement true subscription status when webhook events are implemented status: SubscriptionStatus.ACTIVE, planName: Plan.TEAM, + subscriptionStart, }); } diff --git a/apps/web/app/api/teams/create/route.ts b/apps/web/app/api/teams/create/route.ts index 0099914ab5df6c..3d936872aefb97 100644 --- a/apps/web/app/api/teams/create/route.ts +++ b/apps/web/app/api/teams/create/route.ts @@ -5,6 +5,7 @@ import type Stripe from "stripe"; import { z } from "zod"; import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository"; +import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { HttpError } from "@calcom/lib/http-error"; @@ -87,6 +88,7 @@ async function getHandler(req: NextRequest) { }); if (checkoutSession && subscription) { + const { subscriptionStart } = StripeBillingService.extractSubscriptionDates(subscription); const internalBillingService = new InternalTeamBilling(team); await internalBillingService.saveTeamBilling({ teamId: team.id, @@ -96,6 +98,7 @@ async function getHandler(req: NextRequest) { // TODO: Implement true subscription status when webhook events are implemented status: SubscriptionStatus.ACTIVE, planName: Plan.TEAM, + subscriptionStart, }); } diff --git a/apps/web/app/api/username/route.ts b/apps/web/app/api/username/route.ts index 26e399e6922744..a4be17f81129a3 100644 --- a/apps/web/app/api/username/route.ts +++ b/apps/web/app/api/username/route.ts @@ -5,7 +5,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; -import { checkUsername } from "@calcom/lib/server/checkUsername"; +import { checkUsername } from "@calcom/features/profile/lib/checkUsername"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; diff --git a/apps/web/app/api/video/recording/__tests__/route.test.ts b/apps/web/app/api/video/recording/__tests__/route.test.ts index 3d3a947e262a09..5dfe1acbc22fac 100644 --- a/apps/web/app/api/video/recording/__tests__/route.test.ts +++ b/apps/web/app/api/video/recording/__tests__/route.test.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; import { describe, expect, test, vi, afterEach } from "vitest"; -import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/app-store/videoClient"; +import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/features/conferencing/lib/videoClient"; import { verifyVideoToken } from "@calcom/lib/videoTokens"; import { GET } from "../route"; -vi.mock("@calcom/app-store/videoClient", () => ({ +vi.mock("@calcom/features/conferencing/lib/videoClient", () => ({ getDownloadLinkOfCalVideoByRecordingId: vi.fn(), })); diff --git a/apps/web/app/api/video/recording/route.ts b/apps/web/app/api/video/recording/route.ts index 00d04beec50442..f2fbba1047be7e 100644 --- a/apps/web/app/api/video/recording/route.ts +++ b/apps/web/app/api/video/recording/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/app-store/videoClient"; +import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/features/conferencing/lib/videoClient"; import { verifyVideoToken } from "@calcom/lib/videoTokens"; export async function GET(request: Request) { diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts index 3cd6fc53ba9976..60abe54140ac73 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -11,7 +11,7 @@ import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/ import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import logger from "@calcom/lib/logger"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; -import { BookingRepository } from "@calcom/lib/server/repository/booking"; +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { prisma } from "@calcom/prisma"; import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; diff --git a/apps/web/app/cache/membership.ts b/apps/web/app/cache/membership.ts index ef40178d438def..9af484adde03d3 100644 --- a/apps/web/app/cache/membership.ts +++ b/apps/web/app/cache/membership.ts @@ -3,7 +3,7 @@ import { revalidateTag, unstable_cache } from "next/cache"; import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants"; -import { MembershipRepository } from "@calcom/lib/server/repository/membership"; +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; const CACHE_TAGS = { HAS_TEAM_PLAN: "MembershipRepository.findFirstAcceptedMembershipByUserId", diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index 20f136ab42341b..b0a727b5b30463 100644 --- a/apps/web/components/apps/AppPage.tsx +++ b/apps/web/components/apps/AppPage.tsx @@ -160,13 +160,21 @@ export const AppPage = ({ function refactorMeWithoutEffect() { const data = appDbQuery.data; - const credentialsCount = data?.credentials.length || 0; - setExistingCredentials(data?.credentials || []); + const credentials = data?.credentials || []; + setExistingCredentials(credentials); + + const hasPersonalInstall = credentials.some((c) => !!c.userId && !c.teamId); + const installedTeamIds = new Set(); + for (const cred of credentials) { + if (cred.teamId) installedTeamIds.add(cred.teamId); + } + + const totalInstalledTargets = (hasPersonalInstall ? 1 : 0) + installedTeamIds.size; const appInstalledForAllTargets = availableForTeams && data?.userAdminTeams && data.userAdminTeams.length > 0 - ? credentialsCount >= data.userAdminTeams.length - : credentialsCount > 0; + ? totalInstalledTargets >= data.userAdminTeams.length + 1 + : credentials.length > 0; setAppInstalledForAllTargets(appInstalledForAllTargets); }, [appDbQuery.data, availableForTeams] @@ -244,7 +252,14 @@ export const AppPage = ({ loading: isLoading, }; } - return ; + + return ( + + ); }} /> ); diff --git a/apps/web/components/apps/alby/AlbyPaymentComponent.tsx b/apps/web/components/apps/alby/AlbyPaymentComponent.tsx index e65d71e65727e3..0e2e8f8392863a 100644 --- a/apps/web/components/apps/alby/AlbyPaymentComponent.tsx +++ b/apps/web/components/apps/alby/AlbyPaymentComponent.tsx @@ -5,8 +5,8 @@ import { useEffect, useState } from "react"; import QRCode from "react-qr-code"; import z from "zod"; +import { useBookingSuccessRedirect } from "@calcom/features/bookings/lib/bookingSuccessRedirect"; import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment"; -import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; diff --git a/apps/web/components/apps/btcpayserver/BtcpayPaymentComponent.tsx b/apps/web/components/apps/btcpayserver/BtcpayPaymentComponent.tsx index 1a07443012e67e..870c5e8c0bcf13 100644 --- a/apps/web/components/apps/btcpayserver/BtcpayPaymentComponent.tsx +++ b/apps/web/components/apps/btcpayserver/BtcpayPaymentComponent.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import z from "zod"; import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment"; -import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect"; +import { useBookingSuccessRedirect } from "@calcom/features/bookings/lib/bookingSuccessRedirect"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index f673aa9f6440fb..72ae646473f5ce 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -52,6 +52,7 @@ import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog"; import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import { ReassignDialog } from "@components/dialog/ReassignDialog"; +import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog"; import { RerouteDialog } from "@components/dialog/RerouteDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; @@ -60,9 +61,11 @@ import { getCancelEventAction, getEditEventActions, getAfterEventActions, + getReportAction, shouldShowPendingActions, shouldShowEditActions, shouldShowRecurringCancelAction, + shouldShowIndividualReportButton, type BookingActionContext, } from "./bookingActions"; @@ -181,6 +184,13 @@ function BookingListItem(booking: BookingItemProps) { const isPending = booking.status === BookingStatus.PENDING; const isRescheduled = booking.fromReschedule !== null; const isRecurring = booking.recurringEventId !== null; + + const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => { + if (isCancelled) return "cancelled"; + if (isRejected) return "rejected"; + if (isBookingInPast) return "past"; + return "upcoming"; + }; const isTabRecurring = booking.listingStatus === "recurring"; const isTabUnconfirmed = booking.listingStatus === "unconfirmed"; const isBookingFromRoutingForm = isBookingReroutable(parsedBooking); @@ -292,6 +302,7 @@ function BookingListItem(booking: BookingItemProps) { const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false); const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false); const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false); + const [isOpenReportDialog, setIsOpenReportDialog] = useState(false); const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false); const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({ onSuccess: () => { @@ -402,6 +413,12 @@ function BookingListItem(booking: BookingItemProps) { (action.id === "view_recordings" && !booking.isRecorded), })) as ActionType[]; + const reportAction = getReportAction(actionContext); + const reportActionWithHandler = { + ...reportAction, + onClick: () => setIsOpenReportDialog(true), + }; + return ( <> + {booking.paid && booking.payment[0] && ( ))} + <> + + + + {reportActionWithHandler.label} + + + )} {shouldShowRecurringCancelAction(actionContext) && } + {shouldShowIndividualReportButton(actionContext) && ( +
+
+ )} {isRejected &&
{t("rejected")}
} {isCancelled && booking.rescheduled && (
@@ -776,6 +833,24 @@ const BookingItemBadges = ({ {booking?.assignmentReason.length > 0 && ( )} + {booking.report && ( + + {(() => { + const reasonKey = `report_reason_${booking.report.reason.toLowerCase()}`; + const reasonText = t(reasonKey); + return booking.report.description + ? `${reasonText}: ${booking.report.description}` + : reasonText; + })()} +
+ }> + + {t("reported")} + + + )} {booking.paid && !booking.payment[0] ? ( {t("error_collecting_card")} diff --git a/apps/web/components/booking/bookingActions.ts b/apps/web/components/booking/bookingActions.ts index f98e99266a5d2a..96457a65da5ff2 100644 --- a/apps/web/components/booking/bookingActions.ts +++ b/apps/web/components/booking/bookingActions.ts @@ -64,13 +64,14 @@ export function getPendingActions(context: BookingActionContext): ActionType[] { export function getCancelEventAction(context: BookingActionContext): ActionType { const { booking, isTabRecurring, isRecurring, getSeatReferenceUid, t } = context; + const seatReferenceUid = getSeatReferenceUid(); return { id: "cancel", label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"), href: `/booking/${booking.uid}?cancel=true${ isTabRecurring && isRecurring ? "&allRemainingBookings=true" : "" - }${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}`, + }${booking.seatsReferences.length && seatReferenceUid ? `&seatReferenceUid=${seatReferenceUid}` : ""}`, icon: "circle-x", color: "destructive", disabled: isActionDisabled("cancel", context), @@ -106,6 +107,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[] isAttendee, t, } = context; + const seatReferenceUid = getSeatReferenceUid(); const actions: (ActionType | null)[] = [ { @@ -113,7 +115,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[] icon: "clock", label: t("reschedule_booking"), href: `/reschedule/${booking.uid}${ - booking.seatsReferences.length && isAttendee ? `?seatReferenceUid=${getSeatReferenceUid()}` : "" + booking.seatsReferences.length && isAttendee && seatReferenceUid ? `?seatReferenceUid=${seatReferenceUid}` : "" }`, disabled: (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling, @@ -163,6 +165,18 @@ export function getEditEventActions(context: BookingActionContext): ActionType[] return actions.filter(Boolean) as ActionType[]; } +export function getReportAction(context: BookingActionContext): ActionType { + const { booking, t } = context; + + return { + id: "report", + label: t("report_booking"), + icon: "flag", + color: "destructive", + disabled: !!booking.report, + }; +} + export function getAfterEventActions(context: BookingActionContext): ActionType[] { const { booking, cardCharged, attendeeList, t } = context; @@ -203,9 +217,14 @@ export function shouldShowRecurringCancelAction(context: BookingActionContext): return isTabRecurring && isRecurring; } +export function shouldShowIndividualReportButton(context: BookingActionContext): boolean { + const { booking, isPending, isUpcoming, isCancelled, isRejected } = context; + const hasDropdown = shouldShowEditActions(context); + return !booking.report && !hasDropdown && (isCancelled || isRejected || (isPending && isUpcoming)); +} + export function isActionDisabled(actionId: string, context: BookingActionContext): boolean { - const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isPending, isConfirmed } = - context; + const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling } = context; switch (actionId) { case "reschedule": @@ -225,7 +244,7 @@ export function isActionDisabled(actionId: string, context: BookingActionContext } export function getActionLabel(actionId: string, context: BookingActionContext): string { - const { booking, isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context; + const { isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context; switch (actionId) { case "reject": diff --git a/apps/web/components/dialog/ReportBookingDialog.tsx b/apps/web/components/dialog/ReportBookingDialog.tsx new file mode 100644 index 00000000000000..b926e546eaed31 --- /dev/null +++ b/apps/web/components/dialog/ReportBookingDialog.tsx @@ -0,0 +1,139 @@ +import type { Dispatch, SetStateAction } from "react"; +import { Controller, useForm } from "react-hook-form"; + +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { BookingReportReason } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; +import { Alert } from "@calcom/ui/components/alert"; +import { Button } from "@calcom/ui/components/button"; +import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; +import { Select, Label } from "@calcom/ui/components/form"; +import { TextArea } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; + +type BookingReportStatus = "upcoming" | "past" | "cancelled" | "rejected"; + +interface IReportBookingDialog { + isOpenDialog: boolean; + setIsOpenDialog: Dispatch>; + bookingUid: string; + isRecurring: boolean; + status: BookingReportStatus; +} + +interface FormValues { + reason: BookingReportReason; + description: string; +} + +export const ReportBookingDialog = (props: IReportBookingDialog) => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const { isOpenDialog, setIsOpenDialog, bookingUid, status } = props; + + const willBeCancelled = status === "upcoming"; + + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + defaultValues: { + reason: BookingReportReason.SPAM, + description: "", + }, + }); + + const { mutate: reportBooking, isPending } = trpc.viewer.bookings.reportBooking.useMutation({ + async onSuccess(data) { + showToast(data.message, "success"); + setIsOpenDialog(false); + await utils.viewer.bookings.invalidate(); + }, + onError(error) { + showToast(error.message || t("unexpected_error_try_again"), "error"); + }, + }); + + const onSubmit = (data: FormValues) => { + reportBooking({ + bookingUid, + reason: data.reason, + description: data.description || undefined, + }); + }; + + const reasonOptions = [ + { label: t("report_reason_spam"), value: BookingReportReason.SPAM }, + { label: t("report_reason_dont_know_person"), value: BookingReportReason.DONT_KNOW_PERSON }, + { label: t("report_reason_other"), value: BookingReportReason.OTHER }, + ]; + + return ( + + +
+
+
+ +
+ + ( +