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:
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
@@ -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 || <> </>}
|
||||
</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>
|
||||
|
||||
Reference in new issue
Block a user