fix: remove kyc and make text messages to attendee org only (#11389)

* add orgs upgrade badge to select

* don't allow updating to if no teams or orgs plan

* make sure only paying users can request verification codes

* add new action helper function

* remove kyc code

* code clean up

* fix verify button UI

* fix type errors

* add eventTypeRequiresConfirmation everywhere

* address feedback

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
pull/11473/head
Carina Wollendorfer 2023-09-21 08:22:05 +02:00 committed by GitHub
parent 96263b0cf7
commit 3b50fe075d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 89 additions and 542 deletions

View File

@ -1,4 +0,0 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { kycVerificationRouter } from "@calcom/trpc/server/routers/viewer/kycVerification/_router";
export default createNextApiHandler(kycVerificationRouter);

View File

@ -1,11 +0,0 @@
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;

View File

@ -1,80 +0,0 @@
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>
);
}

View File

@ -1639,6 +1639,7 @@
"minimum_round_robin_hosts_count": "Number of hosts required to attend",
"hosts": "Hosts",
"upgrade_to_enable_feature": "You need to create a team to enable this feature. Click to create a team.",
"orgs_upgrade_to_enable_feature" : "You need to upgrade to our enterprise plan to enable this feature.",
"new_attendee": "New Attendee",
"awaiting_approval": "Awaiting Approval",
"requires_google_calendar": "This app requires a Google Calendar connection",
@ -2003,11 +2004,6 @@
"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 that you are not part of. You can add yourself to them if needed.",

View File

@ -9,7 +9,6 @@ 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";
@ -71,17 +70,6 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
select: {
id: true,
hideBranding: true,
metadata: true,
teams: {
select: {
accepted: true,
team: {
select: {
metadata: true,
},
},
},
},
},
},
teamId: true,
@ -299,8 +287,6 @@ async function handler(req: CustomRequest) {
);
await Promise.all(promises);
const isKYCVerified = isEventTypeOwnerKYCVerified(bookingToDelete.eventType);
//Workflows - schedule reminders
if (bookingToDelete.eventType?.workflows) {
await sendCancelledReminders({
@ -311,7 +297,7 @@ async function handler(req: CustomRequest) {
...{ eventType: { slug: bookingToDelete.eventType.slug } },
},
hideBranding: !!bookingToDelete.eventType.owner?.hideBranding,
isKYCVerified,
eventTypeRequiresConfirmation: bookingToDelete.eventType.requiresConfirmation,
});
}

View File

