fix: add extra security layers for sending messages to attendees (#10636)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Carina Wollendorfer
2023-08-10 11:52:36 -07:00
committed by GitHub
co-authored by CarinaWolli
parent 633d85e81b
commit c7dfa7bc89
33 changed files with 690 additions and 101 deletions
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { kycVerificationRouter } from "@calcom/trpc/server/routers/viewer/kycVerification/_router";
export default createNextApiHandler(kycVerificationRouter);
@@ -0,0 +1,11 @@
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "@components/auth/layouts/AdminLayout";
import KYCVerificationView from "./kycVerificationView";
const KYCVerificationPage = () => <KYCVerificationView />;
KYCVerificationPage.getLayout = getLayout;
KYCVerificationPage.PageWrapper = PageWrapper;
export default KYCVerificationPage;
@@ -0,0 +1,80 @@
import { useForm } from "react-hook-form";
import { trpc } from "@calcom/trpc";
import { Meta, Form, Button, TextField, showToast } from "@calcom/ui";
type FormValues = {
name?: string | null;
};
export default function KYCVerificationView() {
const teamForm = useForm<FormValues>();
const userForm = useForm<FormValues>();
const mutation = trpc.viewer.kycVerification.verify.useMutation({
onSuccess: async (data) => {
showToast(`Successfully verified ${data.isTeam ? "team" : "user"} ${data.name}`, "success");
},
onError: (error) => {
showToast(`Verification failed: ${error.message}`, "error");
},
});
return (
<div>
<Meta
title="KYC Verification"
description="Here you can verify users and teams. This verification is needed for sending sms/whatsapp messages to attendees."
/>
<div>
<div className="mb-2 font-medium">Verify Team</div>
<Form
form={teamForm}
handleSubmit={(values) => {
mutation.mutate({
name: values.name || "",
isTeam: true,
});
}}>
<div className="flex space-x-2">
<TextField
{...teamForm.register("name")}
label=""
type="text"
id="name"
placeholder="team slug"
className="-mt-2 "
required
/>
<Button type="submit">Verify</Button>
</div>
</Form>
</div>
<div>
<div className="mb-2 mt-6 font-medium">Verify User</div>
<Form
form={userForm}
handleSubmit={(values) => {
mutation.mutate({
name: values.name || "",
isTeam: false,
});
}}>
<div className="flex space-x-2">
<TextField
{...userForm.register("name")}
label=""
type="text"
id="name"
placeholder="user name"
className="-mt-2"
required
/>
<Button type="submit">Verify</Button>
</div>
</Form>
</div>
</div>
);
}
@@ -1981,6 +1981,13 @@
"what_is_this_meeting_about": "What is this meeting about?",
"requires_booker_email_verification": "Requires booker email verification",
"description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events",
"requires_confirmation_mandatory": "Text messages can only be sent to attendees when event type requires confirmation.",
"kyc_verification_information": "To ensure security, you have to verify your {{teamOrAccount}} before sending text messages to attendees. Please contact us at <a>{{supportEmail}}</a> and provide the following information:",
"kyc_verification_documents": "<ul><li>Your {{teamOrUser}}</li><li>For businesses: Attach your Business Verification Documentation</li><li>For individuals: Attach a government-issued ID</li></ul>",
"verify_team_or_account": "Verify {{teamOrAccount}}",
"verify_account": "Verify Account",
"kyc_verification": "KYC Verification",
"organizations": "Organizations",
"org_admin_other_teams": "Other teams",
"org_admin_other_teams_description": "Here you can see teams inside your organization but that you are not part of. You can add yourself to them if needed.",
"no_other_teams_found": "No other teams found",
@@ -1989,5 +1996,6 @@
"attendee_last_name_variable": "Attendee last name",
"attendee_first_name_info": "The person booking's first name",
"attendee_last_name_info": "The person booking's last name",
"verify_team_tooltip": "Verify your team to enable sending messages to attendees",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -10,6 +10,7 @@ import { deleteMeeting, updateMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { sendCancelledEmails, sendCancelledSeatEmails } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { isEventTypeOwnerKYCVerified } from "@calcom/features/ee/workflows/lib/isEventTypeOwnerKYCVerified";
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
@@ -65,7 +66,23 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
eventType: {
select: {
slug: true,
owner: true,
owner: {
select: {
id: true,
hideBranding: true,
metadata: true,
teams: {
select: {
accepted: true,
team: {
select: {
metadata: true,
},
},
},
},
},
},
teamId: true,
recurringEvent: true,
title: true,
@@ -275,6 +292,8 @@ async function handler(req: CustomRequest) {
);
await Promise.all(promises);
const isKYCVerified = isEventTypeOwnerKYCVerified(bookingToDelete.eventType);
//Workflows - schedule reminders
if (bookingToDelete.eventType?.workflows) {
await sendCancelledReminders({
@@ -285,6 +304,7 @@ async function handler(req: CustomRequest) {
...{ eventType: { slug: bookingToDelete.eventType.slug } },
},
hideBranding: !!bookingToDelete.eventType.owner?.hideBranding,
isKYCVerified,
});
}
@@ -4,6 +4,7 @@ import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
import type { EventManagerUser } from "@calcom/core/EventManager";
import EventManager from "@calcom/core/EventManager";
import { sendScheduledEmails } from "@calcom/emails";
import { isEventTypeOwnerKYCVerified } from "@calcom/features/ee/workflows/lib/isEventTypeOwnerKYCVerified";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
@@ -85,8 +86,18 @@ export async function handleConfirmation(args: {
eventType: {
bookingFields: Prisma.JsonValue | null;
slug: string;
team: {
metadata: Prisma.JsonValue;
} | null;
owner: {
hideBranding?: boolean | null;
metadata: Prisma.JsonValue;
teams: {
accepted: boolean;
team: {
metadata: Prisma.JsonValue;
};
}[];
} | null;
workflows: (WorkflowsOnEventTypes & {
workflow: Workflow & {
@@ -123,9 +134,25 @@ export async function handleConfirmation(args: {
select: {
slug: true,
bookingFields: true,
team: {
select: {
metadata: true,
},
},
owner: {
select: {
hideBranding: true,
metadata: true,
teams: {
select: {
accepted: true,
team: {
select: {
metadata: true,
},
},
},
},
},
},
workflows: {
@@ -174,9 +201,25 @@ export async function handleConfirmation(args: {
select: {
slug: true,
bookingFields: true,
team: {
select: {
metadata: true,
},
},
owner: {
select: {
hideBranding: true,
metadata: true,
teams: {
select: {
accepted: true,
team: {
select: {
metadata: true,
},
},
},
},
},
},
workflows: {
@@ -206,6 +249,8 @@ export async function handleConfirmation(args: {
updatedBookings.push(updatedBooking);
}
const isKYCVerified = isEventTypeOwnerKYCVerified(updatedBookings[0].eventType);
//Workflows - set reminders for confirmed events
try {
for (let index = 0; index < updatedBookings.length; index++) {
@@ -229,6 +274,8 @@ export async function handleConfirmation(args: {
},
isFirstRecurringEvent: isFirstBooking,
hideBranding: !!updatedBookings[index].eventType?.owner?.hideBranding,
eventTypeRequiresConfirmation: true,
isKYCVerified,
});
}
}
@@ -39,6 +39,7 @@ import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import { isEventTypeOwnerKYCVerified } from "@calcom/features/ee/workflows/lib/isEventTypeOwnerKYCVerified";
import {
cancelWorkflowReminders,
scheduleWorkflowReminders,
@@ -247,6 +248,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
select: {
id: true,
name: true,
metadata: true,
},
},
bookingFields: true,
@@ -277,6 +279,17 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
owner: {
select: {
hideBranding: true,
metadata: true,
teams: {
select: {
accepted: true,
team: {
select: {
metadata: true,
},
},
},
},
},
},
workflows: {
@@ -1119,6 +1132,8 @@ async function handler(
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
const isKYCVerified = isEventTypeOwnerKYCVerified(eventType);
const handleSeats = async () => {
let resultBooking:
| (Partial<Booking> & {
@@ -1679,11 +1694,12 @@ async function handler(
workflows: eventType.workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
requiresConfirmation: evt.requiresConfirmation || false,
isNotConfirmed: evt.requiresConfirmation || false,
isRescheduleEvent: !!rescheduleUid,
isFirstRecurringEvent: true,
emailAttendeeSendToOverride: bookerEmail,
seatReferenceUid: evt.attendeeSeatId,
isKYCVerified,
});
} catch (error) {
log.error("Error while scheduling workflow reminders", error);
@@ -2322,11 +2338,12 @@ async function handler(
...evt,
...{ metadata: metadataFromEvent, eventType: { slug: eventType.slug } },
},
requiresConfirmation: evt.requiresConfirmation || false,
isNotConfirmed: evt.requiresConfirmation || false,
isRescheduleEvent: !!rescheduleUid,
isFirstRecurringEvent: true,
hideBranding: !!eventType.owner?.hideBranding,
seatReferenceUid: evt.attendeeSeatId,
isKYCVerified,
});
} catch (error) {
log.error("Error while scheduling workflow reminders", error);
@@ -134,12 +134,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
);
}
if (
message?.length &&
message?.length > 0 &&
sendTo &&
reminder.workflowStep.action !== WorkflowActions.SMS_ATTENDEE
) {
if (message?.length && message?.length > 0 && sendTo) {
const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate, senderID);
await prisma.workflowReminder.update({
@@ -90,12 +90,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
userName
);
if (
message?.length &&
message?.length > 0 &&
sendTo &&
reminder.workflowStep.action !== WorkflowActions.WHATSAPP_ATTENDEE
) {
if (message?.length && message?.length > 0 && sendTo) {
const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate, "", true);
await prisma.workflowReminder.update({
@@ -173,7 +173,8 @@ export const AddActionDialog = (props: IAddActionDialog) => {
label: string;
value: WorkflowActions;
needsUpgrade: boolean;
}) => option.needsUpgrade}
needsVerification: boolean;
}) => option.needsUpgrade || option.needsVerification}
/>
);
}}
@@ -3,6 +3,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { isTextMessageToAttendeeAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
@@ -10,7 +11,7 @@ import { WorkflowActions } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button, EmptyScreen, showToast, Switch, Tooltip, Alert } from "@calcom/ui";
import { ExternalLink, Zap, Lock } from "@calcom/ui/components/icon";
import { ExternalLink, Zap, Lock, Info } from "@calcom/ui/components/icon";
import LicenseRequired from "../../common/components/LicenseRequired";
import { getActionIcon } from "../lib/getActionIcon";
@@ -22,6 +23,7 @@ type ItemProps = {
eventType: {
id: number;
title: string;
requiresConfirmation: boolean;
};
isChildrenManagedEventType: boolean;
};
@@ -101,71 +103,90 @@ const WorkflowListItem = (props: ItemProps) => {
}
});
const needsRequiresConfirmationWarning =
!eventType.requiresConfirmation &&
workflow.steps.find((step) => {
return isTextMessageToAttendeeAction(step.action);
});
return (
<div className="border-subtle flex w-full items-center overflow-hidden rounded-md border p-6 px-3 md:p-6">
<div className="bg-subtle mr-4 flex h-10 w-10 items-center justify-center rounded-full text-xs font-medium">
{getActionIcon(
workflow.steps,
isActive ? "h-6 w-6 stroke-[1.5px] text-default" : "h-6 w-6 stroke-[1.5px] text-muted"
)}
</div>
<div className=" grow">
<div
className={classNames(
"text-emphasis mb-1 w-full truncate text-base font-medium leading-4 md:max-w-max",
workflow.name && isActive ? "text-emphasis" : "text-subtle"
)}>
{workflow.name
? workflow.name
: "Untitled (" +
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.charAt(0).toUpperCase() +
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) +
")"}
</div>
<div
className={classNames(
" flex w-fit items-center whitespace-nowrap rounded-sm text-sm leading-4",
isActive ? "text-default" : "text-muted"
)}>
<span className="mr-1">{t("to")}:</span>
{Array.from(sendTo).map((sendToPerson, index) => {
return <span key={index}>{`${index ? ", " : ""}${sendToPerson}`}</span>;
})}
</div>
</div>
{!workflow.readOnly && (
<div className="flex-none">
<Link href={`/workflows/${workflow.id}`} passHref={true} target="_blank">
<Button type="button" color="minimal" className="mr-4">
<div className="hidden ltr:mr-2 rtl:ml-2 sm:block">{t("edit")}</div>
<ExternalLink className="text-default -mt-[2px] h-4 w-4 stroke-2" />
</Button>
</Link>
</div>
)}
<Tooltip
content={
t(
workflow.readOnly && props.isChildrenManagedEventType
? "locked_by_admin"
: isActive
? "turn_off"
: "turn_on"
) as string
}>
<div className="flex items-center ltr:mr-2 rtl:ml-2">
{workflow.readOnly && props.isChildrenManagedEventType && (
<Lock className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
<div className="border-subtle w-full overflow-hidden rounded-md border p-6 px-3 md:p-6">
<div className="flex items-center ">
<div className="bg-subtle mr-4 flex h-10 w-10 items-center justify-center rounded-full text-xs font-medium">
{getActionIcon(
workflow.steps,
isActive ? "h-6 w-6 stroke-[1.5px] text-default" : "h-6 w-6 stroke-[1.5px] text-muted"
)}
<Switch
checked={isActive}
disabled={workflow.readOnly}
onCheckedChange={() => {
activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id });
}}
/>
</div>
</Tooltip>
<div className=" grow">
<div
className={classNames(
"text-emphasis mb-1 w-full truncate text-base font-medium leading-4 md:max-w-max",
workflow.name && isActive ? "text-emphasis" : "text-subtle"
)}>
{workflow.name
? workflow.name
: "Untitled (" +
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.charAt(0).toUpperCase() +
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) +
")"}
</div>
<>
<div
className={classNames(
" flex w-fit items-center whitespace-nowrap rounded-sm text-sm leading-4",
isActive ? "text-default" : "text-muted"
)}>
<span className="mr-1">{t("to")}:</span>
{Array.from(sendTo).map((sendToPerson, index) => {
return <span key={index}>{`${index ? ", " : ""}${sendToPerson}`}</span>;
})}
</div>
</>
</div>
{!workflow.readOnly && (
<div className="flex-none">
<Link href={`/workflows/${workflow.id}`} passHref={true} target="_blank">
<Button type="button" color="minimal" className="mr-4">
<div className="hidden ltr:mr-2 rtl:ml-2 sm:block">{t("edit")}</div>
<ExternalLink className="text-default -mt-[2px] h-4 w-4 stroke-2" />
</Button>
</Link>
</div>
)}
<Tooltip
content={
t(
workflow.readOnly && props.isChildrenManagedEventType
? "locked_by_admin"
: isActive
? "turn_off"
: "turn_on"
) as string
}>
<div className="flex items-center ltr:mr-2 rtl:ml-2">
{workflow.readOnly && props.isChildrenManagedEventType && (
<Lock className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
)}
<Switch
checked={isActive}
disabled={workflow.readOnly}
onCheckedChange={() => {
activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id });
}}
/>
</div>
</Tooltip>
</div>
{needsRequiresConfirmationWarning ? (
<div className="text-attention -mb-2 mt-3 flex">
<Info className="mr-1 mt-0.5 h-4 w-4" />
<p className="text-sm">{t("requires_confirmation_mandatory")}</p>
</div>
) : (
<></>
)}
</div>
);
};
@@ -0,0 +1,56 @@
import { Trans } from "next-i18next";
import type { Dispatch, SetStateAction } from "react";
import { SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Dialog, DialogContent, DialogClose, DialogFooter } from "@calcom/ui";
export const KYCVerificationDialog = (props: {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
isPartOfTeam: boolean;
}) => {
const { isOpenDialog, setIsOpenDialog, isPartOfTeam } = props;
const { t } = useLocale();
const isTeamString = isPartOfTeam ? "team" : "";
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent title={t("verify_team_or_account", { teamOrAccount: isTeamString || "account" })}>
<div>
<div className="mb-4">
<Trans
i18nKey="kyc_verification_information"
components={{
a: (
<a
href={`mailto:support@cal.com?subject=${
isPartOfTeam ? "Team%20Verification" : "Account%20Verification"
}`}
style={{ color: "#3E3E3E" }}
target="_blank"
rel="noreferrer"
/>
),
}}
values={{
supportEmail:
SUPPORT_MAIL_ADDRESS === "help@cal.com" ? "support@cal.com" : SUPPORT_MAIL_ADDRESS,
teamOrAccount: isTeamString || "account",
}}
/>
</div>
<Trans
i18nKey="kyc_verification_documents"
components={{ li: <li />, ul: <ul className="ml-8 list-disc" /> }}
values={{ teamOrUser: isPartOfTeam ? "team URL" : "user name" }}
/>
</div>
<DialogFooter>
<DialogClose />
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -5,6 +5,7 @@ import type { UseFormReturn } from "react-hook-form";
import { Controller } from "react-hook-form";
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WorkflowTemplates } from "@calcom/prisma/enums";
import type { WorkflowActions } from "@calcom/prisma/enums";
@@ -18,6 +19,7 @@ import { isSMSAction, isWhatsappAction } from "../lib/actionHelperFunctions";
import type { FormValues } from "../pages/workflow";
import { AddActionDialog } from "./AddActionDialog";
import { DeleteDialog } from "./DeleteDialog";
import { KYCVerificationDialog } from "./KYCVerificationDialog";
import WorkflowStepContainer from "./WorkflowStepContainer";
type User = RouterOutputs["viewer"]["me"];
@@ -39,11 +41,15 @@ export default function WorkflowDetailsPage(props: Props) {
const router = useRouter();
const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false);
const [isKYCVerificationDialogOpen, setKYCVerificationDialogOpen] = useState(false);
const [reload, setReload] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { data, isLoading } = trpc.viewer.eventTypes.getByViewer.useQuery();
const isPartOfTeam = useHasTeamPlan();
const eventTypeOptions = useMemo(
() =>
data?.eventTypeGroups.reduce((options, group) => {
@@ -114,7 +120,7 @@ export default function WorkflowDetailsPage(props: Props) {
return (
<>
<div className="my-8 sm:my-0 md:flex">
<div className="z-1 my-8 sm:my-0 md:flex">
<div className="pl-2 pr-3 md:sticky md:top-6 md:h-0 md:pl-0">
<div className="mb-5">
<TextField
@@ -168,6 +174,7 @@ export default function WorkflowDetailsPage(props: Props) {
user={props.user}
teamId={teamId}
readOnly={props.readOnly}
setKYCVerificationDialogOpen={setKYCVerificationDialogOpen}
/>
</div>
)}
@@ -184,6 +191,7 @@ export default function WorkflowDetailsPage(props: Props) {
setReload={setReload}
teamId={teamId}
readOnly={props.readOnly}
setKYCVerificationDialogOpen={setKYCVerificationDialogOpen}
/>
);
})}
@@ -212,6 +220,11 @@ export default function WorkflowDetailsPage(props: Props) {
setIsOpenDialog={setIsAddActionDialogOpen}
addAction={addAction}
/>
<KYCVerificationDialog
isOpenDialog={isKYCVerificationDialogOpen}
setIsOpenDialog={setKYCVerificationDialogOpen}
isPartOfTeam={!!isPartOfTeam.hasTeamPlan}
/>
<DeleteDialog
isOpenDialog={deleteDialogOpen}
setIsOpenDialog={setDeleteDialogOpen}
@@ -47,6 +47,7 @@ import {
isSMSOrWhatsappAction,
isWhatsappAction,
getWhatsappTemplateForAction,
isTextMessageToAttendeeAction,
} from "../lib/actionHelperFunctions";
import { DYNAMIC_TEXT_VARIABLES } from "../lib/constants";
import { getWorkflowTemplateOptions, getWorkflowTriggerOptions } from "../lib/getOptions";
@@ -66,6 +67,7 @@ type WorkflowStepProps = {
setReload?: Dispatch<SetStateAction<boolean>>;
teamId?: number;
readOnly: boolean;
setKYCVerificationDialogOpen: Dispatch<SetStateAction<boolean>>;
};
export default function WorkflowStepContainer(props: WorkflowStepProps) {
@@ -118,6 +120,11 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
const [showTimeSectionAfter, setShowTimeSectionAfter] = useState(
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);
@@ -331,6 +338,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
label: actionString.charAt(0).toUpperCase() + actionString.slice(1),
value: step.action,
needsUpgrade: false,
needsVerification: false,
verificationAction: () => props.setKYCVerificationDialogOpen(true),
};
const selectedTemplate = { label: t(`${step.template.toLowerCase()}`), value: step.template };
@@ -448,6 +457,12 @@ 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
@@ -513,16 +528,28 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
}
}}
defaultValue={selectedAction}
options={actionOptions}
options={actionOptions?.map((option) => ({
...option,
verificationAction: () => props.setKYCVerificationDialogOpen(true),
}))}
isOptionDisabled={(option: {
label: string;
value: WorkflowActions;
needsUpgrade: boolean;
}) => option.needsUpgrade}
needsVerification: boolean;
}) => option.needsUpgrade || option.needsVerification}
/>
);
}}
/>
{isRequiresConfirmationNeeded ? (
<div className="text-attention mb-3 mt-2 flex">
<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">
@@ -38,6 +38,10 @@ export function isAttendeeAction(action: WorkflowActions) {
);
}
export function isTextMessageToAttendeeAction(action?: WorkflowActions) {
return action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.WHATSAPP_ATTENDEE;
}
export function getWhatsappTemplateForTrigger(trigger: WorkflowTriggerEvents): WorkflowTemplates {
switch (trigger) {
case "NEW_EVENT":
@@ -2,7 +2,11 @@ import type { TFunction } from "next-i18next";
import { WorkflowActions } from "@calcom/prisma/enums";
import { isSMSOrWhatsappAction, isWhatsappAction } from "./actionHelperFunctions";
import {
isTextMessageToAttendeeAction,
isSMSOrWhatsappAction,
isWhatsappAction,
} from "./actionHelperFunctions";
import {
TIME_UNIT,
WHATSAPP_WORKFLOW_TEMPLATES,
@@ -11,7 +15,7 @@ import {
WORKFLOW_TRIGGER_EVENTS,
} from "./constants";
export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean) {
export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean, isKYCVerified?: boolean) {
return WORKFLOW_ACTIONS.filter((action) => action !== WorkflowActions.EMAIL_ADDRESS) //removing EMAIL_ADDRESS for now due to abuse episode
.map((action) => {
const actionString = t(`${action.toLowerCase()}_action`);
@@ -20,6 +24,7 @@ export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean) {
label: actionString.charAt(0).toUpperCase() + actionString.slice(1),
value: action,
needsUpgrade: isSMSOrWhatsappAction(action) && !isTeamsPlan,
needsVerification: isTextMessageToAttendeeAction(action) && !isKYCVerified,
};
});
}
@@ -0,0 +1,45 @@
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import type { Prisma } from "@calcom/prisma/client";
type EventTypeOwnerType = {
team?: {
metadata: Prisma.JsonValue;
} | null;
owner?: {
metadata: Prisma.JsonValue;
teams: {
accepted: boolean;
team: {
metadata: Prisma.JsonValue;
};
}[];
} | null;
};
export function isEventTypeOwnerKYCVerified(eventType?: EventTypeOwnerType | null) {
if (!eventType) return false;
if (eventType.team) {
const isKYCVerified =
eventType.team &&
hasKeyInMetadata(eventType.team, "kycVerified") &&
!!eventType.team.metadata.kycVerified;
return isKYCVerified;
}
if (eventType.owner) {
const isKYCVerified =
eventType.owner &&
hasKeyInMetadata(eventType.owner, "kycVerified") &&
!!eventType.owner.metadata.kycVerified;
if (isKYCVerified) return isKYCVerified;
const isPartOfVerifiedTeam = eventType.owner.teams.find(
(team) =>
team.accepted && hasKeyInMetadata(team.team, "kycVerified") && !!team.team.metadata.kycVerified
);
return !!isPartOfVerifiedTeam;
}
return false;
}
@@ -1,6 +1,9 @@
import type { Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client";
import { isWhatsappAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import {
isTextMessageToAttendeeAction,
isWhatsappAction,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -20,6 +23,8 @@ type ProcessWorkflowStepParams = {
emailAttendeeSendToOverride?: string;
hideBranding?: boolean;
seatReferenceUid?: string;
isKYCVerified: boolean;
eventTypeRequiresConfirmation?: boolean;
};
export interface ScheduleWorkflowRemindersArgs extends ProcessWorkflowStepParams {
@@ -28,7 +33,7 @@ export interface ScheduleWorkflowRemindersArgs extends ProcessWorkflowStepParams
steps: WorkflowStep[];
};
})[];
requiresConfirmation?: boolean;
isNotConfirmed?: boolean;
isRescheduleEvent?: boolean;
isFirstRecurringEvent?: boolean;
}
@@ -42,8 +47,13 @@ const processWorkflowStep = async (
emailAttendeeSendToOverride,
hideBranding,
seatReferenceUid,
eventTypeRequiresConfirmation,
isKYCVerified,
}: ProcessWorkflowStepParams
) => {
if (isTextMessageToAttendeeAction(step.action) && (!isKYCVerified || !eventTypeRequiresConfirmation))
return;
if (step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.SMS_NUMBER) {
const sendTo = step.action === WorkflowActions.SMS_ATTENDEE ? smsReminderNumber : step.sendTo;
await scheduleSMSReminder(
@@ -125,15 +135,16 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA
workflows,
smsReminderNumber,
calendarEvent: evt,
requiresConfirmation = false,
isNotConfirmed = false,
isRescheduleEvent = false,
isFirstRecurringEvent = false,
emailAttendeeSendToOverride = "",
hideBranding,
seatReferenceUid,
eventTypeRequiresConfirmation = false,
isKYCVerified,
} = args;
if (requiresConfirmation || !workflows.length) return;
if (isNotConfirmed || !workflows.length) return;
for (const workflowReference of workflows) {
if (workflowReference.workflow.steps.length === 0) continue;
@@ -164,6 +175,8 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA
smsReminderNumber,
hideBranding,
seatReferenceUid,
eventTypeRequiresConfirmation,
isKYCVerified,
});
}
}
@@ -194,10 +207,13 @@ export interface SendCancelledRemindersArgs {
smsReminderNumber: string | null;
evt: ExtendedCalendarEvent;
hideBranding?: boolean;
isKYCVerified: boolean;
eventTypeRequiresConfirmation?: boolean;
}
export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) => {
const { workflows, smsReminderNumber, evt, hideBranding } = args;
const { workflows, smsReminderNumber, evt, hideBranding, isKYCVerified, eventTypeRequiresConfirmation } =
args;
if (!workflows.length) return;
for (const workflowRef of workflows) {
@@ -210,6 +226,8 @@ export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) =
smsReminderNumber,
hideBranding,
calendarEvent: evt,
eventTypeRequiresConfirmation,
isKYCVerified,
});
}
}
@@ -162,7 +162,7 @@ export const scheduleSMSReminder = async (
// Allows debugging generated email content without waiting for sendgrid to send emails
log.debug(`Sending sms for trigger ${triggerEvent}`, message);
if (message.length > 0 && reminderPhone && isNumberVerified && action !== WorkflowActions.SMS_ATTENDEE) {
if (message.length > 0 && reminderPhone && isNumberVerified) {
//send SMS when event is booked/cancelled/rescheduled
if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
@@ -141,12 +141,7 @@ export const scheduleWhatsappReminder = async (
// Allows debugging generated whatsapp content without waiting for twilio to send whatsapp messages
log.debug(`Sending Whatsapp for trigger ${triggerEvent}`, message);
if (
message.length > 0 &&
reminderPhone &&
isNumberVerified &&
action !== WorkflowActions.WHATSAPP_ATTENDEE
) {
if (message.length > 0 && reminderPhone && isNumberVerified) {
//send WHATSAPP when event is booked/cancelled/rescheduled
if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
@@ -118,6 +118,7 @@ const tabs: VerticalTabItemProps[] = [
{ name: "apps", href: "/settings/admin/apps/calendar" },
{ name: "users", href: "/settings/admin/users" },
{ name: "organizations", href: "/settings/admin/organizations" },
{ name: "kyc_verification", href: "/settings/admin/kycVerification" },
],
},
];
+1
View File
@@ -40,6 +40,7 @@ const ENDPOINTS = [
"workflows",
"appsRouter",
"googleWorkspace",
"kycVerification",
] as const;
export type Endpoint = (typeof ENDPOINTS)[number];
@@ -15,6 +15,7 @@ import { bookingsRouter } from "./bookings/_router";
import { deploymentSetupRouter } from "./deploymentSetup/_router";
import { eventTypesRouter } from "./eventTypes/_router";
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
import { kycVerificationRouter } from "./kycVerification/_router";
import { viewerOrganizationsRouter } from "./organizations/_router";
import { paymentsRouter } from "./payments/_router";
import { slotsRouter } from "./slots/_router";
@@ -50,5 +51,6 @@ export const viewerRouter = mergeRouters(
users: userAdminRouter,
googleWorkspace: googleWorkspaceRouter,
admin: adminRouter,
kycVerification: kycVerificationRouter,
})
);
@@ -0,0 +1,42 @@
import { authedAdminProcedure } from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZVerifyInputSchema } from "./verify.schema";
type KYCVerificationRouterHandlerCache = {
isVerified?: typeof import("./isVerified.handler").isVerifiedHandler;
verify?: typeof import("./verify.handler").verifyHandler;
};
const UNSTABLE_HANDLER_CACHE: KYCVerificationRouterHandlerCache = {};
export const kycVerificationRouter = router({
isVerified: authedAdminProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.isVerified) {
UNSTABLE_HANDLER_CACHE.isVerified = await import("./isVerified.handler").then(
(mod) => mod.isVerifiedHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.isVerified) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.isVerified({
ctx,
});
}),
verify: authedAdminProcedure.input(ZVerifyInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.verify) {
UNSTABLE_HANDLER_CACHE.verify = await import("./verify.handler").then((mod) => mod.verifyHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.verify) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.verify({
ctx,
input,
});
}),
});
@@ -0,0 +1,44 @@
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
type IsKYCVerifiedOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export const isVerifiedHandler = async ({ ctx }: IsKYCVerifiedOptions) => {
const user = ctx.user;
const memberships = await prisma.membership.findMany({
where: {
accepted: true,
userId: user.id,
team: {
slug: {
not: null,
},
},
},
select: {
team: {
select: {
metadata: true,
},
},
},
});
let isKYCVerified = user && hasKeyInMetadata(user, "kycVerified") ? !!user.metadata.kycVerified : false;
if (!isKYCVerified) {
//check if user is part of a team that is KYC verified
isKYCVerified = !!memberships.find(
(membership) =>
hasKeyInMetadata(membership.team, "kycVerified") && !!membership.team.metadata.kycVerified
);
}
return { isKYCVerified };
};
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,76 @@
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TVerifyInputSchema } from "./verify.schema";
type VerifyOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TVerifyInputSchema;
};
export const verifyHandler = async ({ ctx, input }: VerifyOptions) => {
const { name, isTeam } = input;
if (isTeam) {
const team = await prisma.team.findFirst({
where: {
slug: name,
},
});
if (!team) {
throw new Error("Team not found");
}
if (hasKeyInMetadata(team, "kycVerified") && !!team.metadata.kycVerified) {
throw new Error("Team already verified");
}
const metadata = typeof team.metadata === "object" ? team.metadata : {};
const updatedMetadata = { ...metadata, kycVerified: true };
const updatedTeam = await prisma.team.update({
where: {
id: team.id,
},
data: {
metadata: updatedMetadata,
},
});
return {
name: updatedTeam.slug,
isTeam: true,
};
}
const user = await prisma.user.findFirst({
where: {
username: name,
},
});
if (!user) {
throw new Error("User not found");
}
if (hasKeyInMetadata(user, "kycVerified") && !!user.metadata.kycVerified) {
throw new Error("User already verified");
}
const metadata = typeof user.metadata === "object" ? user.metadata : {};
const updatedMetadata = { ...metadata, kycVerified: true };
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
metadata: updatedMetadata,
},
});
return {
name: updatedUser.username,
isTeam: false,
};
};
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZVerifyInputSchema = z.object({
name: z.string(),
isTeam: z.boolean(),
});
export type TVerifyInputSchema = z.infer<typeof ZVerifyInputSchema>;
@@ -4,6 +4,7 @@ import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { getTranslation } from "@calcom/lib/server/i18n";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { isVerifiedHandler } from "../kycVerification/isVerified.handler";
import { hasTeamPlanHandler } from "../teams/hasTeamPlan.handler";
type GetWorkflowActionOptionsOptions = {
@@ -25,6 +26,13 @@ export const getWorkflowActionOptionsHandler = async ({ ctx }: GetWorkflowAction
const { hasTeamPlan } = await hasTeamPlanHandler({ ctx });
isTeamsPlan = !!hasTeamPlan;
}
const { isKYCVerified } = await isVerifiedHandler({ ctx });
const t = await getTranslation(ctx.user.locale, "common");
return getWorkflowActionOptions(t, IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan);
return getWorkflowActionOptions(
t,
IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan,
isKYCVerified
);
};
@@ -1,6 +1,9 @@
import type { Prisma } from "@prisma/client";
import { isSMSOrWhatsappAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import {
isSMSOrWhatsappAction,
isTextMessageToAttendeeAction,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import {
deleteScheduledEmailReminder,
scheduleEmailReminder,
@@ -22,6 +25,7 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import { isVerifiedHandler } from "../kycVerification/isVerified.handler";
import { hasTeamPlanHandler } from "../teams/hasTeamPlan.handler";
import type { TUpdateInputSchema } from "./update.schema";
import {
@@ -80,6 +84,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
const hasPaidPlan = IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan;
const kycVerified = await isVerifiedHandler({ ctx });
const isKYCVerified = kycVerified.isKYCVerified;
const activeOnEventTypes = await ctx.prisma.eventType.findMany({
where: {
id: {
@@ -421,6 +428,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
if (!hasPaidPlan && !isSMSOrWhatsappAction(oldStep.action) && isSMSOrWhatsappAction(newStep.action)) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (!isKYCVerified && isTextMessageToAttendeeAction(newStep.action)) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Account needs to be verified" });
}
const requiresSender =
newStep.action === WorkflowActions.SMS_NUMBER || newStep.action === WorkflowActions.WHATSAPP_NUMBER;
await ctx.prisma.workflowStep.update({
@@ -593,6 +603,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
if (isSMSOrWhatsappAction(s.action) && !hasPaidPlan) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (isTextMessageToAttendeeAction(s.action)) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Account needs to be verified" });
}
const { id: _stepId, ...stepToAdd } = s;
return stepToAdd;
}
@@ -0,0 +1,20 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Tooltip } from "../tooltip";
import { Badge } from "./Badge";
export const KYCVerificationBadge = function KYCVerificationBadge(props: { verifyTeamAction?: () => void }) {
const { verifyTeamAction } = props;
const { t } = useLocale();
if (!verifyTeamAction) return <></>;
return (
<>
<Tooltip content={t("verify_team_tooltip")}>
<Badge variant="gray" onClick={() => verifyTeamAction()}>
{t("verify_account")}
</Badge>
</Tooltip>
</>
);
};
+1
View File
@@ -1,4 +1,5 @@
export { Badge } from "./Badge";
export { UpgradeTeamsBadge } from "./UpgradeTeamsBadge";
export { KYCVerificationBadge } from "./KYCVerificationBadge";
export type { BadgeProps } from "./Badge";
@@ -3,7 +3,7 @@ import { components as reactSelectComponents } from "react-select";
import { classNames } from "@calcom/lib";
import { UpgradeTeamsBadge } from "../../badge";
import { UpgradeTeamsBadge, KYCVerificationBadge } from "../../badge";
import { Check } from "../../icon";
export const InputComponent = <
@@ -30,6 +30,8 @@ type ExtendedOption = {
value: string | number;
label: string;
needsUpgrade?: boolean;
needsVerification?: boolean;
verificationAction?: () => void;
};
export const OptionComponent = <
@@ -46,7 +48,15 @@ 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).needsUpgrade && <UpgradeTeamsBadge />}
{(props.data as unknown as ExtendedOption).needsUpgrade ? (
<UpgradeTeamsBadge />
) : (props.data as unknown as ExtendedOption).needsVerification ? (
<KYCVerificationBadge
verifyTeamAction={(props.data as unknown as ExtendedOption).verificationAction}
/>
) : (
<></>
)}
{props.isSelected && <Check className="ml-2 h-4 w-4" />}
</div>
</reactSelectComponents.Option>