From 883d745784aac92ac3a551d49cd1a2d6a58d6e6e Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Tue, 23 Apr 2024 15:48:44 -0400 Subject: [PATCH] feat: SMS/Whatsapp to attendee for teams (#14648) * remove event type requires confirmation * allow sms sending also for team (frontend) * allow text to attendee for team (backend) * add rate limiting for SMS (WIP) * add functionality to lock and unlock SMS sending (+admin UI view) * rename to smsLockState * add ability to lock users and teams * don't send sms if locked * add missing imports * fix rate limit namespace * add translation * fix type error * remove prop from UsersTable * add smsLockState to buildUser * code clean up * create seperate sms rate limit function * fix type error * add missing userId and teamId to sendSMS * code improvements * add User Team type * fix marking as reviewed * check monthly sms limit only for user * don't lock orgs * fix type error * fix avatars * fix type error * fix type error * fix type error --------- Co-authored-by: CarinaWolli Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- .../settings/admin/lockedSMS/UsersTable.tsx | 150 ++++++++++++++++++ .../pages/settings/admin/lockedSMS/index.tsx | 13 ++ .../admin/lockedSMS/lockedSMSView.tsx | 82 ++++++++++ apps/web/public/static/locales/en/common.json | 1 + .../bookings/lib/handleCancelBooking.ts | 1 - .../bookings/lib/handleConfirmation.ts | 1 - .../features/bookings/lib/handleNewBooking.ts | 1 - .../bookings/lib/handleSeats/handleSeats.ts | 1 - .../ee/workflows/api/scheduleSMSReminders.ts | 32 ++-- .../api/scheduleWhatsappReminders.ts | 50 +++--- .../workflows/components/AddActionDialog.tsx | 3 +- .../components/EventWorkflowsTab.tsx | 16 -- .../components/WorkflowStepContainer.tsx | 23 +-- .../ee/workflows/lib/actionHelperFunctions.ts | 5 +- .../features/ee/workflows/lib/getOptions.ts | 11 +- .../ee/workflows/lib/getWorkflowReminders.ts | 10 +- .../lib/reminders/providers/twilioProvider.ts | 89 ++++++++++- .../lib/reminders/reminderScheduler.ts | 22 +-- .../lib/reminders/smsReminderManager.ts | 31 ++-- .../lib/reminders/whatsappReminderManager.ts | 29 ++-- .../settings/layouts/SettingsLayout.tsx | 1 + .../settings/layouts/SettingsLayoutAppDir.tsx | 1 + packages/lib/checkRateLimitAndThrowError.ts | 54 +++++++ packages/lib/rateLimit.ts | 18 ++- packages/lib/test/builder.ts | 2 + .../migration.sql | 8 + packages/prisma/schema.prisma | 8 + .../server/routers/viewer/admin/_router.ts | 15 ++ .../getSMSLockStateTeamsUsers.handler.ts | 71 +++++++++ .../admin/getSMSLockStateTeamsUsers.schema.ts | 1 + .../viewer/admin/setSMSLockState.handler.ts | 90 +++++++++++ .../viewer/admin/setSMSLockState.schema.ts | 11 ++ .../viewer/workflows/update.handler.ts | 27 +--- .../ui/components/form/select/components.tsx | 11 +- 34 files changed, 725 insertions(+), 164 deletions(-) create mode 100644 apps/web/pages/settings/admin/lockedSMS/UsersTable.tsx create mode 100644 apps/web/pages/settings/admin/lockedSMS/index.tsx create mode 100644 apps/web/pages/settings/admin/lockedSMS/lockedSMSView.tsx create mode 100644 packages/prisma/migrations/20240417175106_add_sms_lock_state/migration.sql create mode 100644 packages/trpc/server/routers/viewer/admin/getSMSLockStateTeamsUsers.handler.ts create mode 100644 packages/trpc/server/routers/viewer/admin/getSMSLockStateTeamsUsers.schema.ts create mode 100644 packages/trpc/server/routers/viewer/admin/setSMSLockState.handler.ts create mode 100644 packages/trpc/server/routers/viewer/admin/setSMSLockState.schema.ts diff --git a/apps/web/pages/settings/admin/lockedSMS/UsersTable.tsx b/apps/web/pages/settings/admin/lockedSMS/UsersTable.tsx new file mode 100644 index 0000000000..e0a639ac7d --- /dev/null +++ b/apps/web/pages/settings/admin/lockedSMS/UsersTable.tsx @@ -0,0 +1,150 @@ +import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import { SMSLockState } from "@calcom/prisma/client"; +import { trpc } from "@calcom/trpc/react"; +import type { IconName } from "@calcom/ui"; +import { Avatar, DropdownActions, Table } from "@calcom/ui"; + +const { Cell, ColumnTitle, Header, Row } = Table; + +type Props = { + setSMSLockState: (param: { userId?: number; teamId?: number; lock: boolean }) => void; +}; + +type User = { + id: number; + username: string | null; + name: string | null; + email: string; + smsLockState: SMSLockState; + avatarUrl: string | null; +}; + +type Team = { + id: number; + name: string; + smsLockState: SMSLockState; + slug: string | null; + logoUrl?: string | null; +}; + +function UsersTable({ setSMSLockState }: Props) { + const { data: usersAndTeams } = trpc.viewer.admin.getSMSLockStateTeamsUsers.useQuery(); + + if (!usersAndTeams) { + return <>; + } + + const users = usersAndTeams.users.locked.concat(usersAndTeams.users.reviewNeeded); + const teams = usersAndTeams.teams.locked.concat(usersAndTeams.teams.reviewNeeded); + + return ; +} + +const LockStatusTable = ({ + users = [], + teams = [], + setSMSLockState, +}: { + users?: User[]; + teams?: Team[]; + setSMSLockState: (param: { userId?: number; teamId?: number; lock: boolean }) => void; +}) => { + function getActions({ user, team }: { user?: User; team?: Team }) { + const smsLockState = user?.smsLockState ?? team?.smsLockState; + if (!smsLockState) return []; + + const actions = [ + { + id: "unlock-sms", + label: smsLockState === SMSLockState.LOCKED ? "Unlock SMS sending" : "Lock SMS sending", + onClick: () => + setSMSLockState({ + userId: user ? user.id : undefined, + teamId: team ? team.id : undefined, + lock: smsLockState !== SMSLockState.LOCKED, + }), + icon: "lock" as IconName, + }, + ]; + if (smsLockState === SMSLockState.REVIEW_NEEDED) { + actions.push({ + id: "reviewed", + label: "Mark as Reviewed", + onClick: () => + setSMSLockState({ + userId: user ? user.id : undefined, + teamId: team ? team.id : undefined, + lock: false, + }), + icon: "pencil" as IconName, + }); + } + + return actions; + } + + return ( + <> + +
+ User/Team + Status + + Edit + +
+ + + {users.map((user) => ( + + +
+ {" "} + +
+ {user.name} + /{user.username} +
+ {user.email} +
+
+
+ + {user.smsLockState} + + + +
+ ))} + {teams.map((team) => ( + + +
+ +
+ {team.name} + /team/{team.slug} +
+
+
+ {team.smsLockState} + + + +
+ ))} + +
+ + ); +}; +export default UsersTable; diff --git a/apps/web/pages/settings/admin/lockedSMS/index.tsx b/apps/web/pages/settings/admin/lockedSMS/index.tsx new file mode 100644 index 0000000000..bb9f9b6f6c --- /dev/null +++ b/apps/web/pages/settings/admin/lockedSMS/index.tsx @@ -0,0 +1,13 @@ +"use client"; + +import PageWrapper from "@components/PageWrapper"; +import { getLayout } from "@components/auth/layouts/AdminLayout"; + +import LockedSMSView from "./lockedSMSView"; + +const LockedSMSPage = () => ; + +LockedSMSPage.getLayout = getLayout; +LockedSMSPage.PageWrapper = PageWrapper; + +export default LockedSMSPage; diff --git a/apps/web/pages/settings/admin/lockedSMS/lockedSMSView.tsx b/apps/web/pages/settings/admin/lockedSMS/lockedSMSView.tsx new file mode 100644 index 0000000000..079586abd6 --- /dev/null +++ b/apps/web/pages/settings/admin/lockedSMS/lockedSMSView.tsx @@ -0,0 +1,82 @@ +"use client"; + +import UsersTable from "@pages/settings/admin/lockedSMS/UsersTable"; +import { useState } from "react"; + +import { trpc } from "@calcom/trpc"; +import { Button, Meta, TextField, showToast } from "@calcom/ui"; + +export default function LockedSMSView() { + const [username, setUsername] = useState(""); + const [teamSlug, setTeamSlug] = useState(""); + + const utils = trpc.useContext(); + + const mutation = trpc.viewer.admin.setSMSLockState.useMutation({ + onSuccess: (data) => { + if (data) { + showToast(`${data.name} successfully ${data.locked ? "locked" : "unlocked"}`, "success"); + } + utils.viewer.admin.getSMSLockStateTeamsUsers.invalidate(); + }, + onError: (error) => { + showToast(`${error}`, "error"); + utils.viewer.admin.getSMSLockStateTeamsUsers.invalidate(); + }, + }); + + function setSMSLockState({ userId, teamId, lock }: { userId?: number; teamId?: number; lock: boolean }) { + mutation.mutate({ + userId, + teamId, + lock, + }); + } + + return ( +
+ +
+
+ setUsername(event.target.value)} + value={username} + /> + +
+
+ { + setTeamSlug(event.target.value); + }} + value={teamSlug} + /> + +
+
+ +
+ ); +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 91779164e3..bd79bbce90 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2413,5 +2413,6 @@ "no_show_url_info":"The URL for No Show Feedback", "no_support_needed":"No Support Needed?", "hide_support":"Hide Support", + "lockedSMS": "Locked SMS", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 3456170bc4..a368fea996 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -316,7 +316,6 @@ async function handler(req: CustomRequest) { ...{ eventType: { slug: bookingToDelete.eventType.slug } }, }, hideBranding: !!bookingToDelete.eventType.owner?.hideBranding, - eventTypeRequiresConfirmation: bookingToDelete.eventType.requiresConfirmation, }); } diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index d2c0f394a3..fb85e1b319 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -288,7 +288,6 @@ export async function handleConfirmation(args: { calendarEvent: evtOfBooking, isFirstRecurringEvent: isFirstBooking, hideBranding: !!updatedBookings[index].eventType?.owner?.hideBranding, - eventTypeRequiresConfirmation: true, }); } } catch (error) { diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 21d7c929a5..8f5090ed8b 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -2354,7 +2354,6 @@ async function handler( isFirstRecurringEvent: true, hideBranding: !!eventType.owner?.hideBranding, seatReferenceUid: evt.attendeeSeatId, - eventTypeRequiresConfirmation: eventType.requiresConfirmation, }); } catch (error) { loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error })); diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index ae96d4ab4f..81d8e62fd2 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -110,7 +110,6 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { isFirstRecurringEvent: true, emailAttendeeSendToOverride: bookerEmail, seatReferenceUid: evt.attendeeSeatId, - eventTypeRequiresConfirmation: eventType.requiresConfirmation, }); } catch (error) { loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error })); diff --git a/packages/features/ee/workflows/api/scheduleSMSReminders.ts b/packages/features/ee/workflows/api/scheduleSMSReminders.ts index 3faf361ce7..299d9bac63 100644 --- a/packages/features/ee/workflows/api/scheduleSMSReminders.ts +++ b/packages/features/ee/workflows/api/scheduleSMSReminders.ts @@ -56,6 +56,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (!reminder.workflowStep || !reminder.booking) { continue; } + const userId = reminder.workflowStep.workflow.userId; + const teamId = reminder.workflowStep.workflow.teamId; + try { const sendTo = reminder.workflowStep.action === WorkflowActions.SMS_NUMBER @@ -141,17 +144,26 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } if (message?.length && message?.length > 0 && sendTo) { - const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate, senderID); + const scheduledSMS = await twilio.scheduleSMS( + sendTo, + message, + reminder.scheduledDate, + senderID, + userId, + teamId + ); - await prisma.workflowReminder.update({ - where: { - id: reminder.id, - }, - data: { - scheduled: true, - referenceId: scheduledSMS.sid, - }, - }); + if (scheduledSMS) { + await prisma.workflowReminder.update({ + where: { + id: reminder.id, + }, + data: { + scheduled: true, + referenceId: scheduledSMS.sid, + }, + }); + } } } catch (error) { console.log(`Error scheduling SMS with error ${error}`); diff --git a/packages/features/ee/workflows/api/scheduleWhatsappReminders.ts b/packages/features/ee/workflows/api/scheduleWhatsappReminders.ts index c4021c4712..5660407715 100644 --- a/packages/features/ee/workflows/api/scheduleWhatsappReminders.ts +++ b/packages/features/ee/workflows/api/scheduleWhatsappReminders.ts @@ -8,6 +8,8 @@ import prisma from "@calcom/prisma"; import { WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums"; import { getWhatsappTemplateFunction } from "../lib/actionHelperFunctions"; +import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders"; +import { select } from "../lib/getWorkflowReminders"; import * as twilio from "../lib/reminders/providers/twilioProvider"; async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -28,7 +30,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }); //find all unscheduled WHATSAPP reminders - const unscheduledReminders = await prisma.workflowReminder.findMany({ + const unscheduledReminders = (await prisma.workflowReminder.findMany({ where: { method: WorkflowMethods.WHATSAPP, scheduled: false, @@ -36,17 +38,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { lte: dayjs().add(7, "day").toISOString(), }, }, - include: { - workflowStep: true, - booking: { - include: { - eventType: true, - user: true, - attendees: true, - }, - }, - }, - }); + select, + })) as PartialWorkflowReminder[]; if (!unscheduledReminders.length) { res.json({ ok: true }); @@ -57,6 +50,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (!reminder.workflowStep || !reminder.booking) { continue; } + const userId = reminder.workflowStep.workflow.userId; + const teamId = reminder.workflowStep.workflow.teamId; + try { const sendTo = reminder.workflowStep.action === WorkflowActions.WHATSAPP_NUMBER @@ -91,17 +87,27 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ); if (message?.length && message?.length > 0 && sendTo) { - const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate, "", true); + const scheduledSMS = await twilio.scheduleSMS( + sendTo, + message, + reminder.scheduledDate, + "", + userId, + teamId, + true + ); - await prisma.workflowReminder.update({ - where: { - id: reminder.id, - }, - data: { - scheduled: true, - referenceId: scheduledSMS.sid, - }, - }); + if (scheduledSMS) { + await prisma.workflowReminder.update({ + where: { + id: reminder.id, + }, + data: { + scheduled: true, + referenceId: scheduledSMS.sid, + }, + }); + } } } catch (error) { console.log(`Error scheduling WHATSAPP with error ${error}`); diff --git a/packages/features/ee/workflows/components/AddActionDialog.tsx b/packages/features/ee/workflows/components/AddActionDialog.tsx index e1f492abab..9af66441c0 100644 --- a/packages/features/ee/workflows/components/AddActionDialog.tsx +++ b/packages/features/ee/workflows/components/AddActionDialog.tsx @@ -175,8 +175,7 @@ export const AddActionDialog = (props: IAddActionDialog) => { label: string; value: WorkflowActions; needsTeamsUpgrade: boolean; - needsOrgsUpgrade: boolean; - }) => option.needsTeamsUpgrade || option.needsOrgsUpgrade} + }) => option.needsTeamsUpgrade} /> ); }} diff --git a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx b/packages/features/ee/workflows/components/EventWorkflowsTab.tsx index c643725171..f45ec61ed7 100644 --- a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx +++ b/packages/features/ee/workflows/components/EventWorkflowsTab.tsx @@ -5,7 +5,6 @@ import { useFormContext } from "react-hook-form"; import { Trans } from "react-i18next"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; -import { isTextMessageToAttendeeAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; import type { FormValues } from "@calcom/features/eventtypes/lib/types"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -105,12 +104,6 @@ const WorkflowListItem = (props: ItemProps) => { } }); - const needsRequiresConfirmationWarning = - !eventType.requiresConfirmation && - workflow.steps.find((step) => { - return isTextMessageToAttendeeAction(step.action); - }); - return (
@@ -179,15 +172,6 @@ const WorkflowListItem = (props: ItemProps) => {
- - {needsRequiresConfirmationWarning ? ( -
- -

{t("requires_confirmation_mandatory")}

-
- ) : ( - <> - )} ); }; diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index 75ae7ac75b..db99627dad 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -45,7 +45,6 @@ import { isAttendeeAction, isSMSAction, isSMSOrWhatsappAction, - isTextMessageToAttendeeAction, isWhatsappAction, } from "../lib/actionHelperFunctions"; import { DYNAMIC_TEXT_VARIABLES } from "../lib/constants"; @@ -120,10 +119,6 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { form.getValues("trigger") === WorkflowTriggerEvents.AFTER_EVENT ); - const [isRequiresConfirmationNeeded, setIsRequiresConfirmationNeeded] = useState( - isTextMessageToAttendeeAction(step?.action) - ); - const { data: actionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery(); const triggerOptions = getWorkflowTriggerOptions(t); const templateOptions = getWorkflowTemplateOptions(t, step?.action); @@ -345,7 +340,6 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { label: actionString.charAt(0).toUpperCase() + actionString.slice(1), value: step.action, needsTeamsUpgrade: false, - needsOrgsUpgrade: false, }; const selectedTemplate = { label: t(`${step.template.toLowerCase()}`), value: step.template }; @@ -463,12 +457,6 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { setIsEmailSubjectNeeded(true); } - if (isTextMessageToAttendeeAction(val.value)) { - setIsRequiresConfirmationNeeded(true); - } else { - setIsRequiresConfirmationNeeded(false); - } - if ( form.getValues(`steps.${step.stepNumber - 1}.template`) === WorkflowTemplates.REMINDER @@ -541,20 +529,11 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { label: string; value: WorkflowActions; needsTeamsUpgrade: boolean; - needsOrgsUpgrade: boolean; - }) => option.needsTeamsUpgrade || option.needsOrgsUpgrade} + }) => option.needsTeamsUpgrade} /> ); }} /> - {isRequiresConfirmationNeeded ? ( -
- -

{t("requires_confirmation_mandatory")}

-
- ) : ( - <> - )} {isPhoneNumberNeeded && (
diff --git a/packages/features/ee/workflows/lib/actionHelperFunctions.ts b/packages/features/ee/workflows/lib/actionHelperFunctions.ts index b66f2b7734..18b126bec3 100644 --- a/packages/features/ee/workflows/lib/actionHelperFunctions.ts +++ b/packages/features/ee/workflows/lib/actionHelperFunctions.ts @@ -42,9 +42,6 @@ export function isEmailToAttendeeAction(action: WorkflowActions) { return action === WorkflowActions.EMAIL_ATTENDEE; } -export function isTextMessageToAttendeeAction(action?: WorkflowActions) { - return action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.WHATSAPP_ATTENDEE; -} export function isTextMessageToSpecificNumber(action?: WorkflowActions) { return action === WorkflowActions.SMS_NUMBER || action === WorkflowActions.WHATSAPP_NUMBER; } @@ -65,7 +62,7 @@ export function getWhatsappTemplateForTrigger(trigger: WorkflowTriggerEvents): W } } -export function getWhatsappTemplateFunction(template: WorkflowTemplates): typeof whatsappReminderTemplate { +export function getWhatsappTemplateFunction(template?: WorkflowTemplates): typeof whatsappReminderTemplate { switch (template) { case "CANCELLED": return whatsappEventCancelledTemplate; diff --git a/packages/features/ee/workflows/lib/getOptions.ts b/packages/features/ee/workflows/lib/getOptions.ts index a1d80149ec..7b4cec8d49 100644 --- a/packages/features/ee/workflows/lib/getOptions.ts +++ b/packages/features/ee/workflows/lib/getOptions.ts @@ -2,12 +2,7 @@ import type { TFunction } from "next-i18next"; import { WorkflowActions } from "@calcom/prisma/enums"; -import { - isTextMessageToAttendeeAction, - isSMSOrWhatsappAction, - isWhatsappAction, - isEmailToAttendeeAction, -} from "./actionHelperFunctions"; +import { isSMSOrWhatsappAction, isWhatsappAction, isEmailToAttendeeAction } from "./actionHelperFunctions"; import { TIME_UNIT, WHATSAPP_WORKFLOW_TEMPLATES, @@ -25,9 +20,7 @@ export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean, is return { label: actionString.charAt(0).toUpperCase() + actionString.slice(1), value: action, - needsTeamsUpgrade: - isSMSOrWhatsappAction(action) && !isTextMessageToAttendeeAction(action) && !isTeamsPlan, - needsOrgsUpgrade: isTextMessageToAttendeeAction(action) && !isOrgsPlan, + needsTeamsUpgrade: isSMSOrWhatsappAction(action) && !isTeamsPlan, }; }); } diff --git a/packages/features/ee/workflows/lib/getWorkflowReminders.ts b/packages/features/ee/workflows/lib/getWorkflowReminders.ts index 4c88206dbd..ea07e4cafa 100644 --- a/packages/features/ee/workflows/lib/getWorkflowReminders.ts +++ b/packages/features/ee/workflows/lib/getWorkflowReminders.ts @@ -3,7 +3,9 @@ import prisma from "@calcom/prisma"; import type { EventType, Prisma, User, WorkflowReminder, WorkflowStep } from "@calcom/prisma/client"; import { WorkflowMethods } from "@calcom/prisma/enums"; -type PartialWorkflowStep = Partial | null; +type PartialWorkflowStep = + | (Partial & { workflow: { userId?: number; teamId?: number } }) + | null; type Booking = Prisma.BookingGetPayload<{ include: { @@ -120,6 +122,12 @@ export const select: Prisma.WorkflowReminderSelect = { template: true, sender: true, includeCalendarEvent: true, + workflow: { + select: { + userId: true, + teamId: true, + }, + }, }, }, booking: { diff --git a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts index d5eb0d1e2a..1f136929dd 100644 --- a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts @@ -1,10 +1,17 @@ import TwilioClient from "twilio"; +import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError"; +import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; +import { SMSLockState } from "@calcom/prisma/enums"; + declare global { // eslint-disable-next-line no-var var twilio: TwilioClient.Twilio | undefined; } +const log = logger.getSubLogger({ prefix: ["[twilioProvider]"] }); + export const twilio = globalThis.twilio || (process.env.TWILIO_SID && process.env.TWILIO_TOKEN && process.env.TWILIO_MESSAGING_SID) @@ -31,8 +38,30 @@ function getSMSNumber(phone: string, whatsapp = false) { return whatsapp ? `whatsapp:${phone}` : phone; } -export const sendSMS = async (phoneNumber: string, body: string, sender: string, whatsapp = false) => { +export const sendSMS = async ( + phoneNumber: string, + body: string, + sender: string, + userId?: number | null, + teamId?: number | null, + whatsapp = false +) => { assertTwilio(twilio); + + const isSMSSendingLocked = await isLockedForSMSSending(userId, teamId); + + if (isSMSSendingLocked) { + log.debug(`${teamId ? `Team id ${teamId} ` : `User id ${userId} `} is locked for SMS sending `); + return; + } + + if (!teamId && userId) { + await checkSMSRateLimit({ + identifier: `sms:user:${userId}`, + rateLimitingType: "smsMonth", + }); + } + const response = await twilio.messages.create({ body: body, messagingServiceSid: process.env.TWILIO_MESSAGING_SID, @@ -48,9 +77,26 @@ export const scheduleSMS = async ( body: string, scheduledDate: Date, sender: string, + userId?: number | null, + teamId?: number | null, whatsapp = false ) => { assertTwilio(twilio); + + const isSMSSendingLocked = await isLockedForSMSSending(userId, teamId); + + if (isSMSSendingLocked) { + log.debug(`${teamId ? `Team id ${teamId} ` : `User id ${userId} `} is locked for SMS sending `); + return; + } + + if (!teamId && userId) { + await checkSMSRateLimit({ + identifier: `sms:user:${userId}`, + rateLimitingType: "smsMonth", + }); + } + const response = await twilio.messages.create({ body: body, messagingServiceSid: process.env.TWILIO_MESSAGING_SID, @@ -90,3 +136,44 @@ export const verifyNumber = async (phoneNumber: string, code: string) => { } } }; + +async function isLockedForSMSSending(userId?: number | null, teamId?: number | null) { + if (teamId) { + const team = await prisma.team.findFirst({ + where: { + id: teamId, + }, + }); + return team?.smsLockState === SMSLockState.LOCKED; + } + + if (userId) { + const memberships = await prisma.membership.findMany({ + where: { + userId: userId, + }, + select: { + team: { + select: { + smsLockState: true, + }, + }, + }, + }); + + const memberOfLockedTeam = memberships.find( + (membership) => membership.team.smsLockState === SMSLockState.LOCKED + ); + + if (!!memberOfLockedTeam) { + return true; + } + + const user = await prisma.user.findFirst({ + where: { + id: userId, + }, + }); + return user?.smsLockState === SMSLockState.LOCKED; + } +} diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index c23291ab94..68422b62cc 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -1,10 +1,7 @@ import type { Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client"; -import { - isSMSAction, - isTextMessageToAttendeeAction, - isWhatsappAction, -} from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; +import { isSMSAction, isWhatsappAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; +import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError"; import { SENDER_NAME } from "@calcom/lib/constants"; import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -25,7 +22,6 @@ type ProcessWorkflowStepParams = { emailAttendeeSendToOverride?: string; hideBranding?: boolean; seatReferenceUid?: string; - eventTypeRequiresConfirmation?: boolean; }; export interface ScheduleWorkflowRemindersArgs extends ProcessWorkflowStepParams { @@ -48,12 +44,14 @@ const processWorkflowStep = async ( emailAttendeeSendToOverride, hideBranding, seatReferenceUid, - eventTypeRequiresConfirmation, }: ProcessWorkflowStepParams ) => { - if (isTextMessageToAttendeeAction(step.action) && !eventTypeRequiresConfirmation) return; - if (isSMSAction(step.action)) { + await checkSMSRateLimit({ + identifier: `sms:${workflow.teamId ? "team:" : "user:"}${workflow.teamId || workflow.userId}`, + rateLimitingType: "sms", + }); + const sendTo = step.action === WorkflowActions.SMS_ATTENDEE ? smsReminderNumber : step.sendTo; await scheduleSMSReminder({ evt, @@ -141,7 +139,6 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA emailAttendeeSendToOverride = "", hideBranding, seatReferenceUid, - eventTypeRequiresConfirmation = false, } = args; if (isNotConfirmed || !workflows.length) return; @@ -173,7 +170,6 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA smsReminderNumber, hideBranding, seatReferenceUid, - eventTypeRequiresConfirmation, }); } } @@ -204,11 +200,10 @@ export interface SendCancelledRemindersArgs { smsReminderNumber: string | null; evt: ExtendedCalendarEvent; hideBranding?: boolean; - eventTypeRequiresConfirmation?: boolean; } export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) => { - const { workflows, smsReminderNumber, evt, hideBranding, eventTypeRequiresConfirmation } = args; + const { workflows, smsReminderNumber, evt, hideBranding } = args; if (!workflows.length) return; for (const workflowRef of workflows) { @@ -221,7 +216,6 @@ export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) = smsReminderNumber, hideBranding, calendarEvent: evt, - eventTypeRequiresConfirmation, }); } } diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index a555d74dff..f47cb12586 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -86,6 +86,7 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => { isVerificationPending = false, seatReferenceUid, } = args; + const { startTime, endTime } = evt; const uid = evt.uid as string; const currentDate = dayjs(); @@ -184,7 +185,7 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => { triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT ) { try { - await twilio.sendSMS(reminderPhone, smsMessage, senderID); + await twilio.sendSMS(reminderPhone, smsMessage, senderID, userId, teamId); } catch (error) { log.error(`Error sending SMS with error ${error}`); } @@ -203,20 +204,24 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => { reminderPhone, smsMessage, scheduledDate.toDate(), - senderID + senderID, + userId, + teamId ); - await prisma.workflowReminder.create({ - data: { - bookingUid: uid, - workflowStepId: workflowStepId, - method: WorkflowMethods.SMS, - scheduledDate: scheduledDate.toDate(), - scheduled: true, - referenceId: scheduledSMS.sid, - seatReferenceId: seatReferenceUid, - }, - }); + if (scheduledSMS) { + await prisma.workflowReminder.create({ + data: { + bookingUid: uid, + workflowStepId: workflowStepId, + method: WorkflowMethods.SMS, + scheduledDate: scheduledDate.toDate(), + scheduled: true, + referenceId: scheduledSMS.sid, + seatReferenceId: seatReferenceUid, + }, + }); + } } catch (error) { log.error(`Error scheduling SMS with error ${error}`); } diff --git a/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts b/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts index 6652924898..967b8a56af 100644 --- a/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts @@ -35,6 +35,7 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) = isVerificationPending = false, seatReferenceUid, } = args; + const { startTime, endTime } = evt; const uid = evt.uid as string; const currentDate = dayjs(); @@ -147,7 +148,7 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) = triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT ) { try { - await twilio.sendSMS(reminderPhone, textMessage, "", true); + await twilio.sendSMS(reminderPhone, textMessage, "", userId, teamId, true); } catch (error) { console.log(`Error sending WHATSAPP with error ${error}`); } @@ -167,20 +168,24 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) = textMessage, scheduledDate.toDate(), "", + userId, + teamId, true ); - await prisma.workflowReminder.create({ - data: { - bookingUid: uid, - workflowStepId: workflowStepId, - method: WorkflowMethods.WHATSAPP, - scheduledDate: scheduledDate.toDate(), - scheduled: true, - referenceId: scheduledWHATSAPP.sid, - seatReferenceId: seatReferenceUid, - }, - }); + if (scheduledWHATSAPP) { + await prisma.workflowReminder.create({ + data: { + bookingUid: uid, + workflowStepId: workflowStepId, + method: WorkflowMethods.WHATSAPP, + scheduledDate: scheduledDate.toDate(), + scheduled: true, + referenceId: scheduledWHATSAPP.sid, + seatReferenceId: seatReferenceUid, + }, + }); + } } catch (error) { console.log(`Error scheduling WHATSAPP with error ${error}`); } diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 329a4efda4..2281735209 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -119,6 +119,7 @@ const tabs: VerticalTabItemProps[] = [ { name: "apps", href: "/settings/admin/apps/calendar" }, { name: "users", href: "/settings/admin/users" }, { name: "organizations", href: "/settings/admin/organizations" }, + { name: "lockedSMS", href: "/settings/admin/lockedSMS" }, { name: "oAuth", href: "/settings/admin/oAuth" }, ], }, diff --git a/packages/features/settings/layouts/SettingsLayoutAppDir.tsx b/packages/features/settings/layouts/SettingsLayoutAppDir.tsx index 6f9c454bd5..c62e1bcc5d 100644 --- a/packages/features/settings/layouts/SettingsLayoutAppDir.tsx +++ b/packages/features/settings/layouts/SettingsLayoutAppDir.tsx @@ -107,6 +107,7 @@ const tabs: VerticalTabItemProps[] = [ { name: "apps", href: "/settings/admin/apps/calendar" }, { name: "users", href: "/settings/admin/users" }, { name: "organizations", href: "/settings/admin/organizations" }, + { name: "lockedSMS", href: "/settings/admin/lockedSMS" }, { name: "oAuth", href: "/settings/admin/oAuth" }, ], }, diff --git a/packages/lib/checkRateLimitAndThrowError.ts b/packages/lib/checkRateLimitAndThrowError.ts index d88845203f..64fd6189a6 100644 --- a/packages/lib/checkRateLimitAndThrowError.ts +++ b/packages/lib/checkRateLimitAndThrowError.ts @@ -1,3 +1,5 @@ +import prisma from "@calcom/prisma"; +import { SMSLockState } from "@calcom/prisma/enums"; import { TRPCError } from "@calcom/trpc/server"; import type { RateLimitHelper } from "./rateLimit"; @@ -23,3 +25,55 @@ export async function checkRateLimitAndThrowError({ }); } } + +export async function checkSMSRateLimit({ + rateLimitingType = "sms", + identifier, + onRateLimiterResponse, + opts, +}: RateLimitHelper) { + const response = await rateLimiter()({ rateLimitingType, identifier, opts }); + const { success } = response; + + if (onRateLimiterResponse) onRateLimiterResponse(response); + + if (!success) { + await changeSMSLockState( + identifier, + rateLimitingType === "sms" ? SMSLockState.LOCKED : SMSLockState.REVIEW_NEEDED + ); + } +} + +async function changeSMSLockState(identifier: string, status: SMSLockState) { + let userId, teamId; + + if (identifier.startsWith("sms:user:")) { + userId = Number(identifier.slice(9)); + } else if (identifier.startsWith("sms:team:")) { + teamId = Number(identifier.slice(9)); + } + + if (userId) { + await prisma.user.update({ + where: { + id: userId, + profiles: { none: {} }, + }, + data: { + smsLockState: status, + }, + }); + } else { + await prisma.team.update({ + where: { + id: teamId, + parentId: null, + isOrganization: false, + }, + data: { + smsLockState: status, + }, + }); + } +} diff --git a/packages/lib/rateLimit.ts b/packages/lib/rateLimit.ts index 1aab25284e..6c2c8f28a6 100644 --- a/packages/lib/rateLimit.ts +++ b/packages/lib/rateLimit.ts @@ -6,7 +6,7 @@ import logger from "./logger"; const log = logger.getSubLogger({ prefix: ["RateLimit"] }); export type RateLimitHelper = { - rateLimitingType?: "core" | "forcedSlowMode" | "common" | "api" | "ai"; + rateLimitingType?: "core" | "forcedSlowMode" | "common" | "api" | "ai" | "sms" | "smsMonth"; identifier: string; opts?: LimitOptions; /** @@ -80,6 +80,22 @@ export function rateLimiter() { async: true, timeout, }), + sms: new Ratelimit({ + rootKey: UNKEY_ROOT_KEY, + namespace: "sms", + limit: 50, + duration: "5m", + async: true, + timeout, + }), + smsMonth: new Ratelimit({ + rootKey: UNKEY_ROOT_KEY, + namespace: "smsMonth", + limit: 250, + duration: "30d", + async: true, + timeout, + }), }; async function rateLimit({ rateLimitingType = "core", identifier, opts }: RateLimitHelper) { diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 118f069a90..7f8fb00dbf 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -239,6 +239,7 @@ type UserPayload = Prisma.UserGetPayload<{ receiveMonthlyDigestEmail: true; movedToProfileId: true; isPlatformManaged: true; + smsLockState: true; }; }>; export const buildUser = >( @@ -246,6 +247,7 @@ export const buildUser = >( ): UserPayload & { priority: number | null } => { return { locked: false, + smsLockState: "UNLOCKED", name: faker.name.firstName(), email: faker.internet.email(), timeZone: faker.address.timeZone(), diff --git a/packages/prisma/migrations/20240417175106_add_sms_lock_state/migration.sql b/packages/prisma/migrations/20240417175106_add_sms_lock_state/migration.sql new file mode 100644 index 0000000000..fc85d63833 --- /dev/null +++ b/packages/prisma/migrations/20240417175106_add_sms_lock_state/migration.sql @@ -0,0 +1,8 @@ +-- CreateEnum +CREATE TYPE "SMSLockState" AS ENUM ('LOCKED', 'UNLOCKED', 'REVIEW_NEEDED'); + +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "smsLockState" "SMSLockState" NOT NULL DEFAULT 'UNLOCKED'; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "smsLockState" "SMSLockState" NOT NULL DEFAULT 'UNLOCKED'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 2c1c38d010..12198062b6 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -318,6 +318,7 @@ model User { isPlatformManaged Boolean @default(false) OutOfOfficeReasons OutOfOfficeReason[] + smsLockState SMSLockState @default(UNLOCKED) @@unique([email]) @@unique([email, username]) @@ -398,6 +399,7 @@ model Team { dsyncTeamGroupMapping DSyncTeamGroupMapping[] isPlatform Boolean @default(false) platformOAuthClient PlatformOAuthClient[] + smsLockState SMSLockState @default(UNLOCKED) @@unique([slug, parentId]) @@index([parentId]) @@ -1346,3 +1348,9 @@ model Task { maxAttempts Int @default(3) lastError String? } + +enum SMSLockState { + LOCKED + UNLOCKED + REVIEW_NEEDED +} diff --git a/packages/trpc/server/routers/viewer/admin/_router.ts b/packages/trpc/server/routers/viewer/admin/_router.ts index 9bb381d29c..c81693b56f 100644 --- a/packages/trpc/server/routers/viewer/admin/_router.ts +++ b/packages/trpc/server/routers/viewer/admin/_router.ts @@ -6,6 +6,7 @@ import { ZListMembersSchema } from "./listPaginated.schema"; import { ZAdminLockUserAccountSchema } from "./lockUserAccount.schema"; import { ZAdminRemoveTwoFactor } from "./removeTwoFactor.schema"; import { ZAdminPasswordResetSchema } from "./sendPasswordReset.schema"; +import { ZSetSMSLockState } from "./setSMSLockState.schema"; const NAMESPACE = "admin"; @@ -47,4 +48,18 @@ export const adminRouter = router({ ); return handler(opts); }), + getSMSLockStateTeamsUsers: authedAdminProcedure.query(async (opts) => { + const handler = await importHandler( + namespaced("getSMSLockStateTeamsUsers"), + () => import("./getSMSLockStateTeamsUsers.handler") + ); + return handler(opts); + }), + setSMSLockState: authedAdminProcedure.input(ZSetSMSLockState).mutation(async (opts) => { + const handler = await importHandler( + namespaced("setSMSLockState"), + () => import("./setSMSLockState.handler") + ); + return handler(opts); + }), }); diff --git a/packages/trpc/server/routers/viewer/admin/getSMSLockStateTeamsUsers.handler.ts b/packages/trpc/server/routers/viewer/admin/getSMSLockStateTeamsUsers.handler.ts new file mode 100644 index 0000000000..26606576e1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/getSMSLockStateTeamsUsers.handler.ts @@ -0,0 +1,71 @@ +import { prisma } from "@calcom/prisma"; +import { SMSLockState } from "@calcom/prisma/client"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; +}; + +const getSMSLockStateTeamsUsers = async ({ ctx }: GetOptions) => { + const userSelect = { + id: true, + smsLockState: true, + email: true, + username: true, + name: true, + avatarUrl: true, + }; + + const teamSelect = { + id: true, + smsLockState: true, + slug: true, + name: true, + logoUrl: true, + }; + + const lockedUsers = await prisma.user.findMany({ + where: { + smsLockState: SMSLockState.LOCKED, + }, + select: userSelect, + }); + const reviewNeededUsers = await prisma.user.findMany({ + where: { + smsLockState: SMSLockState.REVIEW_NEEDED, + }, + select: userSelect, + }); + + const lockedTeams = await prisma.team.findMany({ + where: { + smsLockState: SMSLockState.LOCKED, + }, + select: teamSelect, + }); + + const reviewNeededTeams = await prisma.team.findMany({ + where: { + smsLockState: SMSLockState.REVIEW_NEEDED, + }, + select: teamSelect, + }); + + const resultObj = { + users: { + locked: lockedUsers, + reviewNeeded: reviewNeededUsers, + }, + teams: { + locked: lockedTeams, + reviewNeeded: reviewNeededTeams, + }, + }; + + return resultObj; +}; + +export default getSMSLockStateTeamsUsers; diff --git a/packages/trpc/server/routers/viewer/admin/getSMSLockStateTeamsUsers.schema.ts b/packages/trpc/server/routers/viewer/admin/getSMSLockStateTeamsUsers.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/getSMSLockStateTeamsUsers.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/admin/setSMSLockState.handler.ts b/packages/trpc/server/routers/viewer/admin/setSMSLockState.handler.ts new file mode 100644 index 0000000000..a5279cf433 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/setSMSLockState.handler.ts @@ -0,0 +1,90 @@ +import { prisma } from "@calcom/prisma"; +import { SMSLockState } from "@calcom/prisma/client"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TSetSMSLockState } from "./setSMSLockState.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TSetSMSLockState; +}; + +const setSMSLockState = async ({ input }: GetOptions) => { + const { userId, username, teamId, teamSlug, lock } = input; + if (userId) { + const updatedUser = await prisma.user.update({ + where: { + id: userId, + }, + data: { + smsLockState: lock ? SMSLockState.LOCKED : SMSLockState.UNLOCKED, + }, + }); + if (!updatedUser) { + throw new TRPCError({ code: "BAD_REQUEST", message: "User not found" }); + } + return { name: updatedUser.username, locked: lock }; + } else if (username) { + const userToUpdate = await prisma.user.findFirst({ + where: { + username, + profiles: { none: {} }, + }, + }); + if (!userToUpdate) { + throw new TRPCError({ code: "BAD_REQUEST", message: "User not found" }); + } + if (userToUpdate) { + const updatedUser = await prisma.user.update({ + where: { + id: userToUpdate.id, + }, + data: { + smsLockState: lock ? SMSLockState.LOCKED : SMSLockState.UNLOCKED, + }, + }); + return { name: updatedUser.username, locked: lock }; + } + } else if (teamId) { + const updatedTeam = await prisma.team.update({ + where: { + id: teamId, + }, + data: { + smsLockState: lock ? SMSLockState.LOCKED : SMSLockState.UNLOCKED, + }, + }); + if (!updatedTeam) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Team not found" }); + } + return { name: updatedTeam.slug, locked: lock }; + } else if (teamSlug) { + const teamToUpdate = await prisma.team.findFirst({ + where: { + slug: teamSlug, + parentId: null, + }, + }); + if (!teamToUpdate) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Team not found" }); + } + if (teamToUpdate) { + const updatedTeam = await prisma.team.update({ + where: { + id: teamToUpdate.id, + }, + data: { + smsLockState: lock ? SMSLockState.LOCKED : SMSLockState.UNLOCKED, + }, + }); + return { name: updatedTeam.slug, locked: lock }; + } + } + throw new TRPCError({ code: "BAD_REQUEST", message: "Input data missing" }); +}; + +export default setSMSLockState; diff --git a/packages/trpc/server/routers/viewer/admin/setSMSLockState.schema.ts b/packages/trpc/server/routers/viewer/admin/setSMSLockState.schema.ts new file mode 100644 index 0000000000..d51d5a1be1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/setSMSLockState.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const ZSetSMSLockState = z.object({ + userId: z.number().optional(), + username: z.string().optional(), + teamId: z.number().optional(), + teamSlug: z.string().optional(), + lock: z.boolean().optional(), +}); + +export type TSetSMSLockState = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts index f729f89bf0..331b6c9ca3 100644 --- a/packages/trpc/server/routers/viewer/workflows/update.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -1,10 +1,6 @@ import type { Prisma } from "@prisma/client"; -import { - isSMSOrWhatsappAction, - isTextMessageToAttendeeAction, - isTextMessageToSpecificNumber, -} from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; +import { isSMSOrWhatsappAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; import { deleteScheduledEmailReminder, scheduleEmailReminder, @@ -84,8 +80,6 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } const hasPaidPlan = IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan; - const hasOrgsPlan = IS_SELF_HOSTED || (ctx.user.profile?.organizationId ?? null); - const where: Prisma.EventTypeWhereInput = {}; where.id = { in: activeOn, @@ -427,21 +421,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { //step was edited } else if (JSON.stringify(oldStep) !== JSON.stringify(newStep)) { // check if step that require team plan already existed before - if ( - !hasPaidPlan && - !isTextMessageToSpecificNumber(oldStep.action) && - isTextMessageToSpecificNumber(newStep.action) - ) { + if (!hasPaidPlan && !isSMSOrWhatsappAction(oldStep.action) && isSMSOrWhatsappAction(newStep.action)) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" }); } - // check if step that require org already existed before - if ( - !hasOrgsPlan && - !isTextMessageToAttendeeAction(oldStep.action) && - isTextMessageToAttendeeAction(newStep.action) - ) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "Enterprise plan required" }); - } const requiresSender = newStep.action === WorkflowActions.SMS_NUMBER || newStep.action === WorkflowActions.WHATSAPP_NUMBER; await ctx.prisma.workflowStep.update({ @@ -613,12 +595,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { //added steps const addedSteps = steps.map((s) => { if (s.id <= 0) { - if (isSMSOrWhatsappAction(s.action) && !isTextMessageToAttendeeAction(s.action) && !hasPaidPlan) { + if (isSMSOrWhatsappAction(s.action) && !hasPaidPlan) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" }); } - if (!hasOrgsPlan && isTextMessageToAttendeeAction(s.action)) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "Enterprise plan require" }); - } const { id: _stepId, ...stepToAdd } = s; return stepToAdd; } diff --git a/packages/ui/components/form/select/components.tsx b/packages/ui/components/form/select/components.tsx index e46ae36160..d41557ea9c 100644 --- a/packages/ui/components/form/select/components.tsx +++ b/packages/ui/components/form/select/components.tsx @@ -4,7 +4,7 @@ import { components as reactSelectComponents } from "react-select"; import { classNames } from "@calcom/lib"; import { Icon } from "../../.."; -import { UpgradeTeamsBadge, UpgradeOrgsBadge } from "../../badge"; +import { UpgradeTeamsBadge } from "../../badge"; import type { SelectProps } from "./Select"; export const InputComponent = < @@ -31,7 +31,6 @@ type ExtendedOption = { value: string | number; label: string; needsTeamsUpgrade?: boolean; - needsOrgsUpgrade?: boolean; }; export const OptionComponent = < @@ -48,13 +47,7 @@ export const OptionComponent = < {props.label || <> } - {(props.data as unknown as ExtendedOption).needsTeamsUpgrade ? ( - - ) : (props.data as unknown as ExtendedOption).needsOrgsUpgrade ? ( - - ) : ( - <> - )} + {(props.data as unknown as ExtendedOption).needsTeamsUpgrade ? : <>} {props.isSelected && }