@ -3,7 +3,6 @@ import type { Prisma, Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@pri
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 { scheduleTrigger } from "@calcom/features/webhooks/lib/scheduleTrigger";
@ -87,18 +86,8 @@ 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 & {
@ -135,25 +124,9 @@ 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: {
@ -202,25 +175,9 @@ 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: {
@ -250,8 +207,6 @@ 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++) {
@ -276,7 +231,6 @@ export async function handleConfirmation(args: {
isFirstRecurringEvent: isFirstBooking,
hideBranding: !!updatedBookings[index].eventType?.owner?.hideBranding,
eventTypeRequiresConfirmation: true,
isKYCVerified,
});
}
}

View File

@ -39,7 +39,6 @@ import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import { isEventTypeOwnerKYCVerified } from "@calcom/features/ee/workflows/lib/isEventTypeOwnerKYCVerified";
import {
cancelWorkflowReminders,
scheduleWorkflowReminders,
@ -260,7 +259,6 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
select: {
id: true,
name: true,
metadata: true,
},
},
bookingFields: true,
@ -292,17 +290,6 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
owner: {
select: {
hideBranding: true,
metadata: true,
teams: {
select: {
accepted: true,
team: {
select: {
metadata: true,
},
},
},
},
},
},
workflows: {
@ -379,7 +366,7 @@ async function ensureAvailableUsers(
}
) {
const availableUsers: IsFixedAwareUser[] = [];
const duration = dayjs(input.dateTo).diff(input.dateFrom, 'minute');
const duration = dayjs(input.dateTo).diff(input.dateFrom, "minute");
const originalBookingDuration = input.originalRescheduledBooking
? dayjs(input.originalRescheduledBooking.endTime).diff(
@ -1199,8 +1186,6 @@ async function handler(
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
const isKYCVerified = isEventTypeOwnerKYCVerified(eventType);
const handleSeats = async () => {
let resultBooking:
| (Partial<Booking> & {
@ -1769,7 +1754,7 @@ async function handler(
isFirstRecurringEvent: true,
emailAttendeeSendToOverride: bookerEmail,
seatReferenceUid: evt.attendeeSeatId,
isKYCVerified,
eventTypeRequiresConfirmation: eventType.requiresConfirmation,
});
} catch (error) {
log.error("Error while scheduling workflow reminders", error);
@ -2416,7 +2401,7 @@ async function handler(
isFirstRecurringEvent: true,
hideBranding: !!eventType.owner?.hideBranding,
seatReferenceUid: evt.attendeeSeatId,
isKYCVerified,
eventTypeRequiresConfirmation: eventType.requiresConfirmation,
});
} catch (error) {
log.error("Error while scheduling workflow reminders", error);

View File

@ -39,7 +39,6 @@ interface IAddActionDialog {
senderId?: string,
senderName?: string
) => void;
setKYCVerificationDialogOpen: () => void;
}
interface ISelectActionOption {
@ -57,7 +56,7 @@ type AddActionFormValues = {
export const AddActionDialog = (props: IAddActionDialog) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, addAction, setKYCVerificationDialogOpen } = props;
const { isOpenDialog, setIsOpenDialog, addAction } = props;
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(false);
const [isSenderIdNeeded, setIsSenderIdNeeded] = useState(false);
const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState(false);
@ -171,14 +170,13 @@ export const AddActionDialog = (props: IAddActionDialog) => {
onChange={handleSelectAction}
options={actionOptions.map((option) => ({
...option,
verificationAction: () => setKYCVerificationDialogOpen(),
}))}
isOptionDisabled={(option: {
label: string;
value: WorkflowActions;
needsUpgrade: boolean;
needsVerification: boolean;
}) => option.needsUpgrade || option.needsVerification}
needsTeamsUpgrade: boolean;
needsOrgsUpgrade: boolean;
}) => option.needsTeamsUpgrade || option.needsOrgsUpgrade}
/>
);
}}

View File

@ -1,56 +0,0 @@
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>
);
};

View File

