diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index f92f54faa0..854e981095 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -5,10 +5,9 @@ import type { FC } from "react"; import { useEffect, useState, memo } from "react"; import { z } from "zod"; -import { - CreateEventTypeButton, - EventTypeDescriptionLazy as EventTypeDescription, -} from "@calcom/features/eventtypes/components"; +import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components"; +import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog"; +import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog"; import Shell from "@calcom/features/shell/Shell"; import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -35,6 +34,7 @@ import { showToast, Switch, Tooltip, + CreateButton, HorizontalTabs, } from "@calcom/ui"; import { @@ -134,9 +134,9 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou ) : null} {readOnly && ( - + {t("readonly")} - + )} { const query = { ...router.query, - dialog: "duplicate-event-type", + dialog: "duplicate", title: eventType.title, description: eventType.description, slug: eventType.slug, @@ -627,17 +627,32 @@ const CreateFirstEventTypeView = () => { }; const CTA = () => { + const { t } = useLocale(); + const query = trpc.viewer.eventTypes.getByViewer.useQuery(); if (!query.data) return null; - return ; + const profileOptions = query.data.profiles + .filter((profile) => !profile.readOnly) + .map((profile) => { + return { teamId: profile.teamId, label: profile.name || profile.slug, image: profile.image }; + }); + + return ( + + ); }; const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer); const EventTypesPage = () => { const { t } = useLocale(); + const router = useRouter(); const isMobile = useMediaQuery("(max-width: 768px)"); @@ -683,6 +698,7 @@ const EventTypesPage = () => { )} + {router.query.dialog === "duplicate" && } )} /> diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 7e6e3a9f3b..6b5d84dd22 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -598,7 +598,6 @@ "new_event_type_heading": "Create your first event type", "new_event_type_description": "Event types enable you to share links that show available times on your calendar and allow people to make bookings with you.", "new_event_title": "Add a new event type", - "new_event_subtitle": "Create an event type under your name or a team.", "new_team_event": "Add a new team event type", "new_event_description": "Create a new event type for people to book times with.", "event_type_created_successfully": "{{eventTypeTitle}} event type created successfully", @@ -1527,6 +1526,7 @@ "sender_name": "Sender name", "already_invited": "Attendee already invited", "no_recordings_found": "No recordings found", + "new_workflow_subtitle": "New workflow for...", "reporting": "Reporting", "reporting_feature": "See all incoming from data and download it as a CSV", "teams_plan_required": "Teams plan required", @@ -1581,14 +1581,17 @@ "email_no_user_step_four":"Join {{teamName}}", "email_no_user_signoff":"Happy Scheduling from the {{appName}} team", "impersonation_user_tip": "You are about to impersonate a user, which means you can make changes on their behalf. Please be careful.", - "scheduler": "{Scheduler}", "available_variables": "Available variables", + "scheduler": "{Scheduler}", + "no_workflows": "No workflows", + "change_filter": "Change filter to see your personal and team workflows.", "recommended_next_steps": "Recommended next steps", "create_a_managed_event": "Create a managed event type", "meetings_are_better_with_the_right": "Meetings are better with the right team members there. Invite them now.", "create_a_one_one_template": "Create a one-one one template for an event type and distribute it to multiple members.", "collective_or_roundrobin": "Collective or round-robin", "book_your_team_members": "Book your team members together with collective events or cycle through to get the right person with round-robin.", + "create_event_on": "Create event on", "default_app_link_title":"Set a default app link", "default_app_link_description":"Setting a default app link allows all newly created event types to use the app link you set.", "change_default_conferencing_app":"Set as default", @@ -1619,5 +1622,7 @@ "select_a_router": "Select a router", "add_a_new_route": "Add a new Route", "no_responses_yet": "No responses yet", - "this_will_be_the_placeholder": "This will be the placeholder" + "this_will_be_the_placeholder": "This will be the placeholder", + "verification_code": "Verification code", + "verify": "Verify" } diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index 385b053159..e0920441b1 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -1,5 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { MembershipRole, Prisma } from "@prisma/client"; +import type { Prisma } from "@prisma/client"; +import { MembershipRole } from "@prisma/client"; import MarkdownIt from "markdown-it"; import { useSession } from "next-auth/react"; import Link from "next/link"; @@ -7,7 +8,6 @@ import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; -import { CAL_URL } from "@calcom/lib/constants"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar"; import { useLocale } from "@calcom/lib/hooks/useLocale"; diff --git a/packages/features/ee/workflows/components/DeleteDialog.tsx b/packages/features/ee/workflows/components/DeleteDialog.tsx index 925d7a75ec..ddb1848799 100644 --- a/packages/features/ee/workflows/components/DeleteDialog.tsx +++ b/packages/features/ee/workflows/components/DeleteDialog.tsx @@ -1,4 +1,4 @@ -import { Dispatch, SetStateAction } from "react"; +import type { Dispatch, SetStateAction } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; @@ -30,6 +30,10 @@ export const DeleteDialog = (props: IDeleteDialog) => { showToast(message, "error"); setIsOpenDialog(false); } + if (err.data?.code === "UNAUTHORIZED") { + const message = `${err.data.code}: You are not authorized to delete this workflow`; + showToast(message, "error"); + } }, }); diff --git a/packages/features/ee/workflows/components/EmptyScreen.tsx b/packages/features/ee/workflows/components/EmptyScreen.tsx index edcdc95caa..b0de24999f 100644 --- a/packages/features/ee/workflows/components/EmptyScreen.tsx +++ b/packages/features/ee/workflows/components/EmptyScreen.tsx @@ -1,9 +1,11 @@ -import React from "react"; +import { useRouter } from "next/router"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { SVGComponent } from "@calcom/types/SVGComponent"; -import { Button } from "@calcom/ui"; -import { FiSmartphone, FiMail, FiPlus } from "@calcom/ui/components/icon"; +import { HttpError } from "@calcom/lib/http-error"; +import { trpc } from "@calcom/trpc/react"; +import type { SVGComponent } from "@calcom/types/SVGComponent"; +import { CreateButton, showToast, EmptyScreen as ClassicEmptyScreen } from "@calcom/ui"; +import { FiSmartphone, FiMail, FiZap } from "@calcom/ui/components/icon"; type WorkflowExampleType = { Icon: SVGComponent; @@ -31,24 +33,33 @@ function WorkflowExample(props: WorkflowExampleType) { ); } -export default function EmptyScreen({ - IconHeading, - headline, - description, - buttonText, - buttonOnClick, - isLoading, - showExampleWorkflows, -}: { - IconHeading: SVGComponent; - headline: string; - description: string | React.ReactElement; - buttonText?: string; - buttonOnClick?: (event: React.MouseEvent) => void; - isLoading: boolean; - showExampleWorkflows: boolean; +export default function EmptyScreen(props: { + profileOptions: { + label: string | null; + image?: string | null; + teamId: number | null | undefined; + }[]; + isFilteredView: boolean; }) { const { t } = useLocale(); + const router = useRouter(); + + const createMutation = trpc.viewer.workflows.create.useMutation({ + onSuccess: async ({ workflow }) => { + await router.replace("/workflows/" + workflow.id); + }, + onError: (err) => { + if (err instanceof HttpError) { + const message = `${err.statusCode}: ${err.message}`; + showToast(message, "error"); + } + + if (err.data?.code === "UNAUTHORIZED") { + const message = `${err.data.code}: You are not authorized to create this workflow`; + showToast(message, "error"); + } + }, + }); const workflowsExamples = [ { icon: FiSmartphone, text: t("workflow_example_1") }, @@ -60,38 +71,39 @@ export default function EmptyScreen({ ]; // new workflow example when 'after meetings ends' trigger is implemented: Send custom thank you email to attendee after event (FiSmile icon), + if (props.isFilteredView) { + return ; + } + return ( <>
- +
-

{headline}

+

{t("workflows")}

- {description} + {t("no_workflows_description")}

- {buttonOnClick && buttonText && ( - - )} -
-
- {showExampleWorkflows && ( -
-
- {workflowsExamples.map((example, index) => ( - - ))} +
+ createMutation.mutate({ teamId })} + buttonText={t("create_workflow")} + isLoading={createMutation.isLoading} + />
- )} +
+
+
+ {workflowsExamples.map((example, index) => ( + + ))} +
+
); } diff --git a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx b/packages/features/ee/workflows/components/EventWorkflowsTab.tsx index 8641933e67..8f1189f5c0 100644 --- a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx +++ b/packages/features/ee/workflows/components/EventWorkflowsTab.tsx @@ -13,7 +13,7 @@ import { FiExternalLink, FiZap } from "@calcom/ui/components/icon"; import LicenseRequired from "../../common/components/v2/LicenseRequired"; import { getActionIcon } from "../lib/getActionIcon"; import SkeletonLoader from "./SkeletonLoaderEventWorkflowsTab"; -import { WorkflowType } from "./WorkflowListPage"; +import type { WorkflowType } from "./WorkflowListPage"; type ItemProps = { workflow: WorkflowType; @@ -65,6 +65,11 @@ const WorkflowListItem = (props: ItemProps) => { const message = `${err.statusCode}: ${err.message}`; showToast(message, "error"); } + if (err.data?.code === "UNAUTHORIZED") { + // TODO: Add missing translation + const message = `${err.data.code}: You are not authorized to enable or disable this workflow`; + showToast(message, "error"); + } }, }); @@ -148,14 +153,21 @@ type Props = { eventType: { id: number; title: string; + userId: number | null; + team: { + id?: number; + } | null; }; workflows: WorkflowType[]; }; function EventWorkflowsTab(props: Props) { - const { workflows } = props; + const { workflows, eventType } = props; const { t } = useLocale(); - const { data, isLoading } = trpc.viewer.workflows.list.useQuery(); + const { data, isLoading } = trpc.viewer.workflows.list.useQuery({ + teamId: eventType.team?.id, + userId: eventType.userId || undefined, + }); const router = useRouter(); const [sortedWorkflows, setSortedWorkflows] = useState>([]); @@ -176,7 +188,7 @@ function EventWorkflowsTab(props: Props) { } }, [isLoading]); - const createMutation = trpc.viewer.workflows.createV2.useMutation({ + const createMutation = trpc.viewer.workflows.create.useMutation({ onSuccess: async ({ workflow }) => { await router.replace("/workflows/" + workflow.id); }, @@ -212,7 +224,7 @@ function EventWorkflowsTab(props: Props) { diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx index f9d2978ac9..38d7747067 100644 --- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx @@ -1,7 +1,10 @@ -import { WorkflowActions, WorkflowTemplates } from "@prisma/client"; +import type { WorkflowActions } from "@prisma/client"; +import { WorkflowTemplates } from "@prisma/client"; import { useRouter } from "next/router"; -import { Dispatch, SetStateAction, useMemo, useState } from "react"; -import { Controller, UseFormReturn } from "react-hook-form"; +import type { Dispatch, SetStateAction } from "react"; +import { useMemo, useState } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { Controller } from "react-hook-form"; import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -21,10 +24,12 @@ interface Props { workflowId: number; selectedEventTypes: Option[]; setSelectedEventTypes: Dispatch>; + teamId?: number; + isMixedEventType: boolean; } export default function WorkflowDetailsPage(props: Props) { - const { form, workflowId, selectedEventTypes, setSelectedEventTypes } = props; + const { form, workflowId, selectedEventTypes, setSelectedEventTypes, teamId, isMixedEventType } = props; const { t } = useLocale(); const router = useRouter(); @@ -36,19 +41,32 @@ export default function WorkflowDetailsPage(props: Props) { const eventTypeOptions = useMemo( () => - data?.eventTypeGroups.reduce( - (options, group) => [ + data?.eventTypeGroups.reduce((options, group) => { + /** only show event types that belong to team or user */ + if (!(!teamId && !group.teamId) || teamId !== group.teamId) return options; + return [ ...options, ...group.eventTypes.map((eventType) => ({ value: String(eventType.id), label: eventType.title, })), - ], - [] as Option[] - ) || [], + ]; + }, [] as Option[]) || [], [data] ); + let allEventTypeOptions = eventTypeOptions; + const distinctEventTypes = new Set(); + + if (!teamId && isMixedEventType) { + allEventTypeOptions = [...eventTypeOptions, ...selectedEventTypes]; + allEventTypeOptions = allEventTypeOptions.filter((option) => { + const duplicate = distinctEventTypes.has(option.value); + distinctEventTypes.add(option.value); + return !duplicate; + }); + } + const addAction = ( action: WorkflowActions, sendTo?: string, @@ -101,7 +119,7 @@ export default function WorkflowDetailsPage(props: Props) { render={() => { return ( {form.getValues("trigger") && (
- +
)} {form.getValues("steps") && ( @@ -142,6 +160,7 @@ export default function WorkflowDetailsPage(props: Props) { step={step} reload={reload} setReload={setReload} + teamId={teamId} /> ); })} diff --git a/packages/features/ee/workflows/components/WorkflowListPage.tsx b/packages/features/ee/workflows/components/WorkflowListPage.tsx index 1827d780c9..92201ac0ae 100644 --- a/packages/features/ee/workflows/components/WorkflowListPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowListPage.tsx @@ -1,11 +1,11 @@ -import { Workflow, WorkflowStep } from "@prisma/client"; +import type { Workflow, WorkflowStep, Membership } from "@prisma/client"; +import { useSession } from "next-auth/react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useState } from "react"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { HttpError } from "@calcom/lib/http-error"; import { trpc } from "@calcom/trpc/react"; import { Button, @@ -15,50 +15,22 @@ import { DropdownMenuItem, DropdownItem, DropdownMenuTrigger, - showToast, Tooltip, + Badge, } from "@calcom/ui"; -import { FiEdit2, FiLink, FiMoreHorizontal, FiTrash2, FiZap } from "@calcom/ui/components/icon"; +import { FiEdit2, FiLink, FiMoreHorizontal, FiTrash2 } from "@calcom/ui/components/icon"; import { getActionIcon } from "../lib/getActionIcon"; import { DeleteDialog } from "./DeleteDialog"; import EmptyScreen from "./EmptyScreen"; -const CreateEmptyWorkflowView = () => { - const { t } = useLocale(); - const router = useRouter(); - - const createMutation = trpc.viewer.workflows.createV2.useMutation({ - onSuccess: async ({ workflow }) => { - await router.replace("/workflows/" + workflow.id); - }, - onError: (err) => { - if (err instanceof HttpError) { - const message = `${err.statusCode}: ${err.message}`; - showToast(message, "error"); - } - - if (err.data?.code === "UNAUTHORIZED") { - const message = `${err.data.code}: You are not able to create this workflow`; - showToast(message, "error"); - } - }, - }); - - return ( - createMutation.mutate()} - IconHeading={FiZap} - headline={t("workflows")} - description={t("no_workflows_description")} - isLoading={createMutation.isLoading} - showExampleWorkflows={true} - /> - ); -}; - export type WorkflowType = Workflow & { + team: { + id: number; + name: string; + members: Membership[]; + slug: string | null; + } | null; steps: WorkflowStep[]; activeOn: { eventType: { @@ -66,16 +38,24 @@ export type WorkflowType = Workflow & { title: string; }; }[]; + readOnly?: boolean; }; interface Props { workflows: WorkflowType[] | undefined; + profileOptions: { + image?: string | null; + label: string | null; + teamId: number | null | undefined; + }[]; + hasNoWorkflows?: boolean; } -export default function WorkflowListPage({ workflows }: Props) { +export default function WorkflowListPage({ workflows, profileOptions, hasNoWorkflows }: Props) { const { t } = useLocale(); const utils = trpc.useContext(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [workflowToDeleteId, setwWorkflowToDeleteId] = useState(0); const router = useRouter(); + const session = useSession(); return ( <> @@ -87,57 +67,79 @@ export default function WorkflowListPage({ workflows }: Props) {
-
- {workflow.name - ? workflow.name - : workflow.steps[0] - ? "Untitled (" + - `${t(`${workflow.steps[0].action.toLowerCase()}_action`)}` - .charAt(0) - .toUpperCase() + - `${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) + - ")" - : "Untitled"} +
+
+ {workflow.name + ? workflow.name + : workflow.steps[0] + ? "Untitled (" + + `${t(`${workflow.steps[0].action.toLowerCase()}_action`)}` + .charAt(0) + .toUpperCase() + + `${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) + + ")" + : "Untitled"} +
+
+ {workflow.readOnly && ( + + {t("readonly")} + + )} +
-
    -
  • -
    - {getActionIcon(workflow.steps)} - {t("triggers")} - {workflow.timeUnit && workflow.time && ( - - {t(`${workflow.timeUnit.toLowerCase()}`, { count: workflow.time })} - - )} - {t(`${workflow.trigger.toLowerCase()}_trigger`)} -
    +
      +
    • + +
      + {getActionIcon(workflow.steps)} + + {t("triggers")} + {workflow.timeUnit && workflow.time && ( + + {t(`${workflow.timeUnit.toLowerCase()}`, { count: workflow.time })} + + )} + {t(`${workflow.trigger.toLowerCase()}_trigger`)} +
      +
    • -
    • - {workflow.activeOn && workflow.activeOn.length > 0 ? ( - ( -

      {activeOn.eventType.title}

      - ))}> +
    • + + {workflow.activeOn && workflow.activeOn.length > 0 ? ( + ( +

      {activeOn.eventType.title}

      + ))}> +
      +
      +
      + ) : (
      - - ) : ( -
      -
      - )} + )} +
    • + {workflow.teamId && ( +
    • + + <>{workflow.team?.name} + +
    • + )}
