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 <wollencarina@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2024-04-23 19:48:44 +00:00
1 parent 571e93a19a
commit 883d745784
34 files changed
+725 -164

No files matched your search

@@ -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 <LockStatusTable users={users} teams={teams} setSMSLockState={setSMSLockState} />;
}
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 (
<>
<Table>
<Header>
<ColumnTitle widthClassNames="w-auto">User/Team</ColumnTitle>
<ColumnTitle>Status</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">
<span className="sr-only">Edit</span>
</ColumnTitle>
</Header>
<tbody className="divide-subtle divide-y rounded-md">
{users.map((user) => (
<Row key={`user-${user.id}`}>
<Cell widthClassNames="w-auto">
<div className="min-h-10 flex items-center">
{" "}
<Avatar
size="md"
alt={`Avatar of ${user.username || "Nameless"}`}
imageSrc={getUserAvatarUrl(user)}
/>
<div className="text-subtle ml-4 font-medium">
<span className="text-default">{user.name}</span>
<span className="ml-3">/{user.username}</span>
<br />
<span className="break-all">{user.email}</span>
</div>
</div>
</Cell>
<Cell>{user.smsLockState}</Cell>
<Cell widthClassNames="w-auto">
<DropdownActions actions={getActions({ user })} />
</Cell>
</Row>
))}
{teams.map((team) => (
<Row key={`team-${team.id}`}>
<Cell widthClassNames="w-auto">
<div className="min-h-10 flex items-center">
<Avatar
size="md"
alt={`Avatar of ${team.name}`}
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)}
/>
<div className="text-subtle ml-4 font-medium">
<span className="text-default">{team.name}</span>
<span className="ml-3 break-all">/team/{team.slug}</span>
</div>
</div>
</Cell>
<Cell>{team.smsLockState}</Cell>
<Cell widthClassNames="w-auto">
<DropdownActions actions={getActions({ team })} />
</Cell>
</Row>
))}
</tbody>
</Table>
</>
);
};
export default UsersTable;
@@ -0,0 +1,13 @@
"use client";
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "@components/auth/layouts/AdminLayout";
import LockedSMSView from "./lockedSMSView";
const LockedSMSPage = () => <LockedSMSView />;
LockedSMSPage.getLayout = getLayout;
LockedSMSPage.PageWrapper = PageWrapper;
export default LockedSMSPage;
@@ -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 (
<div>
<Meta title="lockedSMS" description="Lock or unlock SMS sending for users" />
<div className="mb-4 flex w-full items-center justify-between space-x-2 rtl:space-x-reverse">
<div className="flex">
<TextField
name="Lock User"
placeholder="username"
defaultValue=""
onChange={(event) => setUsername(event.target.value)}
value={username}
/>
<Button
type="submit"
className="ml-2 mt-5"
onClick={() => {
mutation.mutate({ username, lock: true });
utils.viewer.admin.getSMSLockStateTeamsUsers.invalidate();
}}>
Lock User
</Button>
</div>
<div className="flex">
<TextField
name="Lock Team"
placeholder="team slug"
defaultValue=""
onChange={(event) => {
setTeamSlug(event.target.value);
}}
value={teamSlug}
/>
<Button
type="submit"
className="ml-2 mt-5"
onClick={() => {
mutation.mutate({ teamSlug, lock: true });
utils.viewer.admin.getSMSLockStateTeamsUsers.invalidate();
}}>
Lock Team
</Button>
</div>
</div>
<UsersTable setSMSLockState={setSMSLockState} />
</div>
);
}
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -316,7 +316,6 @@ async function handler(req: CustomRequest) {
...{ eventType: { slug: bookingToDelete.eventType.slug } },
},
hideBranding: !!bookingToDelete.eventType.owner?.hideBranding,
eventTypeRequiresConfirmation: bookingToDelete.eventType.requiresConfirmation,
});
}
@@ -288,7 +288,6 @@ export async function handleConfirmation(args: {
calendarEvent: evtOfBooking,
isFirstRecurringEvent: isFirstBooking,
hideBranding: !!updatedBookings[index].eventType?.owner?.hideBranding,
eventTypeRequiresConfirmation: true,
});
}
} catch (error) {
@@ -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 }));
@@ -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 }));
@@ -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}`);
@@ -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}`);
@@ -175,8 +175,7 @@ export const AddActionDialog = (props: IAddActionDialog) => {
label: string;
value: WorkflowActions;
needsTeamsUpgrade: boolean;
needsOrgsUpgrade: boolean;
}) => option.needsTeamsUpgrade || option.needsOrgsUpgrade}
}) => option.needsTeamsUpgrade}
/>
);
}}
@@ -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 (
<div className="border-subtle w-full overflow-hidden rounded-md border p-6 px-3 md:p-6">
<div className="flex items-center ">
@@ -179,15 +172,6 @@ const WorkflowListItem = (props: ItemProps) => {
</div>
</Tooltip>
</div>
{needsRequiresConfirmationWarning ? (
<div className="text-attention -mb-2 mt-3 flex">
<Icon name="info" className="mr-1 mt-0.5 h-4 w-4" />
<p className="text-sm">{t("requires_confirmation_mandatory")}</p>
</div>
) : (
<></>
)}
</div>
);
};
@@ -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 ? (
<div className="text-attention mb-3 mt-2 flex">
<Icon name="info" className="mr-1 mt-0.5 h-4 w-4" />
<p className="text-sm">{t("requires_confirmation_mandatory")}</p>
</div>
) : (
<></>
)}
</div>
{isPhoneNumberNeeded && (
<div className="bg-muted mt-2 rounded-md p-4 pt-0">
@@ -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;
@@ -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,
};
});
}
@@ -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<WorkflowStep> | null;
type PartialWorkflowStep =
| (Partial<WorkflowStep> & { 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: {
@@ -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;
}
}
@@ -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,
});
}
}
@@ -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}`);
}
@@ -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}`);
}
@@ -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" },
],
},
@@ -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" },
],
},
@@ -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,
},
});
}
}
+17 -1
View File
@@ -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) {
+2
View File
@@ -239,6 +239,7 @@ type UserPayload = Prisma.UserGetPayload<{
receiveMonthlyDigestEmail: true;
movedToProfileId: true;
isPlatformManaged: true;
smsLockState: true;
};
}>;
export const buildUser = <T extends Partial<UserPayload>>(
@@ -246,6 +247,7 @@ export const buildUser = <T extends Partial<UserPayload>>(
): UserPayload & { priority: number | null } => {
return {
locked: false,
smsLockState: "UNLOCKED",
name: faker.name.firstName(),
email: faker.internet.email(),
timeZone: faker.address.timeZone(),
@@ -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';
+8
View File
@@ -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
}
@@ -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);
}),
});
@@ -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<TrpcSessionUser>;
};
};
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;
@@ -0,0 +1 @@
export {};
@@ -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<TrpcSessionUser>;
};
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;
@@ -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<typeof ZSetSMSLockState>;
@@ -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;
}
@@ -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 = <
<span className="mr-auto" data-testid={`select-option-${(props as unknown as ExtendedOption).value}`}>
{props.label || <>&nbsp;</>}
</span>
{(props.data as unknown as ExtendedOption).needsTeamsUpgrade ? (
<UpgradeTeamsBadge />
) : (props.data as unknown as ExtendedOption).needsOrgsUpgrade ? (
<UpgradeOrgsBadge />
) : (
<></>
)}
{(props.data as unknown as ExtendedOption).needsTeamsUpgrade ? <UpgradeTeamsBadge /> : <></>}
{props.isSelected && <Icon name="check" className="ml-2 h-4 w-4" />}
</div>
</reactSelectComponents.Option>