@ -5,7 +5,6 @@ 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";
@ -19,7 +18,6 @@ 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"];
@ -41,15 +39,12 @@ 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) => {
@ -177,7 +172,6 @@ export default function WorkflowDetailsPage(props: Props) {
user={props.user}
teamId={teamId}
readOnly={props.readOnly}
setKYCVerificationDialogOpen={setKYCVerificationDialogOpen}
/>
</div>
)}
@ -194,7 +188,6 @@ export default function WorkflowDetailsPage(props: Props) {
setReload={setReload}
teamId={teamId}
readOnly={props.readOnly}
setKYCVerificationDialogOpen={setKYCVerificationDialogOpen}
/>
);
})}
@ -222,12 +215,6 @@ export default function WorkflowDetailsPage(props: Props) {
isOpenDialog={isAddActionDialogOpen}
setIsOpenDialog={setIsAddActionDialogOpen}
addAction={addAction}
setKYCVerificationDialogOpen={() => setKYCVerificationDialogOpen(true)}
/>
<KYCVerificationDialog
isOpenDialog={isKYCVerificationDialogOpen}
setIsOpenDialog={setKYCVerificationDialogOpen}
isPartOfTeam={!!isPartOfTeam.hasTeamPlan}
/>
<DeleteDialog
isOpenDialog={deleteDialogOpen}

View File

@ -67,7 +67,6 @@ type WorkflowStepProps = {
setReload?: Dispatch<SetStateAction<boolean>>;
teamId?: number;
readOnly: boolean;
setKYCVerificationDialogOpen: Dispatch<SetStateAction<boolean>>;
};
export default function WorkflowStepContainer(props: WorkflowStepProps) {
@ -331,15 +330,13 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
}
if (step && step.action) {
const templateValue = form.watch(`steps.${step.stepNumber - 1}.template`);
const actionString = t(`${step.action.toLowerCase()}_action`);
const selectedAction = {
label: actionString.charAt(0).toUpperCase() + actionString.slice(1),
value: step.action,
needsUpgrade: false,
needsVerification: false,
verificationAction: () => props.setKYCVerificationDialogOpen(true),
needsTeamsUpgrade: false,
needsOrgsUpgrade: false,
};
const selectedTemplate = { label: t(`${step.template.toLowerCase()}`), value: step.template };
@ -530,14 +527,13 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
defaultValue={selectedAction}
options={actionOptions?.map((option) => ({
...option,
verificationAction: () => props.setKYCVerificationDialogOpen(true),
}))}
isOptionDisabled={(option: {
label: string;
value: WorkflowActions;
needsUpgrade: boolean;
needsVerification: boolean;
}) => option.needsUpgrade || option.needsVerification}
needsTeamsUpgrade: boolean;
needsOrgsUpgrade: boolean;
}) => option.needsTeamsUpgrade || option.needsOrgsUpgrade}
/>
);
}}
@ -617,7 +613,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
/>
<Button
color="secondary"
className="-ml-[3px] h-[38px] min-w-fit sm:block sm:rounded-bl-none sm:rounded-tl-none "
className="-ml-[3px] h-[36px] min-w-fit py-0 sm:block sm:rounded-bl-none sm:rounded-tl-none "
disabled={verifyPhoneNumberMutation.isLoading || props.readOnly}
onClick={() => {
verifyPhoneNumberMutation.mutate({

View File

@ -41,6 +41,9 @@ export function isAttendeeAction(action: WorkflowActions) {
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;
}
export function getWhatsappTemplateForTrigger(trigger: WorkflowTriggerEvents): WorkflowTemplates {
switch (trigger) {

View File

@ -15,7 +15,7 @@ import {
WORKFLOW_TRIGGER_EVENTS,
} from "./constants";
export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean, isKYCVerified?: boolean) {
export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean, isOrgsPlan?: 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`);
@ -23,8 +23,9 @@ export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean, is
return {
label: actionString.charAt(0).toUpperCase() + actionString.slice(1),
value: action,
needsUpgrade: isSMSOrWhatsappAction(action) && !isTeamsPlan,
needsVerification: isTextMessageToAttendeeAction(action) && !isKYCVerified,
needsTeamsUpgrade:
isSMSOrWhatsappAction(action) && !isTextMessageToAttendeeAction(action) && !isTeamsPlan,
needsOrgsUpgrade: isTextMessageToAttendeeAction(action) && !isOrgsPlan,
};
});
}

View File

@ -1,45 +0,0 @@
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;
}

View File

@ -23,7 +23,6 @@ type ProcessWorkflowStepParams = {
emailAttendeeSendToOverride?: string;
hideBranding?: boolean;
seatReferenceUid?: string;
isKYCVerified: boolean;
eventTypeRequiresConfirmation?: boolean;
};
@ -48,11 +47,9 @@ const processWorkflowStep = async (
hideBranding,
seatReferenceUid,
eventTypeRequiresConfirmation,
isKYCVerified,
}: ProcessWorkflowStepParams
) => {
if (isTextMessageToAttendeeAction(step.action) && (!isKYCVerified || !eventTypeRequiresConfirmation))
return;
if (isTextMessageToAttendeeAction(step.action) && !eventTypeRequiresConfirmation) return;
if (step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.SMS_NUMBER) {
const sendTo = step.action === WorkflowActions.SMS_ATTENDEE ? smsReminderNumber : step.sendTo;
@ -143,7 +140,6 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA
hideBranding,
seatReferenceUid,
eventTypeRequiresConfirmation = false,
isKYCVerified,
} = args;
if (isNotConfirmed || !workflows.length) return;
@ -177,7 +173,6 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA
hideBranding,
seatReferenceUid,
eventTypeRequiresConfirmation,
isKYCVerified,
});
}
}
@ -208,13 +203,11 @@ 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, isKYCVerified, eventTypeRequiresConfirmation } =
args;
const { workflows, smsReminderNumber, evt, hideBranding, eventTypeRequiresConfirmation } = args;
if (!workflows.length) return;
for (const workflowRef of workflows) {
@ -228,7 +221,6 @@ export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) =
hideBranding,
calendarEvent: evt,
eventTypeRequiresConfirmation,
isKYCVerified,
});
}
}

View File

@ -117,7 +117,6 @@ 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" },
],
},
];

View File

@ -41,7 +41,6 @@ const ENDPOINTS = [
"workflows",
"appsRouter",
"googleWorkspace",
"kycVerification",
] as const;
export type Endpoint = (typeof ENDPOINTS)[number];

View File

@ -16,7 +16,6 @@ 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";
@ -53,6 +52,5 @@ export const viewerRouter = mergeRouters(
users: userAdminRouter,
googleWorkspace: googleWorkspaceRouter,
admin: adminRouter,
kycVerification: kycVerificationRouter,
})
);

View File

@ -1,42 +0,0 @@
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,
});
}),
});

View File

@ -1,44 +0,0 @@
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 };
};

View File

@ -1,76 +0,0 @@
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,
};
};

View File

@ -1,8 +0,0 @@
import { z } from "zod";
export const ZVerifyInputSchema = z.object({
name: z.string(),
isTeam: z.boolean(),
});
export type TVerifyInputSchema = z.infer<typeof ZVerifyInputSchema>;

View File

@ -4,7 +4,6 @@ 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 = {
@ -27,12 +26,12 @@ export const getWorkflowActionOptionsHandler = async ({ ctx }: GetWorkflowAction
isTeamsPlan = !!hasTeamPlan;
}
const { isKYCVerified } = await isVerifiedHandler({ ctx });
const hasOrgsPlan = !!user.organizationId;
const t = await getTranslation(ctx.user.locale, "common");
return getWorkflowActionOptions(
t,
IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan,
isKYCVerified
IS_SELF_HOSTED || hasOrgsPlan
);
};

View File

@ -1,6 +1,10 @@
import { sendVerificationCode } from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import { hasTeamPlanHandler } from "../teams/hasTeamPlan.handler";
import type { TSendVerificationCodeInputSchema } from "./sendVerificationCode.schema";
type SendVerificationCodeOptions = {
@ -10,7 +14,22 @@ type SendVerificationCodeOptions = {
input: TSendVerificationCodeInputSchema;
};
export const sendVerificationCodeHandler = async ({ ctx: _ctx, input }: SendVerificationCodeOptions) => {
export const sendVerificationCodeHandler = async ({ ctx, input }: SendVerificationCodeOptions) => {
const { user } = ctx;
const isCurrentUsernamePremium =
user && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
let isTeamsPlan = false;
if (!isCurrentUsernamePremium) {
const { hasTeamPlan } = await hasTeamPlanHandler({ ctx });
isTeamsPlan = !!hasTeamPlan;
}
if (!isCurrentUsernamePremium && !isTeamsPlan) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const { phoneNumber } = input;
return sendVerificationCode(phoneNumber);
};

View File

@ -3,6 +3,7 @@ import type { Prisma } from "@prisma/client";
import {
isSMSOrWhatsappAction,
isTextMessageToAttendeeAction,
isTextMessageToSpecificNumber,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import {
deleteScheduledEmailReminder,
@ -25,7 +26,6 @@ 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 {
@ -84,8 +84,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
const hasPaidPlan = IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan;
const kycVerified = await isVerifiedHandler({ ctx });
const isKYCVerified = kycVerified.isKYCVerified;
const hasOrgsPlan = IS_SELF_HOSTED || ctx.user.organizationId;
const activeOnEventTypes = await ctx.prisma.eventType.findMany({
where: {
@ -425,11 +424,21 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
//step was edited
} else if (JSON.stringify(oldStep) !== JSON.stringify(newStep)) {
if (!hasPaidPlan && !isSMSOrWhatsappAction(oldStep.action) && isSMSOrWhatsappAction(newStep.action)) {
throw new TRPCError({ code: "UNAUTHORIZED" });
// check if step that require team plan already existed before
if (
!hasPaidPlan &&
!isTextMessageToSpecificNumber(oldStep.action) &&
isTextMessageToSpecificNumber(newStep.action)
) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" });
}
if (!isKYCVerified && isTextMessageToAttendeeAction(newStep.action)) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Account needs to be verified" });
// 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;
@ -605,11 +614,11 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
//added steps
const addedSteps = steps.map((s) => {
if (s.id <= 0) {
if (isSMSOrWhatsappAction(s.action) && !hasPaidPlan) {
throw new TRPCError({ code: "UNAUTHORIZED" });
if (isSMSOrWhatsappAction(s.action) && !isTextMessageToAttendeeAction(s.action) && !hasPaidPlan) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" });
}
if (!isKYCVerified && isTextMessageToAttendeeAction(s.action)) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Account needs to be verified" });
if (!hasOrgsPlan && isTextMessageToAttendeeAction(s.action)) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Enterprise plan require" });
}
const { id: _stepId, ...stepToAdd } = s;
return stepToAdd;

View File

@ -1,20 +0,0 @@
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>
</>
);
};

View File

@ -0,0 +1,16 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Tooltip } from "../tooltip";
import { Badge } from "./Badge";
export const UpgradeOrgsBadge = function UpgradeOrgsBadge() {
const { t } = useLocale();
return (
<Tooltip content={t("orgs_upgrade_to_enable_feature")}>
<a href="https://cal.com/enterprise" target="_blank">
<Badge variant="gray">{t("upgrade")}</Badge>
</a>
</Tooltip>
);
};

View File

@ -1,5 +1,5 @@
export { Badge } from "./Badge";
export { UpgradeTeamsBadge } from "./UpgradeTeamsBadge";
export { KYCVerificationBadge } from "./KYCVerificationBadge";
export { UpgradeOrgsBadge } from "./UpgradeOrgsBadge";
export type { BadgeProps } from "./Badge";

View File

@ -3,7 +3,7 @@ import { components as reactSelectComponents } from "react-select";
import { classNames } from "@calcom/lib";
import { UpgradeTeamsBadge, KYCVerificationBadge } from "../../badge";
import { UpgradeTeamsBadge, UpgradeOrgsBadge } from "../../badge";
import { Check } from "../../icon";
export const InputComponent = <
@ -29,9 +29,8 @@ export const InputComponent = <
type ExtendedOption = {
value: string | number;
label: string;
needsUpgrade?: boolean;
needsVerification?: boolean;
verificationAction?: () => void;
needsTeamsUpgrade?: boolean;
needsOrgsUpgrade?: boolean;
};
export const OptionComponent = <
@ -48,12 +47,10 @@ 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 ? (
{(props.data as unknown as ExtendedOption).needsTeamsUpgrade ? (
<UpgradeTeamsBadge />
) : (props.data as unknown as ExtendedOption).needsVerification ? (
<KYCVerificationBadge
verifyTeamAction={(props.data as unknown as ExtendedOption).verificationAction}
/>
) : (props.data as unknown as ExtendedOption).needsOrgsUpgrade ? (
<UpgradeOrgsBadge />
) : (
<></>
)}