+
@@ -147,6 +149,7 @@ export default function WorkflowListPage({ workflows }: Props) { color="secondary" variant="icon" StartIcon={FiEdit2} + disabled={workflow.readOnly} onClick={async () => await router.replace("/workflows/" + workflow.id)} /> @@ -158,40 +161,48 @@ export default function WorkflowListPage({ workflows }: Props) { }} color="secondary" variant="icon" + disabled={workflow.readOnly} StartIcon={FiTrash2} />
-
- - -
+ color="minimal" + variant="icon" + StartIcon={FiMoreHorizontal} + /> + + + + await router.replace("/workflows/" + workflow.id)}> + {t("edit")} + + + + { + setDeleteDialogOpen(true); + setwWorkflowToDeleteId(workflow.id); + }}> + {t("delete")} + + + + +
+ )}
@@ -207,7 +218,7 @@ export default function WorkflowListPage({ workflows }: Props) { /> ) : ( - + )} ); diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index 3b13904cdb..613bb23e84 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -1,12 +1,9 @@ -import { - TimeUnit, - WorkflowActions, - WorkflowStep, - WorkflowTemplates, - WorkflowTriggerEvents, -} from "@prisma/client"; -import { Dispatch, SetStateAction, useRef, useState } from "react"; -import { Controller, UseFormReturn } from "react-hook-form"; +import type { WorkflowStep } from "@prisma/client"; +import { TimeUnit, WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@prisma/client"; +import type { Dispatch, SetStateAction } from "react"; +import { useRef, useState, useEffect } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { Controller } from "react-hook-form"; import "react-phone-number-input/style.css"; import { classNames } from "@calcom/lib"; @@ -51,17 +48,20 @@ type WorkflowStepProps = { form: UseFormReturn; reload?: boolean; setReload?: Dispatch>; + teamId?: number; }; export default function WorkflowStepContainer(props: WorkflowStepProps) { - const { t, i18n } = useLocale(); + const { t } = useLocale(); const utils = trpc.useContext(); - const { step, form, reload, setReload } = props; - const { data: _verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery(); - const verifiedNumbers = _verifiedNumbers?.map((number) => number.phoneNumber); + const { step, form, reload, setReload, teamId } = props; + const { data: _verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery( + { teamId }, + { enabled: !!teamId } + ); + const verifiedNumbers = _verifiedNumbers?.map((number) => number.phoneNumber) || []; const [isAdditionalInputsDialogOpen, setIsAdditionalInputsDialogOpen] = useState(false); - const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); const [verificationCode, setVerificationCode] = useState(""); @@ -75,6 +75,13 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { : false ); + useEffect(() => { + setNumberVerified( + !!step && + !!verifiedNumbers.find((number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`)) + ); + }, [verifiedNumbers.length]); + const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState( step?.action === WorkflowActions.EMAIL_ADDRESS ? true : false ); @@ -116,9 +123,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { const refReminderBody = useRef(null); const [numberVerified, setNumberVerified] = useState( - verifiedNumbers && step - ? !!verifiedNumbers.find((number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`)) - : false + step && + !!verifiedNumbers.find((number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`)) ); const addVariableBody = (variable: string) => { @@ -451,11 +457,18 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { verifyPhoneNumberMutation.mutate({ phoneNumber: form.getValues(`steps.${step.stepNumber - 1}.sendTo`) || "", code: verificationCode, + teamId, }); }}> - Verify + {t("verify")} + {form.formState.errors.steps && + form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && ( +

+ {form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""} +

+ )} )} diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index 769ac11f41..6ef3f02b61 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -54,6 +54,7 @@ export const scheduleWorkflowReminders = async ( step.template, step.sender || SENDER_ID, workflow.userId, + workflow.teamId, step.numberVerificationPending ); } else if ( @@ -124,6 +125,7 @@ export const sendCancelledReminders = async ( step.template, step.sender || SENDER_ID, workflow.userId, + workflow.teamId, step.numberVerificationPending ); } else if ( diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index 6b17c0dbb0..fe726b4a65 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -53,7 +53,8 @@ export const scheduleSMSReminder = async ( workflowStepId: number, template: WorkflowTemplates, sender: string, - userId: number, + userId?: number | null, + teamId?: number | null, isVerificationPending = false ) => { const { startTime, endTime } = evt; @@ -69,7 +70,10 @@ export const scheduleSMSReminder = async ( async function getIsNumberVerified() { if (action === WorkflowActions.SMS_ATTENDEE) return true; const verifiedNumber = await prisma.verifiedNumber.findFirst({ - where: { userId, phoneNumber: reminderPhone || "" }, + where: { + OR: [{ userId }, { teamId }], + phoneNumber: reminderPhone || "", + }, }); if (!!verifiedNumber) return true; return isVerificationPending; diff --git a/packages/features/ee/workflows/lib/reminders/verifyPhoneNumber.ts b/packages/features/ee/workflows/lib/reminders/verifyPhoneNumber.ts index 4f911cd59a..6041747e5d 100644 --- a/packages/features/ee/workflows/lib/reminders/verifyPhoneNumber.ts +++ b/packages/features/ee/workflows/lib/reminders/verifyPhoneNumber.ts @@ -6,13 +6,21 @@ export const sendVerificationCode = async (phoneNumber: string) => { return twilio.sendVerificationCode(phoneNumber); }; -export const verifyPhoneNumber = async (phoneNumber: string, code: string, userId: number) => { +export const verifyPhoneNumber = async ( + phoneNumber: string, + code: string, + userId?: number, + teamId?: number +) => { + if (!userId && !teamId) return true; + const verificationStatus = await twilio.verifyNumber(phoneNumber, code); if (verificationStatus === "approved") { await prisma.verifiedNumber.create({ data: { userId, + teamId, phoneNumber, }, }); diff --git a/packages/features/ee/workflows/pages/index.tsx b/packages/features/ee/workflows/pages/index.tsx index dbaee5bb90..55c95eff84 100644 --- a/packages/features/ee/workflows/pages/index.tsx +++ b/packages/features/ee/workflows/pages/index.tsx @@ -1,26 +1,34 @@ import { useSession } from "next-auth/react"; import { useRouter } from "next/router"; +import type { Dispatch, SetStateAction } from "react"; +import { useState, useEffect } from "react"; import Shell from "@calcom/features/shell/Shell"; +import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; import { trpc } from "@calcom/trpc/react"; -import { Button, showToast } from "@calcom/ui"; -import { FiPlus } from "@calcom/ui/components/icon"; +import { AnimatedPopover, Avatar, CreateButton, showToast } from "@calcom/ui"; import LicenseRequired from "../../common/components/v2/LicenseRequired"; import SkeletonLoader from "../components/SkeletonLoaderList"; +import type { WorkflowType } from "../components/WorkflowListPage"; import WorkflowList from "../components/WorkflowListPage"; function WorkflowsPage() { const { t } = useLocale(); - const session = useSession(); const router = useRouter(); + const [checkedFilterItems, setCheckedFilterItems] = useState<{ userId: number | null; teamIds: number[] }>({ + userId: session.data?.user.id || null, + teamIds: [], + }); - const { data, isLoading } = trpc.viewer.workflows.list.useQuery(); + const { data: allWorkflowsData, isLoading } = trpc.viewer.workflows.list.useQuery(); - const createMutation = trpc.viewer.workflows.createV2.useMutation({ + const [filteredWorkflows, setFilteredWorkflows] = useState([]); + + const createMutation = trpc.viewer.workflows.create.useMutation({ onSuccess: async ({ workflow }) => { await router.replace("/workflows/" + workflow.id); }, @@ -37,20 +45,66 @@ function WorkflowsPage() { }, }); + const query = trpc.viewer.workflows.getByViewer.useQuery(); + + useEffect(() => { + const allWorkflows = allWorkflowsData?.workflows; + if (allWorkflows && allWorkflows.length > 0) { + const filtered = allWorkflows.filter((workflow) => { + if (checkedFilterItems.teamIds.includes(workflow.teamId || 0)) return workflow; + if (!workflow.teamId) { + if (!!workflow.userId && workflow.userId === checkedFilterItems.userId) return workflow; + } + }); + setFilteredWorkflows(filtered); + } else { + setFilteredWorkflows([]); + } + }, [checkedFilterItems, allWorkflowsData]); + + useEffect(() => { + if (session.status !== "loading" && !query.isLoading) { + if (!query.data) return; + setCheckedFilterItems({ + userId: session.data?.user.id || null, + teamIds: query.data.profiles + .map((profile) => { + if (!!profile.teamId) { + return profile.teamId; + } + }) + .filter((teamId): teamId is number => !!teamId), + }); + } + }, [session.status, query.isLoading, allWorkflowsData]); + + if (!query.data) return null; + + const profileOptions = query.data.profiles + .filter((profile) => !profile.readOnly) + .map((profile) => { + return { teamId: profile.teamId, label: profile.name || profile.slug, image: profile.image }; + }); + return ( 0 ? ( - + query.data.profiles.length === 1 && + session.data?.hasValidLicense && + allWorkflowsData?.workflows && + allWorkflowsData?.workflows.length > 0 ? ( + { + createMutation.mutate({ teamId }); + }} + isLoading={createMutation.isLoading} + disableMobileButton={true} + /> ) : ( <> ) @@ -60,7 +114,31 @@ function WorkflowsPage() { ) : ( <> - + {query.data.profiles.length > 1 && + allWorkflowsData?.workflows && + allWorkflowsData.workflows.length > 0 && ( +
+ +
+ createMutation.mutate({ teamId })} + isLoading={createMutation.isLoading} + disableMobileButton={true} + /> +
+
+ )} + )} @@ -68,4 +146,126 @@ function WorkflowsPage() { ); } +const Filter = (props: { + profiles: { + readOnly?: boolean | undefined; + slug: string | null; + name: string | null; + teamId: number | null | undefined; + }[]; + checked: { + userId: number | null; + teamIds: number[]; + }; + setChecked: Dispatch< + SetStateAction<{ + userId: number | null; + teamIds: number[]; + }> + >; +}) => { + const session = useSession(); + const userId = session.data?.user.id || 0; + const userName = session.data?.user.name || ""; + + const teams = props.profiles.filter((profile) => !!profile.teamId); + const { checked, setChecked } = props; + + const [noFilter, setNoFilter] = useState(true); + + return ( +
+ +
+ + + + { + if (e.target.checked) { + setChecked({ userId: userId, teamIds: checked.teamIds }); + if (checked.teamIds.length === teams.length) { + setNoFilter(true); + } + } else if (!e.target.checked) { + setChecked({ userId: null, teamIds: checked.teamIds }); + + setNoFilter(false); + } + }} + /> +
+ {teams.map((profile) => ( +
+ + + + { + if (e.target.checked) { + const updatedChecked = checked; + updatedChecked.teamIds.push(profile.teamId || 0); + setChecked({ userId: checked.userId, teamIds: [...updatedChecked.teamIds] }); + + if (checked.userId && updatedChecked.teamIds.length === teams.length) { + setNoFilter(true); + } else { + setNoFilter(false); + } + } else if (!e.target.checked) { + const index = checked.teamIds.indexOf(profile.teamId || 0); + if (index !== -1) { + const updatedChecked = checked; + updatedChecked.teamIds.splice(index, 1); + setChecked({ userId: checked.userId, teamIds: [...updatedChecked.teamIds] }); + + if (checked.userId && updatedChecked.teamIds.length === teams.length) { + setNoFilter(true); + } else { + setNoFilter(false); + } + } + } + }} + className="text-primary-600 focus:ring-primary-500 inline-flex h-4 w-4 place-self-center justify-self-end rounded border-gray-300 " + /> +
+ ))} +
+
+ ); +}; + export default WorkflowsPage; diff --git a/packages/features/ee/workflows/pages/workflow.tsx b/packages/features/ee/workflows/pages/workflow.tsx index 59737a7f83..0caee1b9ca 100644 --- a/packages/features/ee/workflows/pages/workflow.tsx +++ b/packages/features/ee/workflows/pages/workflow.tsx @@ -1,10 +1,11 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import type { WorkflowStep } from "@prisma/client"; import { TimeUnit, WorkflowActions, - WorkflowStep, WorkflowTemplates, WorkflowTriggerEvents, + MembershipRole, } from "@prisma/client"; import { isValidPhoneNumber } from "libphonenumber-js"; import { useSession } from "next-auth/react"; @@ -21,7 +22,7 @@ import { HttpError } from "@calcom/lib/http-error"; import { stringOrNumber } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc/react"; import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui"; -import { Alert, Button, Form, showToast } from "@calcom/ui"; +import { Alert, Button, Form, showToast, Badge } from "@calcom/ui"; import LicenseRequired from "../../common/components/v2/LicenseRequired"; import SkeletonLoader from "../components/SkeletonLoaderEdit"; @@ -87,6 +88,7 @@ function WorkflowPage() { const [selectedEventTypes, setSelectedEventTypes] = useState([]); const [isAllDataLoaded, setIsAllDataLoaded] = useState(false); + const [isMixedEventType, setIsMixedEventType] = useState(false); //for old event types before team workflows existed const form = useForm({ mode: "onBlur", @@ -108,10 +110,22 @@ function WorkflowPage() { } ); - const { data: verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery(); + const { data: verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery( + { teamId: workflow?.team?.id }, + { + enabled: !!workflow?.id, + } + ); + + const readOnly = + workflow?.team?.members?.find((member) => member.userId === session.data?.user.id)?.role === + MembershipRole.MEMBER; useEffect(() => { if (workflow && !isLoading) { + if (workflow.userId && workflow.activeOn.find((active) => !!active.eventType.teamId)) { + setIsMixedEventType(true); + } setSelectedEventTypes( workflow.activeOn.map((active) => ({ value: String(active.eventType.id), @@ -247,14 +261,23 @@ function WorkflowPage() { title={workflow && workflow.name ? workflow.name : "Untitled"} CTA={
- +
} heading={ session.data?.hasValidLicense && isAllDataLoaded && ( -
- {workflow && workflow.name ? workflow.name : "untitled"} +
+
+ {workflow && workflow.name ? workflow.name : "untitled"} +
+ {workflow && workflow.team && ( + + {workflow.team.slug} + + )}
) }> @@ -268,6 +291,8 @@ function WorkflowPage() { workflowId={+workflowId} selectedEventTypes={selectedEventTypes} setSelectedEventTypes={setSelectedEventTypes} + teamId={workflow ? workflow.teamId || undefined : undefined} + isMixedEventType={isMixedEventType} /> ) : ( diff --git a/packages/features/eventtypes/components/CreateEventTypeButton.tsx b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx similarity index 69% rename from packages/features/eventtypes/components/CreateEventTypeButton.tsx rename to packages/features/eventtypes/components/CreateEventTypeDialog.tsx index 331afe9e0c..fe16070ecb 100644 --- a/packages/features/eventtypes/components/CreateEventTypeButton.tsx +++ b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; import { HttpError } from "@calcom/lib/http-error"; @@ -14,27 +13,16 @@ import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype"; import { trpc } from "@calcom/trpc/react"; import { Alert, - Avatar, Button, Dialog, DialogClose, DialogContent, - Dropdown, - DropdownMenuContent, - DropdownMenuItem, - DropdownItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, Form, RadioGroup as RadioArea, showToast, TextAreaField, TextField, } from "@calcom/ui"; -import { FiPlus } from "@calcom/ui/components/icon"; - -import { DuplicateDialog } from "./DuplicateDialog"; // this describes the uniform data needed to create a new event type on Profile or Team export interface EventTypeParent { @@ -44,15 +32,6 @@ export interface EventTypeParent { image?: string | null; } -interface CreateEventTypeBtnProps { - // set true for use on the team settings page - canAddEvents: boolean; - // set true when in use on the team settings page - isIndividualTeam?: boolean; - // EventTypeParent can be a profile (as first option) or a team for the rest. - options: EventTypeParent[]; -} - const locationFormSchema = z.array( z.object({ locationType: z.string(), @@ -71,10 +50,7 @@ const querySchema = z.object({ teamId: z.union([z.string().transform((val) => +val), z.number()]).optional(), title: z.string().optional(), slug: z.string().optional(), - length: z - .union([z.string().transform((val) => +val), z.number()]) - .optional() - .default(15), + length: z.union([z.string().transform((val) => +val), z.number()]).optional(), description: z.string().optional(), schedulingType: z.nativeEnum(SchedulingType).optional(), locations: z @@ -83,17 +59,19 @@ const querySchema = z.object({ .optional(), }); -const CreateEventTypeDialog = () => { +export default function CreateEventTypeDialog() { const { t } = useLocale(); const router = useRouter(); const { - data: { teamId, eventPage: pageSlug, ...defaultValues }, + data: { teamId, eventPage: pageSlug }, } = useTypedQuery(querySchema); const form = useForm>({ + defaultValues: { + length: 15, + }, resolver: zodResolver(createEventTypeInput), - defaultValues, }); const { register } = form; @@ -123,7 +101,7 @@ const CreateEventTypeDialog = () => { return ( { ); -}; - -export default function CreateEventTypeButton(props: CreateEventTypeBtnProps) { - const { t } = useLocale(); - const router = useRouter(); - - const hasTeams = !!props.options.find((option) => option.teamId); - - // inject selection data into url for correct router history - const openModal = (option: EventTypeParent) => { - const query = { - ...router.query, - dialog: "new-eventtype", - eventPage: option.slug, - teamId: option.teamId, - }; - if (!option.teamId) { - delete query.teamId; - } - router.push( - { - pathname: router.pathname, - query, - }, - undefined, - { shallow: true } - ); - }; - - return ( - <> - {!hasTeams || props.isIndividualTeam ? ( - - ) : ( - - - - - - -
{t("new_event_subtitle")}
-
- - {props.options.map((option) => ( - - ( - - )} - onClick={() => openModal(option)}> - {option.name ? option.name : option.slug} - - - ))} -
-
- )} - {/* Dialog for duplicate event type */} - {router.query.dialog === "duplicate-event-type" && } - {router.query.dialog === "new-eventtype" && } - - ); } diff --git a/packages/features/eventtypes/components/DuplicateDialog.tsx b/packages/features/eventtypes/components/DuplicateDialog.tsx index 49dce168dc..e856252b73 100644 --- a/packages/features/eventtypes/components/DuplicateDialog.tsx +++ b/packages/features/eventtypes/components/DuplicateDialog.tsx @@ -1,4 +1,5 @@ import { useRouter } from "next/router"; +import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -44,6 +45,16 @@ const DuplicateDialog = () => { }); const { register } = form; + useEffect(() => { + if (router.query.dialog === "duplicate") { + form.setValue("id", Number(router.query.id as string) || -1); + form.setValue("title", (router.query.title as string) || ""); + form.setValue("slug", t("event_type_duplicate_copy_text", { slug: router.query.slug as string })); + form.setValue("description", (router.query.description as string) || ""); + form.setValue("length", Number(router.query.length) || 30); + } + }, [router.query.dialog]); + const duplicateMutation = trpc.viewer.eventTypes.duplicate.useMutation({ onSuccess: async ({ eventType }) => { await router.replace("/event-types/" + eventType.id); @@ -69,7 +80,7 @@ const DuplicateDialog = () => { return (
import("./EventTypeDescription")); diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 5bf2ae1630..b396caa2ef 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -152,6 +152,14 @@ export default async function getEventTypeById({ include: { workflow: { include: { + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, activeOn: { select: { eventType: { diff --git a/packages/prisma/migrations/20230210132534_add_workflows_to_teams/migration.sql b/packages/prisma/migrations/20230210132534_add_workflows_to_teams/migration.sql new file mode 100644 index 0000000000..5a0475a2d8 --- /dev/null +++ b/packages/prisma/migrations/20230210132534_add_workflows_to_teams/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Workflow" ADD COLUMN "teamId" INTEGER, +ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Workflow" ADD CONSTRAINT "Workflow_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20230210182245_add_verified_numbers_to_team/migration.sql b/packages/prisma/migrations/20230210182245_add_verified_numbers_to_team/migration.sql new file mode 100644 index 0000000000..f381d6548c --- /dev/null +++ b/packages/prisma/migrations/20230210182245_add_verified_numbers_to_team/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "VerifiedNumber" ADD COLUMN "teamId" INTEGER, +ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "VerifiedNumber" ADD CONSTRAINT "VerifiedNumber_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 8e34f78988..fbc9198b49 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -203,23 +203,25 @@ model User { } model Team { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// @zod.min(1) name String /// @zod.min(1) - slug String? @unique + slug String? @unique logo String? bio String? - hideBranding Boolean @default(false) - hideBookATeamMember Boolean @default(false) + hideBranding Boolean @default(false) + hideBookATeamMember Boolean @default(false) members Membership[] eventTypes EventType[] - createdAt DateTime @default(now()) + workflows Workflow[] + createdAt DateTime @default(now()) /// @zod.custom(imports.teamMetadataSchema) metadata Json? theme String? - brandColor String @default("#292929") - darkBrandColor String @default("#fafafa") + brandColor String @default("#292929") + darkBrandColor String @default("#fafafa") + verifiedNumbers VerifiedNumber[] } enum MembershipRole { @@ -598,8 +600,10 @@ model WorkflowStep { model Workflow { id Int @id @default(autoincrement()) name String - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? activeOn WorkflowsOnEventTypes[] trigger WorkflowTriggerEvents time Int? @@ -656,7 +660,9 @@ enum WorkflowMethods { model VerifiedNumber { id Int @id @default(autoincrement()) - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) phoneNumber String } diff --git a/packages/trpc/server/routers/viewer/eventTypes.ts b/packages/trpc/server/routers/viewer/eventTypes.ts index a8b8081105..c67c7b6173 100644 --- a/packages/trpc/server/routers/viewer/eventTypes.ts +++ b/packages/trpc/server/routers/viewer/eventTypes.ts @@ -219,6 +219,7 @@ export const eventTypesRouter = router({ startTime: true, endTime: true, bufferTime: true, + avatar: true, teams: { where: { accepted: true, @@ -323,6 +324,7 @@ export const eventTypesRouter = router({ profile: { slug: user.username, name: user.name, + image: user.avatar || undefined, }, eventTypes: _.orderBy(mergedEventTypes, ["position", "id"], ["desc", "asc"]), metadata: { diff --git a/packages/trpc/server/routers/viewer/workflows.tsx b/packages/trpc/server/routers/viewer/workflows.tsx index 468bb8a02e..7e586e78d1 100644 --- a/packages/trpc/server/routers/viewer/workflows.tsx +++ b/packages/trpc/server/routers/viewer/workflows.tsx @@ -1,4 +1,4 @@ -import type { PrismaPromise } from "@prisma/client"; +import type { PrismaPromise, Workflow } from "@prisma/client"; import { WorkflowTemplates, WorkflowActions, @@ -6,9 +6,11 @@ import { BookingStatus, WorkflowMethods, TimeUnit, + MembershipRole, } from "@prisma/client"; import { z } from "zod"; +import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage"; // import dayjs from "@calcom/dayjs"; import { WORKFLOW_TEMPLATES, @@ -30,11 +32,12 @@ import { verifyPhoneNumber, sendVerificationCode, } from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber"; -import { IS_SELF_HOSTED, SENDER_ID } from "@calcom/lib/constants"; +import { IS_SELF_HOSTED, SENDER_ID, CAL_URL } from "@calcom/lib/constants"; import { SENDER_NAME } from "@calcom/lib/constants"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; // import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getTranslation } from "@calcom/lib/server/i18n"; +import type PrismaType from "@calcom/prisma"; import type { WorkflowStep } from "@calcom/prisma/client"; import { TRPCError } from "@trpc/server"; @@ -48,32 +51,210 @@ function getSender( return isSMSAction(step.action) ? step.sender || SENDER_ID : step.senderName || SENDER_NAME; } -export const workflowsRouter = router({ - list: authedProcedure.query(async ({ ctx }) => { - const workflows = await ctx.prisma.workflow.findMany({ +async function isAuthorized( + workflow: Pick | null, + prisma: typeof PrismaType, + currentUserId: number, + readOnly?: boolean +) { + if (!workflow) { + return false; + } + + if (!readOnly) { + const userWorkflow = await prisma.workflow.findFirst({ where: { - userId: ctx.user.id, + id: workflow.id, + OR: [ + { userId: currentUserId }, + { + team: { + members: { + some: { + userId: currentUserId, + accepted: true, + }, + }, + }, + }, + ], }, - include: { - activeOn: { - select: { - eventType: { - select: { - id: true, - title: true, + }); + if (userWorkflow) return true; + } + + const userWorkflow = await prisma.workflow.findFirst({ + where: { + id: workflow.id, + OR: [ + { userId: currentUserId }, + { + team: { + members: { + some: { + userId: currentUserId, + accepted: true, + NOT: { + role: MembershipRole.MEMBER, + }, }, }, }, }, - steps: true, - }, - orderBy: { - id: "asc", - }, - }); + ], + }, + }); - return { workflows }; - }), + if (userWorkflow) return true; + + return false; +} + +export const workflowsRouter = router({ + list: authedProcedure + .input( + z + .object({ + teamId: z.number().optional(), + userId: z.number().optional(), + }) + .optional() + ) + .query(async ({ ctx, input }) => { + if (input && input.teamId) { + const workflows: WorkflowType[] = await ctx.prisma.workflow.findMany({ + where: { + team: { + id: input.teamId, + members: { + some: { + userId: ctx.user.id, + accepted: true, + }, + }, + }, + }, + include: { + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + }, + }, + }, + }, + steps: true, + }, + orderBy: { + id: "asc", + }, + }); + const workflowsWithReadOnly = workflows.map((workflow) => { + const readOnly = !!workflow.team?.members?.find( + (member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER + ); + return { ...workflow, readOnly }; + }); + + return { workflows: workflowsWithReadOnly }; + } + + if (input && input.userId) { + const workflows: WorkflowType[] = await ctx.prisma.workflow.findMany({ + where: { + userId: ctx.user.id, + }, + include: { + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + }, + }, + }, + }, + steps: true, + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + }, + orderBy: { + id: "asc", + }, + }); + + return { workflows }; + } + + const workflows = await ctx.prisma.workflow.findMany({ + where: { + OR: [ + { userId: ctx.user.id }, + { + team: { + members: { + some: { + userId: ctx.user.id, + accepted: true, + }, + }, + }, + }, + ], + }, + include: { + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + }, + }, + }, + }, + steps: true, + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + }, + orderBy: { + id: "asc", + }, + }); + + const workflowsWithReadOnly: WorkflowType[] = workflows.map((workflow) => { + const readOnly = !!workflow.team?.members?.find( + (member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER + ); + + return { readOnly, ...workflow }; + }); + + return { workflows: workflowsWithReadOnly }; + }), get: authedProcedure .input( z.object({ @@ -83,12 +264,20 @@ export const workflowsRouter = router({ .query(async ({ ctx, input }) => { const workflow = await ctx.prisma.workflow.findFirst({ where: { - userId: ctx.user.id, id: input.id, }, select: { id: true, name: true, + userId: true, + teamId: true, + team: { + select: { + id: true, + slug: true, + members: true, + }, + }, time: true, timeUnit: true, activeOn: { @@ -104,45 +293,71 @@ export const workflowsRouter = router({ }, }, }); - if (!workflow) { + + const isUserAuthorized = await isAuthorized(workflow, ctx.prisma, ctx.user.id); + + if (!isUserAuthorized) { throw new TRPCError({ code: "UNAUTHORIZED", }); } + return workflow; }), create: authedProcedure .input( z.object({ - name: z.string(), - trigger: z.enum(WORKFLOW_TRIGGER_EVENTS), - action: z.enum(WORKFLOW_ACTIONS), - timeUnit: z.enum(TIME_UNIT).optional(), - time: z.number().optional(), - sendTo: z.string().optional(), + teamId: z.number().optional(), }) ) .mutation(async ({ ctx, input }) => { - const { name, trigger, action, timeUnit, time, sendTo } = input; + const { teamId } = input; + const userId = ctx.user.id; + if (teamId) { + const team = await ctx.prisma.team.findFirst({ + where: { + id: teamId, + members: { + some: { + userId: ctx.user.id, + accepted: true, + NOT: { + role: MembershipRole.MEMBER, + }, + }, + }, + }, + }); + + if (!team) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + } + try { - const workflow = await ctx.prisma.workflow.create({ + const workflow: Workflow = await ctx.prisma.workflow.create({ data: { - name, - trigger, + name: "", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 24, + timeUnit: TimeUnit.HOUR, userId, - timeUnit: time ? timeUnit : undefined, - time, + teamId, }, }); await ctx.prisma.workflowStep.create({ data: { stepNumber: 1, - action, + action: WorkflowActions.EMAIL_HOST, + template: WorkflowTemplates.REMINDER, workflowId: workflow.id, - sendTo, + sender: SENDER_NAME, + numberVerificationPending: false, }, }); return { workflow }; @@ -150,35 +365,6 @@ export const workflowsRouter = router({ throw e; } }), - createV2: authedProcedure.mutation(async ({ ctx }) => { - const userId = ctx.user.id; - - try { - const workflow = await ctx.prisma.workflow.create({ - data: { - name: "", - trigger: WorkflowTriggerEvents.BEFORE_EVENT, - time: 24, - timeUnit: TimeUnit.HOUR, - userId, - }, - }); - - await ctx.prisma.workflowStep.create({ - data: { - stepNumber: 1, - action: WorkflowActions.EMAIL_HOST, - template: WorkflowTemplates.REMINDER, - workflowId: workflow.id, - sender: SENDER_NAME, - numberVerificationPending: false, - }, - }); - return { workflow }; - } catch (e) { - throw e; - } - }), delete: authedProcedure .input( z.object({ @@ -191,40 +377,42 @@ export const workflowsRouter = router({ const workflowToDelete = await ctx.prisma.workflow.findFirst({ where: { id, - userId: ctx.user.id, }, }); - if (workflowToDelete) { - const scheduledReminders = await ctx.prisma.workflowReminder.findMany({ - where: { - workflowStep: { - workflowId: id, - }, - scheduled: true, - NOT: { - referenceId: null, - }, - }, - }); + const isUserAuthorized = await isAuthorized(workflowToDelete, ctx.prisma, ctx.user.id, true); - //cancel workflow reminders of deleted workflow - scheduledReminders.forEach((reminder) => { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.id, reminder.referenceId, true); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.id, reminder.referenceId); - } - }); - - await ctx.prisma.workflow.deleteMany({ - where: { - userId: ctx.user.id, - id, - }, - }); + if (!isUserAuthorized || !workflowToDelete) { + throw new TRPCError({ code: "UNAUTHORIZED" }); } + const scheduledReminders = await ctx.prisma.workflowReminder.findMany({ + where: { + workflowStep: { + workflowId: id, + }, + scheduled: true, + NOT: { + referenceId: null, + }, + }, + }); + + //cancel workflow reminders of deleted workflow + scheduledReminders.forEach((reminder) => { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.id, reminder.referenceId, true); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.id, reminder.referenceId); + } + }); + + await ctx.prisma.workflow.deleteMany({ + where: { + id, + }, + }); + return { id, }; @@ -264,7 +452,9 @@ export const workflowsRouter = router({ id, }, select: { + id: true, userId: true, + teamId: true, user: { select: { teams: true, @@ -274,12 +464,15 @@ export const workflowsRouter = router({ }, }); - if ( - !userWorkflow || - userWorkflow.userId !== user.id || - steps.filter((step) => step.workflowId != id).length > 0 - ) + const isUserAuthorized = await isAuthorized(userWorkflow, ctx.prisma, ctx.user.id, true); + + if (!isUserAuthorized || !userWorkflow) { throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + if (steps.find((step) => step.workflowId != id)) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } const oldActiveOnEventTypes = await ctx.prisma.workflowsOnEventTypes.findMany({ where: { @@ -303,7 +496,7 @@ export const workflowsRouter = router({ } }); - //check if new event types belong to user + //check if new event types belong to user or team for (const newEventTypeId of newActiveEventTypes) { const newEventType = await ctx.prisma.eventType.findFirst({ where: { @@ -318,13 +511,20 @@ export const workflowsRouter = router({ }, }, }); - if ( - newEventType && - newEventType.userId !== user.id && - !newEventType?.team?.members.find((membership) => membership.userId === user.id) && - !newEventType?.users.find((eventTypeUser) => eventTypeUser.id === user.id) - ) { - throw new TRPCError({ code: "UNAUTHORIZED" }); + + if (newEventType) { + if (userWorkflow.teamId && userWorkflow.teamId !== newEventType.teamId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + if ( + !userWorkflow.teamId && + userWorkflow.userId && + newEventType.userId !== userWorkflow.userId && + !newEventType?.users.find((eventTypeUser) => eventTypeUser.id === userWorkflow.userId) + ) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } } } @@ -484,7 +684,8 @@ export const workflowsRouter = router({ step.id, step.template, step.sender || SENDER_ID, - user.id + user.id, + userWorkflow.teamId ); } }); @@ -532,7 +733,8 @@ export const workflowsRouter = router({ //step was edited } else if (JSON.stringify(oldStep) !== JSON.stringify(newStep)) { if ( - !userWorkflow.user.teams.length && + !userWorkflow.teamId && + !userWorkflow.user?.teams.length && !isSMSAction(oldStep.action) && isSMSAction(newStep.action) ) { @@ -674,7 +876,8 @@ export const workflowsRouter = router({ newStep.id || 0, newStep.template, newStep.sender || SENDER_ID, - user.id + user.id, + userWorkflow.teamId ); } }); @@ -684,7 +887,7 @@ export const workflowsRouter = router({ //added steps const addedSteps = steps.map((s) => { if (s.id <= 0) { - if (!userWorkflow.user.teams.length && isSMSAction(s.action)) { + if (!userWorkflow.user?.teams.length && isSMSAction(s.action)) { throw new TRPCError({ code: "UNAUTHORIZED" }); } const { id: stepId, ...stepToAdd } = s; @@ -801,7 +1004,8 @@ export const workflowsRouter = router({ createdStep.id, step.template, step.sender || SENDER_ID, - user.id + user.id, + userWorkflow.teamId ); } }); @@ -833,6 +1037,13 @@ export const workflowsRouter = router({ eventType: true, }, }, + team: { + select: { + id: true, + slug: true, + members: true, + }, + }, steps: { orderBy: { stepNumber: "asc", @@ -1018,31 +1229,52 @@ action === WorkflowActions.EMAIL_ADDRESS*/ .mutation(async ({ ctx, input }) => { const { eventTypeId, workflowId } = input; - // Check that workflow & event type belong to the user + // Check that vent type belong to the user or team const userEventType = await ctx.prisma.eventType.findFirst({ where: { id: eventTypeId, - users: { - some: { - id: ctx.user.id, + OR: [ + { userId: ctx.user.id }, + { + team: { + members: { + some: { + userId: ctx.user.id, + accepted: true, + NOT: { + role: MembershipRole.MEMBER, + }, + }, + }, + }, }, - }, + ], }, }); if (!userEventType) - throw new TRPCError({ code: "UNAUTHORIZED", message: "This event type does not belong to the user" }); + throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authorized to edit this event type" }); - // Check that the workflow belongs to the user + // Check that the workflow belongs to the user or team const eventTypeWorkflow = await ctx.prisma.workflow.findFirst({ where: { id: workflowId, - userId: ctx.user.id, + OR: [ + { + userId: ctx.user.id, + }, + { + teamId: userEventType.teamId, + }, + ], }, }); if (!eventTypeWorkflow) - throw new TRPCError({ code: "UNAUTHORIZED", message: "This event type does not belong to the user" }); + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Not authorized to enable/disable this workflow", + }); //check if event type is already active const isActive = await ctx.prisma.workflowsOnEventTypes.findFirst({ @@ -1083,24 +1315,31 @@ action === WorkflowActions.EMAIL_ADDRESS*/ z.object({ phoneNumber: z.string(), code: z.string(), + teamId: z.number().optional(), }) ) .mutation(async ({ ctx, input }) => { - const { phoneNumber, code } = input; + const { phoneNumber, code, teamId } = input; const { user } = ctx; - const verifyStatus = await verifyPhoneNumber(phoneNumber, code, user.id); + const verifyStatus = await verifyPhoneNumber(phoneNumber, code, user.id, teamId); return verifyStatus; }), - getVerifiedNumbers: authedProcedure.query(async ({ ctx }) => { - const { user } = ctx; - const verifiedNumbers = await ctx.prisma.verifiedNumber.findMany({ - where: { - userId: user.id, - }, - }); + getVerifiedNumbers: authedProcedure + .input( + z.object({ + teamId: z.number().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { user } = ctx; + const verifiedNumbers = await ctx.prisma.verifiedNumber.findMany({ + where: { + OR: [{ userId: user.id }, { teamId: input.teamId }], + }, + }); - return verifiedNumbers; - }), + return verifiedNumbers; + }), getWorkflowActionOptions: authedProcedure.query(async ({ ctx }) => { const { user } = ctx; @@ -1114,4 +1353,113 @@ action === WorkflowActions.EMAIL_ADDRESS*/ const t = await getTranslation(ctx.user.locale, "common"); return getWorkflowActionOptions(t, IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan); }), + getByViewer: authedProcedure.query(async ({ ctx }) => { + const { prisma } = ctx; + + const user = await prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + select: { + id: true, + username: true, + avatar: true, + name: true, + startTime: true, + endTime: true, + bufferTime: true, + workflows: { + select: { + id: true, + name: true, + }, + }, + teams: { + where: { + accepted: true, + }, + select: { + role: true, + team: { + select: { + id: true, + name: true, + slug: true, + members: { + select: { + userId: true, + }, + }, + workflows: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!user) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + + const userWorkflows = user.workflows; + + type WorkflowGroup = { + teamId?: number | null; + profile: { + slug: (typeof user)["username"]; + name: (typeof user)["name"]; + image?: string; + }; + metadata?: { + readOnly: boolean; + }; + workflows: typeof userWorkflows; + }; + + let workflowGroups: WorkflowGroup[] = []; + + workflowGroups.push({ + teamId: null, + profile: { + slug: user.username, + name: user.name, + image: user.avatar || undefined, + }, + workflows: userWorkflows, + metadata: { + readOnly: false, + }, + }); + + workflowGroups = ([] as WorkflowGroup[]).concat( + workflowGroups, + user.teams.map((membership) => ({ + teamId: membership.team.id, + profile: { + name: membership.team.name, + slug: "team/" + membership.team.slug, + image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`, + }, + metadata: { + readOnly: membership.role === MembershipRole.MEMBER, + }, + workflows: membership.team.workflows, + })) + ); + + return { + workflowGroups: workflowGroups.filter((groupBy) => !!groupBy.workflows?.length), + profiles: workflowGroups.map((group) => ({ + teamId: group.teamId, + ...group.profile, + ...group.metadata, + })), + }; + }), }); diff --git a/packages/ui/components/createButton/CreateButton.tsx b/packages/ui/components/createButton/CreateButton.tsx new file mode 100644 index 0000000000..192e6d3153 --- /dev/null +++ b/packages/ui/components/createButton/CreateButton.tsx @@ -0,0 +1,124 @@ +import { useRouter } from "next/router"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { + Avatar, + Button, + Dropdown, + DropdownItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@calcom/ui"; +import { FiPlus } from "@calcom/ui/components/icon"; + +export interface Option { + teamId: number | null | undefined; // if undefined, then it's a profile + label: string | null; + image?: string | null; +} + +interface CreateBtnProps { + options: Option[]; + createDialog?: () => JSX.Element; + createFunction?: (teamId?: number) => void; + subtitle?: string; + buttonText?: string; + isLoading?: boolean; + disableMobileButton?: boolean; +} + +export function CreateButton(props: CreateBtnProps) { + const { t } = useLocale(); + const router = useRouter(); + + const CreateDialog = props.createDialog ? props.createDialog() : null; + + const hasTeams = !!props.options.find((option) => option.teamId); + + // inject selection data into url for correct router history + const openModal = (option: Option) => { + const query = { + ...router.query, + dialog: "new", + eventPage: option.label, + teamId: option.teamId, + }; + if (!option.teamId) { + delete query.teamId; + } + router.push( + { + pathname: router.pathname, + query, + }, + undefined, + { shallow: true } + ); + }; + + return ( + <> + {!hasTeams ? ( + + ) : ( + + + + + + +
{props.subtitle}
+
+ {props.options.map((option) => ( + + ( + + )} + onClick={() => + !!CreateDialog + ? openModal(option) + : props.createFunction + ? props.createFunction(option.teamId || undefined) + : null + }> + {" "} + {/*improve this code */} + {option.label} + + + ))} +
+
+ )} + {router.query.dialog === "new" && CreateDialog} + + ); +} diff --git a/packages/ui/components/createButton/index.ts b/packages/ui/components/createButton/index.ts new file mode 100644 index 0000000000..b73942be77 --- /dev/null +++ b/packages/ui/components/createButton/index.ts @@ -0,0 +1 @@ +export { CreateButton } from "./CreateButton"; diff --git a/packages/ui/components/popover/AnimatedPopover.tsx b/packages/ui/components/popover/AnimatedPopover.tsx index 079ea56749..f6774af090 100644 --- a/packages/ui/components/popover/AnimatedPopover.tsx +++ b/packages/ui/components/popover/AnimatedPopover.tsx @@ -44,9 +44,7 @@ export const AnimatedPopover = ({
+ className="mb-2 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border border-gray-300 px-3 py-2 text-sm placeholder:text-gray-400 hover:cursor-pointer hover:border-gray-400 focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1">
diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 95d1d42934..908c89fbb8 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -128,3 +128,4 @@ export { default as MultiSelectCheckboxes } from "./components/form/checkbox/Mul export type { Option as MultiSelectCheckboxesOptionType } from "./components/form/checkbox/MultiSelectCheckboxes"; export { default as ImageUploader } from "./components/image-uploader/ImageUploader"; export type { ButtonColor } from "./components/button/Button"; +export { CreateButton } from "./components/createButton";