diff --git a/apps/api/v2/src/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input.ts b/apps/api/v2/src/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input.ts index c255495586..e45142e846 100644 --- a/apps/api/v2/src/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input.ts +++ b/apps/api/v2/src/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input.ts @@ -8,4 +8,4 @@ export class CreateOrganizationAttributeOptionInput { @IsString() @IsNotEmpty() readonly slug!: string; -} \ No newline at end of file +} diff --git a/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes.service.ts b/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes.service.ts index 2e2891fecc..a7a67215c4 100644 --- a/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes.service.ts +++ b/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes.service.ts @@ -3,34 +3,53 @@ import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository"; import { Injectable } from "@nestjs/common"; - @Injectable() export class OrganizationAttributesService { constructor(private readonly organizationAttributesRepository: OrganizationAttributesRepository) {} async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) { - const attribute = await this.organizationAttributesRepository.createOrganizationAttribute(organizationId, data); + const attribute = await this.organizationAttributesRepository.createOrganizationAttribute( + organizationId, + data + ); return attribute; } async getOrganizationAttribute(organizationId: number, attributeId: string) { - const attribute = await this.organizationAttributesRepository.getOrganizationAttribute(organizationId, attributeId); + const attribute = await this.organizationAttributesRepository.getOrganizationAttribute( + organizationId, + attributeId + ); return attribute; } async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) { - const attributes = await this.organizationAttributesRepository.getOrganizationAttributes(organizationId, skip, take); + const attributes = await this.organizationAttributesRepository.getOrganizationAttributes( + organizationId, + skip, + take + ); return attributes; } - async updateOrganizationAttribute(organizationId: number, attributeId: string, data: UpdateOrganizationAttributeInput) { - const attribute = await this.organizationAttributesRepository.updateOrganizationAttribute(organizationId, attributeId, data); - return attribute; - } - - async deleteOrganizationAttribute(organizationId: number, attributeId: string) { - const attribute = await this.organizationAttributesRepository.deleteOrganizationAttribute(organizationId, attributeId); + async updateOrganizationAttribute( + organizationId: number, + attributeId: string, + data: UpdateOrganizationAttributeInput + ) { + const attribute = await this.organizationAttributesRepository.updateOrganizationAttribute( + organizationId, + attributeId, + data + ); return attribute; } + async deleteOrganizationAttribute(organizationId: number, attributeId: string) { + const attribute = await this.organizationAttributesRepository.deleteOrganizationAttribute( + organizationId, + attributeId + ); + return attribute; + } } diff --git a/apps/web/components/apps/installation/AccountsStepCard.tsx b/apps/web/components/apps/installation/AccountsStepCard.tsx index 8f0602e1e8..314d4e931c 100644 --- a/apps/web/components/apps/installation/AccountsStepCard.tsx +++ b/apps/web/components/apps/installation/AccountsStepCard.tsx @@ -4,14 +4,27 @@ import React, { useState } from "react"; import { classNames } from "@calcom/lib"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { Team, User } from "@calcom/prisma/client"; -import { Avatar, StepCard } from "@calcom/ui"; +import type { User } from "@calcom/prisma/client"; +import { StepCard } from "@calcom/ui"; +import { Avatar } from "@calcom/ui"; + +import type { TTeams } from "~/apps/installation/[[...step]]/step-view"; + +export type PersonalAccountProps = Pick & { alreadyInstalled: boolean }; + +type AccountStepCardProps = { + teams?: TTeams; + personalAccount: PersonalAccountProps; + onSelect: (id?: number) => void; + loading: boolean; + installableOnTeams: boolean; +}; type AccountSelectorProps = { avatar?: string; name: string; alreadyInstalled: boolean; - onClick: () => void; + onClick?: () => void; loading: boolean; testId: string; }; @@ -29,13 +42,13 @@ const AccountSelector: FC = ({ return (
{ - if (!alreadyInstalled && !loading) { + if (!alreadyInstalled && !loading && onClick) { setSelected(true); onClick(); } @@ -45,28 +58,14 @@ const AccountSelector: FC = ({ imageSrc={getPlaceholderAvatar(avatar, name)} // if no image, use default avatar size="sm" /> -
+
{name} - {alreadyInstalled ? {t("already_installed")} : ""} + {alreadyInstalled ? ({t("already_installed")}) : ""}
); }; -export type PersonalAccountProps = Pick & { alreadyInstalled: boolean }; - -export type TeamsProp = (Pick & { - alreadyInstalled: boolean; -})[]; - -type AccountStepCardProps = { - teams?: TeamsProp; - personalAccount: PersonalAccountProps; - onSelect: (id?: number) => void; - loading: boolean; - installableOnTeams: boolean; -}; - export const AccountsStepCard: FC = ({ teams, personalAccount, @@ -77,7 +76,7 @@ export const AccountsStepCard: FC = ({ const { t } = useLocale(); return ( -
{t("install_app_on")}
+
{t("install_app_on")}
; - eventTypes: TEventType[] | undefined; + eventTypeGroups: TEventTypeGroup[]; setConfigureStep: Dispatch>; handleSetUpLater: () => void; }; @@ -56,6 +56,7 @@ type EventTypeAppSettingsFormProps = Pick< locations?: LocationObject[]; }) => void; }; +type TUpdatedEventTypesStatus = { id: number; updated: boolean }[][]; const EventTypeAppSettingsForm = forwardRef( function EventTypeAppSettingsForm(props, ref) { @@ -116,82 +117,139 @@ const EventTypeAppSettingsForm = forwardRef = ({ - loading, - formPortalRef, - eventTypes, - setConfigureStep, - handleSetUpLater, +const EventTypeGroup = ({ + groupIndex, + eventTypeGroups, + setUpdatedEventTypesStatus, + submitRefs, ...props +}: ConfigureStepCardProps & { + groupIndex: number; + setUpdatedEventTypesStatus: Dispatch>; + submitRefs: Array>; }) => { - const { t } = useLocale(); - const { control, getValues } = useFormContext(); + const { control } = useFormContext(); const { fields, update } = useFieldArray({ control, - name: "eventTypes", + name: `eventTypeGroups.${groupIndex}.eventTypes`, keyName: "fieldId", }); + return ( +
+ {fields.map( + (field, index) => + field.selected && ( + { + const eventTypeDb = eventTypeGroups[groupIndex].eventTypes?.find( + (eventType) => eventType.id == field.id + ); + update(index, { + ...field, + selected: false, + metadata: eventTypeDb?.metadata, + bookingFields: eventTypeDb?.bookingFields, + ...(eventTypeDb?.locations && { locations: eventTypeDb.locations }), + }); - const submitRefs = useRef>>([]); - submitRefs.current = fields.map( - (_ref, index) => (submitRefs.current[index] = React.createRef()) + setUpdatedEventTypesStatus((prev) => { + const res = [...prev]; + res[groupIndex] = res[groupIndex].filter((item) => !(item.id === field.id)); + if (!res.some((item) => item.length > 0)) { + props.setConfigureStep(false); + } + return res; + }); + }} + onSubmit={(data) => { + update(index, { ...field, ...data }); + setUpdatedEventTypesStatus((prev) => { + const res = [...prev]; + res[groupIndex] = res[groupIndex].map((item) => + item.id === field.id ? { ...item, updated: true } : item + ); + return res; + }); + }} + ref={submitRefs[index]} + {...props} + /> + ) + )} +
+ ); +}; + +export const ConfigureStepCard: FC = (props) => { + const { loading, formPortalRef, handleSetUpLater } = props; + const { t } = useLocale(); + const { control, watch } = useFormContext(); + const { fields } = useFieldArray({ + control, + name: "eventTypeGroups", + keyName: "fieldId", + }); + const eventTypeGroups = watch("eventTypeGroups"); + const submitRefs = useRef>>>([]); + + submitRefs.current = eventTypeGroups.reduce( + (arr: Array>>, field) => { + const res = field.eventTypes + .filter((eventType) => eventType.selected) + .map((_ref) => React.createRef()); + return [...arr, res]; + }, + [] ); const mainForSubmitRef = useRef(null); - const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState( - fields.filter((field) => field.selected).map((field) => ({ id: field.id, updated: false })) - ); - const [submit, setSubmit] = useState(false); - const allUpdated = updatedEventTypesStatus.every((item) => item.updated); + const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState( + eventTypeGroups.reduce((arr: Array<{ id: number; updated: boolean }[]>, field) => { + const selectedEventTypes = field.eventTypes + .filter((eventType) => eventType.selected) + .map((eventType) => ({ id: eventType.id as number, updated: false })); - useEffect(() => { - setUpdatedEventTypesStatus((prev) => - prev.filter((state) => fields.some((field) => field.id === state.id && field.selected)) - ); - if (!fields.some((field) => field.selected)) { - setConfigureStep(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fields]); + return [...arr, selectedEventTypes]; + }, []) + ); + + const [submit, setSubmit] = useState(false); + const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated)); useEffect(() => { if (submit && allUpdated && mainForSubmitRef.current) { mainForSubmitRef.current?.click(); setSubmit(false); } - }, [submit, allUpdated, getValues, mainForSubmitRef]); + }, [submit, allUpdated, mainForSubmitRef]); return ( formPortalRef?.current && createPortal(
-
- {fields.map((field, index) => { - return ( - field.selected && ( - { - const eventMetadataDb = eventTypes?.find( - (eventType) => eventType.id == field.id - )?.metadata; - update(index, { ...field, selected: false, metadata: eventMetadataDb }); - }} - onSubmit={(data) => { - update(index, { ...field, ...data }); - setUpdatedEventTypesStatus((prev) => - prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item)) - ); - }} - ref={submitRefs.current[index]} - {...props} + {fields.map((group, groupIndex) => ( +
+ {eventTypeGroups[groupIndex].eventTypes.some((eventType) => eventType.selected === true) && ( +
+ - ) - ); - })} -
- +

{group.slug}

+
+ )} + +
+ ))} @@ -200,7 +258,7 @@ export const ConfigureStepCard: FC = ({ type="button" data-testid="configure-step-save" onClick={() => { - submitRefs.current.reverse().map((ref) => ref.current?.click()); + submitRefs.current.map((group) => group?.map((ref) => ref.current?.click())); setSubmit(true); }} loading={loading}> diff --git a/apps/web/components/apps/installation/EventTypesStepCard.tsx b/apps/web/components/apps/installation/EventTypesStepCard.tsx index 309b9a6550..ccd1b0d16a 100644 --- a/apps/web/components/apps/installation/EventTypesStepCard.tsx +++ b/apps/web/components/apps/installation/EventTypesStepCard.tsx @@ -5,9 +5,9 @@ import { useFieldArray, useFormContext } from "react-hook-form"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; -import { ScrollableArea, Badge, Button } from "@calcom/ui"; +import { ScrollableArea, Badge, Button, Avatar } from "@calcom/ui"; -import type { TEventType, TEventTypesForm } from "~/apps/installation/[[...step]]/step-view"; +import type { TEventType, TEventTypesForm, TEventTypeGroup } from "~/apps/installation/[[...step]]/step-view"; type EventTypesCardProps = { userName: string; @@ -15,61 +15,7 @@ type EventTypesCardProps = { handleSetUpLater: () => void; }; -export const EventTypesStepCard: FC = ({ - setConfigureStep, - userName, - handleSetUpLater, -}) => { - const { t } = useLocale(); - const { control } = useFormContext(); - const { fields, update } = useFieldArray({ - control, - name: "eventTypes", - keyName: "fieldId", - }); - - return ( -
-
- -
    - {fields.map((field, index) => ( - update(index, { ...field, selected: !field.selected })} - userName={userName} - key={field.fieldId} - {...field} - /> - ))} -
-
-
- - - -
- -
-
- ); -}; +type EventTypeGroupProps = { groupIndex: number; userName: string } & TEventTypeGroup; type EventTypeCardProps = TEventType & { userName: string; handleSelect: () => void }; @@ -95,7 +41,7 @@ const EventTypeCard: FC = ({ return (
handleSelect()}> = ({
); }; +const EventTypeGroup: FC = ({ groupIndex, userName, ...props }) => { + const { control } = useFormContext(); + const { fields, update } = useFieldArray({ + control, + name: `eventTypeGroups.${groupIndex}.eventTypes`, + keyName: "fieldId", + }); + + return ( +
+
+ +

{props.slug}

+
+ +
+ +
    + {fields.length > 0 ? ( + fields.map((field, index) => ( + { + update(index, { ...field, selected: !field.selected }); + }} + userName={userName} + {...field} + /> + )) + ) : ( +
    Team has no Events
    + )} +
+
+
+
+ ); +}; + +export const EventTypesStepCard: FC = ({ + setConfigureStep, + userName, + handleSetUpLater, +}) => { + const { t } = useLocale(); + const { control, watch } = useFormContext(); + const { fields } = useFieldArray({ + control, + name: "eventTypeGroups", + keyName: "fieldId", + }); + + const eventTypeGroups = watch("eventTypeGroups") || []; + + return ( +
+ {fields.map( + (field, index) => + !field.isOrganisation && ( + + ) + )} + + + +
+ +
+
+ ); +}; diff --git a/apps/web/lib/apps/getServerSideProps.tsx b/apps/web/lib/apps/getServerSideProps.tsx index ce6dc94cdd..7288401ee7 100644 --- a/apps/web/lib/apps/getServerSideProps.tsx +++ b/apps/web/lib/apps/getServerSideProps.tsx @@ -2,7 +2,6 @@ import type { GetServerSidePropsContext } from "next"; import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import type { UserAdminTeams } from "@calcom/lib/server/repository/user"; import { UserRepository } from "@calcom/lib/server/repository/user"; import type { AppCategories } from "@calcom/prisma/enums"; @@ -15,13 +14,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const session = await getServerSession({ req }); - let appStore, userAdminTeams: UserAdminTeams; + let appStore, userAdminTeamsIds: number[]; if (session?.user?.id) { - userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id); - appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeams); + const userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id); + userAdminTeamsIds = userAdminTeams?.teams?.map(({ team }) => team.id) ?? []; + appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeamsIds); } else { appStore = await getAppRegistry(); - userAdminTeams = []; + userAdminTeamsIds = []; } const categoryQuery = appStore.map(({ categories }) => ({ @@ -45,7 +45,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return b.count - a.count; }), appStore, - userAdminTeams, + userAdminTeams: userAdminTeamsIds, trpcState: ssr.dehydrate(), }, }; diff --git a/apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts b/apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts index f6df20731b..b3beb6ce4f 100644 --- a/apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts +++ b/apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts @@ -1,3 +1,4 @@ +import type { Prisma } from "@prisma/client"; import type { GetServerSidePropsContext } from "next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { z } from "zod"; @@ -10,134 +11,171 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; import { CAL_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import prisma from "@calcom/prisma"; import { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; import { STEPS } from "~/apps/installation/[[...step]]/constants"; -import type { OnboardingPageProps, TEventType } from "~/apps/installation/[[...step]]/step-view"; - -// Redirect Error map to give context on edge cases, this is for the devs, never shown to users -const ERROR_MESSAGES = { - appNotFound: "App not found", - userNotAuthed: "User is not logged in", - userNotFound: "User from session not found", - appNotExtendsEventType: "App does not extend EventTypes", - userNotInTeam: "User is not in provided team", -} as const; +import type { OnboardingPageProps, TEventTypeGroup } from "~/apps/installation/[[...step]]/step-view"; const getUser = async (userId: number) => { - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - select: { - id: true, - avatarUrl: true, - name: true, - username: true, - teams: { - where: { - accepted: true, - team: { - members: { - some: { - userId, - role: { - in: ["ADMIN", "OWNER"], - }, - }, - }, - }, - }, - select: { - team: { - select: { - id: true, - name: true, - logoUrl: true, - parent: { - select: { - logoUrl: true, - name: true, - }, - }, - }, - }, - }, - }, - }, - }); + const userAdminTeams = await UserRepository.getUserAdminTeams(userId); - if (!user) { - throw new Error(ERROR_MESSAGES.userNotFound); + if (!userAdminTeams?.id) { + return null; } - const teams = user.teams.map(({ team }) => ({ + let teams = userAdminTeams.teams.map(({ team }) => ({ ...team, logoUrl: team.parent ? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name) : getPlaceholderAvatar(team.logoUrl, team.name), })); + + const orgTeam = teams.find((team) => team.isOrganization === true); + if (orgTeam?.id) { + teams = teams.filter((team) => team?.parent?.id !== orgTeam.id); + } return { - ...user, + ...userAdminTeams, teams, }; }; +const getOrgSubTeams = async (parentId: number) => { + const teams = await prisma.team.findMany({ + where: { + parentId, + }, + select: { + id: true, + name: true, + logoUrl: true, + isOrganization: true, + parent: { + select: { + logoUrl: true, + name: true, + id: true, + }, + }, + }, + }); + return teams.map((team) => ({ + ...team, + logoUrl: team.parent + ? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name) + : getPlaceholderAvatar(team.logoUrl, team.name), + })); +}; + const getAppBySlug = async (appSlug: string) => { const app = await prisma.app.findUnique({ where: { slug: appSlug, enabled: true }, select: { slug: true, keys: true, enabled: true, dirName: true }, }); - if (!app) throw new Error(ERROR_MESSAGES.appNotFound); return app; }; -const getEventTypes = async (userId: number, teamId?: number) => { - const eventTypes = ( - await prisma.eventType.findMany({ +const getEventTypes = async (userId: number, teamIds?: number[]) => { + const eventTypeSelect: Prisma.EventTypeSelect = { + id: true, + description: true, + durationLimits: true, + metadata: true, + length: true, + title: true, + position: true, + recurringEvent: true, + requiresConfirmation: true, + team: { select: { slug: true } }, + schedulingType: true, + teamId: true, + users: { select: { username: true } }, + seatsPerTimeSlot: true, + slug: true, + locations: true, + userId: true, + destinationCalendar: true, + bookingFields: true, + }; + let eventTypeGroups: TEventTypeGroup[] | null = []; + + if (teamIds && teamIds.length > 0) { + const teams = await prisma.team.findMany({ + where: { + id: { + in: teamIds, + }, + isOrganization: false, + }, select: { id: true, - description: true, - durationLimits: true, - metadata: true, - length: true, - title: true, - position: true, - recurringEvent: true, - requiresConfirmation: true, - team: { select: { slug: true } }, - schedulingType: true, - teamId: true, - users: { select: { username: true } }, - seatsPerTimeSlot: true, + name: true, + logoUrl: true, slug: true, - locations: true, - userId: true, - destinationCalendar: true, - bookingFields: true, + isOrganization: true, + eventTypes: { + select: eventTypeSelect, + }, }, - /** - * filter out managed events for now - * @todo: can install apps to managed event types - */ - where: teamId ? { teamId } : { userId, parent: null, teamId: null }, - }) - ).sort((eventTypeA, eventTypeB) => { - return eventTypeB.position - eventTypeA.position; - }); + }); + eventTypeGroups = teams.map((team) => ({ + teamId: team.id, + slug: team.slug, + name: team.name, + isOrganisation: team.isOrganization, + image: getPlaceholderAvatar(team.logoUrl, team.name), + eventTypes: team.eventTypes + .map((item) => ({ + ...item, + URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`, + selected: false, + locations: item.locations as unknown as LocationObject[], + bookingFields: eventTypeBookingFields.parse(item.bookingFields || []), + })) + .sort((eventTypeA, eventTypeB) => eventTypeB.position - eventTypeA.position), + })); + } else { + const user = await prisma.user.findFirst({ + where: { + id: userId, + }, + select: { + id: true, + username: true, + name: true, + avatarUrl: true, + eventTypes: { + where: { + teamId: null, + }, + select: eventTypeSelect, + }, + }, + }); - if (eventTypes.length === 0) { - return []; + if (user) { + eventTypeGroups.push({ + userId: user.id, + slug: user.username, + name: user.name, + image: getPlaceholderAvatar(user.avatarUrl, user.name), + eventTypes: user.eventTypes + .map((item) => ({ + ...item, + URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${ + item.slug + }`, + selected: false, + locations: item.locations as unknown as LocationObject[], + bookingFields: eventTypeBookingFields.parse(item.bookingFields || []), + })) + .sort((eventTypeA, eventTypeB) => eventTypeB.position - eventTypeA.position), + }); + } } - - return eventTypes.map((item) => ({ - ...item, - URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`, - selected: false, - locations: item.locations as unknown as LocationObject[], - bookingFields: eventTypeBookingFields.parse(item.bookingFields || []), - })); + return eventTypeGroups; }; const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: number[]) => { @@ -161,136 +199,128 @@ const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: n }; export const getServerSideProps = async (context: GetServerSidePropsContext) => { - try { - let eventTypes: TEventType[] | null = null; - const { req, query, params } = context; - const stepsEnum = z.enum(STEPS); - const parsedAppSlug = z.coerce.string().parse(query?.slug); - const parsedStepParam = z.coerce.string().parse(params?.step); - const parsedTeamIdParam = z.coerce.number().optional().parse(query?.teamId); - const _ = stepsEnum.parse(parsedStepParam); - const session = await getServerSession({ req }); - const locale = await getLocale(context.req); - const app = await getAppBySlug(parsedAppSlug); - const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata]; - const extendsEventType = appMetadata?.extendsFeature === "EventType"; + const { req, res, query, params } = context; + let eventTypeGroups: TEventTypeGroup[] | null = null; + let isOrg = false; + const stepsEnum = z.enum(STEPS); + const parsedAppSlug = z.coerce.string().parse(query?.slug); + const parsedStepParam = z.coerce.string().parse(params?.step); + const parsedTeamIdParam = z.coerce.number().optional().parse(query?.teamId); + const _ = stepsEnum.parse(parsedStepParam); + const session = await getServerSession({ req, res }); + if (!session?.user?.id) return { redirect: { permanent: false, destination: "/auth/login" } }; + const locale = await getLocale(context.req); + const app = await getAppBySlug(parsedAppSlug); + if (!app) return { redirect: { permanent: false, destination: "/apps" } }; + const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata]; + const extendsEventType = appMetadata?.extendsFeature === "EventType"; - const isConferencing = isConferencingApp(appMetadata.categories); - const showEventTypesStep = extendsEventType || isConferencing; - console.log("sshowEventTypesStephowEventTypesStep: ", showEventTypesStep); + const isConferencing = isConferencingApp(appMetadata.categories); + const showEventTypesStep = extendsEventType || isConferencing; - if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed); + const user = await getUser(session.user.id); + if (!user) return { redirect: { permanent: false, destination: "/apps" } }; - const user = await getUser(session.user.id); + let userTeams = user.teams; + const hasTeams = Boolean(userTeams.length); - const userTeams = user.teams; - const hasTeams = Boolean(userTeams.length); - - const appInstalls = await getAppInstallsBySlug( - parsedAppSlug, - user.id, - userTeams.map(({ id }) => id) - ); - - if (parsedTeamIdParam) { - const isUserMemberOfTeam = userTeams.some((team) => team.id === parsedTeamIdParam); - if (!isUserMemberOfTeam) { - throw new Error(ERROR_MESSAGES.userNotInTeam); - } - } - - if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) { - if (!showEventTypesStep) { - return { - redirect: { - permanent: false, - destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`, - }, - }; - } - eventTypes = await getEventTypes(user.id, parsedTeamIdParam); - if (isConferencing) { - const destinationCalendar = await prisma.destinationCalendar.findFirst({ - where: { - userId: user.id, - eventTypeId: null, - }, - }); - for (let index = 0; index < eventTypes.length; index++) { - let eventType = eventTypes[index]; - if (!eventType.destinationCalendar) { - eventType = { ...eventType, destinationCalendar }; - } - eventTypes[index] = eventType; - } - } - - if (eventTypes.length === 0) { - return { - redirect: { - permanent: false, - destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`, - }, - }; - } - } - - const personalAccount = { - id: user.id, - name: user.name, - avatarUrl: user.avatarUrl, - alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id), - }; - - const teamsWithIsAppInstalled = hasTeams - ? userTeams.map((team) => ({ - ...team, - alreadyInstalled: appInstalls.some( - (install) => Boolean(install.teamId) && install.teamId === team.id - ), - })) - : []; - let credentialId = null; - if (parsedTeamIdParam) { - credentialId = - appInstalls.find((item) => !!item.teamId && item.teamId == parsedTeamIdParam)?.id ?? null; - } else { - credentialId = appInstalls.find((item) => !!item.userId && item.userId == user.id)?.id ?? null; - } - return { - props: { - ...(await serverSideTranslations(locale, ["common"])), - app, - appMetadata, - showEventTypesStep, - step: parsedStepParam, - teams: teamsWithIsAppInstalled, - personalAccount, - eventTypes, - teamId: parsedTeamIdParam ?? null, - userName: user.username, - credentialId, - isConferencing, - // conferencing apps dont support team install - installableOnTeams: !isConferencing, - } as OnboardingPageProps, - }; - } catch (err) { - if (err instanceof z.ZodError) { + if (parsedTeamIdParam) { + const currentTeam = userTeams.find((team) => team.id === parsedTeamIdParam); + if (!currentTeam?.id) { return { redirect: { permanent: false, destination: "/apps" } }; } - - if (err instanceof Error) { - switch (err.message) { - case ERROR_MESSAGES.userNotAuthed: - return { redirect: { permanent: false, destination: "/auth/login" } }; - case ERROR_MESSAGES.userNotFound: - return { redirect: { permanent: false, destination: "/auth/login" } }; - default: - return { redirect: { permanent: false, destination: "/apps" } }; - } - } else { - return { redirect: { permanent: false, destination: "/apps" } }; + if (currentTeam.isOrganization) { + const subTeams = await getOrgSubTeams(parsedTeamIdParam); + userTeams = [...userTeams, ...subTeams]; + isOrg = true; } } + + if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) { + if (!showEventTypesStep) { + return { + redirect: { + permanent: false, + destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`, + }, + }; + } + if (isOrg) { + const teamIds = userTeams.map((item) => item.id); + eventTypeGroups = await getEventTypes(user.id, teamIds); + } else if (parsedTeamIdParam) { + eventTypeGroups = await getEventTypes(user.id, [parsedTeamIdParam]); + } else { + eventTypeGroups = await getEventTypes(user.id); + } + if (isConferencing && eventTypeGroups) { + const destinationCalendar = await prisma.destinationCalendar.findFirst({ + where: { + userId: user.id, + eventTypeId: null, + }, + }); + + eventTypeGroups.forEach((group) => { + group.eventTypes = group.eventTypes.map((eventType) => { + if (!eventType.destinationCalendar) { + return { ...eventType, destinationCalendar }; + } + return eventType; + }); + }); + } + } + + const appInstalls = await getAppInstallsBySlug( + parsedAppSlug, + user.id, + userTeams.map(({ id }) => id) + ); + + const personalAccount = { + id: user.id, + name: user.name, + avatarUrl: user.avatarUrl, + alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id), + }; + + const teamsWithIsAppInstalled = hasTeams + ? userTeams.map((team) => ({ + ...team, + alreadyInstalled: appInstalls.some( + (install) => Boolean(install.teamId) && install.teamId === team.id + ), + })) + : []; + let credentialId = null; + if (parsedTeamIdParam) { + credentialId = appInstalls.find((item) => !!item.teamId && item.teamId == parsedTeamIdParam)?.id ?? null; + } else { + credentialId = appInstalls.find((item) => !!item.userId && item.userId == user.id)?.id ?? null; + } + // dont allow app installation without cretendialId + if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP && !credentialId) { + return { redirect: { permanent: false, destination: "/apps" } }; + } + + return { + props: { + ...(await serverSideTranslations(locale, ["common"])), + app, + appMetadata, + showEventTypesStep, + step: parsedStepParam, + teams: teamsWithIsAppInstalled, + personalAccount, + eventTypeGroups, + teamId: parsedTeamIdParam ?? null, + userName: user.username, + credentialId, + isConferencing, + isOrg, + // conferencing apps dont support team install + installableOnTeams: !isConferencing, + } as OnboardingPageProps, + }; }; diff --git a/apps/web/modules/apps/installation/[[...step]]/step-view.tsx b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx index e72cf8bbf8..6691621cbc 100644 --- a/apps/web/modules/apps/installation/[[...step]]/step-view.tsx +++ b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx @@ -16,6 +16,7 @@ import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { Team } from "@calcom/prisma/client"; import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc/react"; @@ -24,7 +25,7 @@ import { Form, Steps, showToast } from "@calcom/ui"; import { HttpError } from "@lib/core/http/error"; -import type { PersonalAccountProps, TeamsProp } from "@components/apps/installation/AccountsStepCard"; +import type { PersonalAccountProps } from "@components/apps/installation/AccountsStepCard"; import { AccountsStepCard } from "@components/apps/installation/AccountsStepCard"; import { ConfigureStepCard } from "@components/apps/installation/ConfigureStepCard"; import { EventTypesStepCard } from "@components/apps/installation/EventTypesStepCard"; @@ -42,10 +43,20 @@ export type TEventType = EventTypeAppSettingsComponentProps["eventType"] & bookingFields?: LocationFormValues["bookingFields"]; }; -export type TEventTypesForm = { +export type TEventTypeGroup = { + teamId?: number; + userId?: number | null; + slug?: string | null; + name?: string | null; + image: string; + isOrganisation?: boolean; eventTypes: TEventType[]; }; +export type TEventTypesForm = { + eventTypeGroups: TEventTypeGroup[]; +}; + type StepType = (typeof STEPS)[number]; type StepObj = Record< @@ -57,17 +68,22 @@ type StepObj = Record< } >; +export type TTeams = (Pick & { + alreadyInstalled: boolean; +})[]; + export type OnboardingPageProps = { appMetadata: AppMeta; step: StepType; - teams?: TeamsProp; + teams?: TTeams; personalAccount: PersonalAccountProps; - eventTypes?: TEventType[]; + eventTypeGroups?: TEventTypeGroup[]; userName: string; credentialId?: number; showEventTypesStep: boolean; isConferencing: boolean; installableOnTeams: boolean; + isOrg: boolean; }; type TUpdateObject = { @@ -82,7 +98,7 @@ const OnboardingPage = ({ teams, personalAccount, appMetadata, - eventTypes, + eventTypeGroups, userName, credentialId, showEventTypesStep, @@ -133,7 +149,7 @@ const OnboardingPage = ({ const formMethods = useForm({ defaultValues: { - eventTypes, + eventTypeGroups, }, }); const mutation = useAddAppMutation(null, { @@ -147,9 +163,9 @@ const OnboardingPage = ({ }); useEffect(() => { - eventTypes && formMethods.setValue("eventTypes", eventTypes); + eventTypeGroups && formMethods.setValue("eventTypeGroups", eventTypeGroups); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [eventTypes]); + }, [eventTypeGroups]); const updateMutation = trpc.viewer.eventTypes.update.useMutation({ onSuccess: async (data) => { @@ -222,34 +238,38 @@ const OnboardingPage = ({ form={formMethods} id="outer-event-type-form" handleSubmit={async (values) => { - const mutationPromises = values?.eventTypes - .filter((eventType) => eventType.selected) - .map((value: TEventType) => { - // Prevent two payment apps to be enabled - // Ok to cast type here because this metadata will be updated as the event type metadata - if ( - checkForMultiplePaymentApps(value.metadata as z.infer) - ) - throw new Error(t("event_setup_multiple_payment_apps_error")); - if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) { - throw new Error(t("seats_and_no_show_fee_error")); - } - let updateObject: TUpdateObject = { id: value.id }; - if (isConferencing) { - updateObject = { - ...updateObject, - locations: value.locations, - bookingFields: value.bookingFields ? value.bookingFields : undefined, - }; - } else { - updateObject = { - ...updateObject, - metadata: value.metadata, - }; - } + let mutationPromises: ReturnType[] = []; + for (const group of values.eventTypeGroups) { + const promises = group.eventTypes + .filter((eventType) => eventType.selected) + .map((value: TEventType) => { + // Prevent two payment apps to be enabled + // Ok to cast type here because this metadata will be updated as the event type metadata + if ( + checkForMultiplePaymentApps(value.metadata as z.infer) + ) + throw new Error(t("event_setup_multiple_payment_apps_error")); + if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) { + throw new Error(t("seats_and_no_show_fee_error")); + } + let updateObject: TUpdateObject = { id: value.id }; + if (isConferencing) { + updateObject = { + ...updateObject, + locations: value.locations, + bookingFields: value.bookingFields ? value.bookingFields : undefined, + }; + } else { + updateObject = { + ...updateObject, + metadata: value.metadata, + }; + } - return updateMutation.mutateAsync(updateObject); - }); + return updateMutation.mutateAsync(updateObject); + }); + mutationPromises = [...mutationPromises, ...promises]; + } try { await Promise.all(mutationPromises); router.push("/event-types"); @@ -272,28 +292,30 @@ const OnboardingPage = ({ /> )} {currentStep === AppOnboardingSteps.EVENT_TYPES_STEP && - eventTypes && - Boolean(eventTypes?.length) && ( + eventTypeGroups && + Boolean(eventTypeGroups?.length) && ( )} - {currentStep === AppOnboardingSteps.CONFIGURE_STEP && formPortalRef.current && ( - - )} + {currentStep === AppOnboardingSteps.CONFIGURE_STEP && + formPortalRef.current && + eventTypeGroups && ( + + )}
diff --git a/apps/web/playwright/apps/conferencing/conferencingApps.e2e.ts b/apps/web/playwright/apps/conferencing/conferencingApps.e2e.ts index de5942b9ca..f6b0549212 100644 --- a/apps/web/playwright/apps/conferencing/conferencingApps.e2e.ts +++ b/apps/web/playwright/apps/conferencing/conferencingApps.e2e.ts @@ -138,7 +138,7 @@ test.describe("check non-oAuth link-based conferencing apps using the new flow", const user = await users.create(); await user.apiLogin(); const eventTypes = await user.getUserEventsAsOwner(); - const eventTypeIds = eventTypes.map((item) => item.id).filter((item, index) => index < 2); + const eventTypeIds = eventTypes.map((item) => item.id).filter((item, index) => index < 1); await appsPage.installConferencingAppNewFlow(app, eventTypeIds); await appsPage.verifyConferencingAppNew(app, eventTypeIds); }); diff --git a/apps/web/playwright/fixtures/apps.ts b/apps/web/playwright/fixtures/apps.ts index 60f60dad4d..d86fe1a3a7 100644 --- a/apps/web/playwright/fixtures/apps.ts +++ b/apps/web/playwright/fixtures/apps.ts @@ -73,8 +73,7 @@ export function createAppsFixture(page: Page) { }, installConferencingAppNewFlow: async (app: TApp, eventTypeIds: number[]) => { - await page.goto("apps/categories/conferencing"); - await page.getByTestId(`app-store-app-card-${app.slug}`).click(); + await page.goto(`apps/${app.slug}`); await page.getByTestId("install-app-button").click(); await page.waitForURL(`apps/installation/event-types?slug=${app.slug}`); @@ -82,6 +81,7 @@ export function createAppsFixture(page: Page) { await page.click(`[data-testid="select-event-type-${id}"]`); } await page.click(`[data-testid="save-event-types"]`); + page.waitForLoadState("networkidle"); for (let eindex = 0; eindex < eventTypeIds.length; eindex++) { if (!app.organizerInputPlaceholder) continue; diff --git a/apps/web/playwright/integrations-stripe.e2e.ts b/apps/web/playwright/integrations-stripe.e2e.ts index 75aacc2f15..7e6754726a 100644 --- a/apps/web/playwright/integrations-stripe.e2e.ts +++ b/apps/web/playwright/integrations-stripe.e2e.ts @@ -265,7 +265,7 @@ test.describe("Stripe integration skip true", () => { }); }); -test.describe("Stripe integration with the new app install flow skip flase", () => { +test.describe("Stripe integration with the new app install flow skip false", () => { // eslint-disable-next-line playwright/no-skipped-test test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed"); diff --git a/packages/app-store/_utils/oauth/createOAuthAppCredential.ts b/packages/app-store/_utils/oauth/createOAuthAppCredential.ts index 6d10992e9b..5e820c00b5 100644 --- a/packages/app-store/_utils/oauth/createOAuthAppCredential.ts +++ b/packages/app-store/_utils/oauth/createOAuthAppCredential.ts @@ -29,20 +29,7 @@ const createOAuthAppCredential = async ( if (state?.teamId) { // Check that the user belongs to the team - const team = await prisma.team.findFirst({ - where: { - id: state.teamId, - members: { - some: { - userId: req.session?.user.id, - accepted: true, - }, - }, - }, - select: { id: true, members: { select: { userId: true } } }, - }); - - if (!team) throw new Error("User does not belong to the team"); + await throwIfNotHaveAdminAccessToTeam({ teamId: state?.teamId ?? null, userId }); return await prisma.credential.create({ data: { @@ -54,8 +41,6 @@ const createOAuthAppCredential = async ( }); } - await throwIfNotHaveAdminAccessToTeam({ teamId: state?.teamId ?? null, userId }); - return await prisma.credential.create({ data: { type: appData.type, diff --git a/packages/app-store/_utils/throwIfNotHaveAdminAccessToTeam.ts b/packages/app-store/_utils/throwIfNotHaveAdminAccessToTeam.ts index dbbeba1d63..744cc99b82 100644 --- a/packages/app-store/_utils/throwIfNotHaveAdminAccessToTeam.ts +++ b/packages/app-store/_utils/throwIfNotHaveAdminAccessToTeam.ts @@ -11,7 +11,8 @@ export const throwIfNotHaveAdminAccessToTeam = async ({ if (!teamId) { return; } - const teamsUserHasAdminAccessFor = await UserRepository.getUserAdminTeams(userId); + const userAdminTeams = await UserRepository.getUserAdminTeams(userId); + const teamsUserHasAdminAccessFor = userAdminTeams?.teams?.map(({ team }) => team.id) ?? []; const hasAdminAccessToTeam = teamsUserHasAdminAccessFor.some((id) => id === teamId); if (!hasAdminAccessToTeam) { diff --git a/packages/app-store/_utils/useAddAppMutation.ts b/packages/app-store/_utils/useAddAppMutation.ts index 98c4e632cb..b3ef6e6784 100644 --- a/packages/app-store/_utils/useAddAppMutation.ts +++ b/packages/app-store/_utils/useAddAppMutation.ts @@ -67,8 +67,8 @@ function useAddAppMutation(_type: App["type"] | null, options?: UseAddAppMutatio const state: IntegrationOAuthCallbackState = { onErrorReturnTo, fromApp: true, - ...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }), ...(teamId && { teamId }), + ...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }), ...(returnTo && { returnTo }), ...(defaultInstall && { defaultInstall }), }; diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index 832f32c7c2..3ecea08512 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -516,7 +516,58 @@ export class UserRepository { }, }); } - + static async getUserAdminTeams(userId: number) { + return prisma.user.findFirst({ + where: { + id: userId, + }, + select: { + id: true, + avatarUrl: true, + name: true, + username: true, + teams: { + where: { + accepted: true, + OR: [ + { + role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] }, + }, + { + team: { + parent: { + members: { + some: { + id: userId, + role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] }, + }, + }, + }, + }, + }, + ], + }, + select: { + team: { + select: { + id: true, + name: true, + logoUrl: true, + isOrganization: true, + parent: { + select: { + logoUrl: true, + name: true, + id: true, + }, + }, + }, + }, + }, + }, + }, + }); + } static async isAdminOfTeamOrParentOrg({ userId, teamId }: { userId: number; teamId: number }) { const membershipQuery = { members: { @@ -543,29 +594,6 @@ export class UserRepository { return !!teams.length; } - static async getUserAdminTeams(userId: number): Promise { - const user = await prisma.user.findFirst({ - where: { - id: userId, - }, - select: { - teams: { - where: { - accepted: true, - role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] }, - }, - select: { teamId: true }, - }, - }, - }); - - const teamIds = []; - for (const team of user?.teams || []) { - teamIds.push(team.teamId); - } - return teamIds; - } - static async getTimeZoneAndDefaultScheduleId({ userId }: { userId: number }) { return await prisma.user.findUnique({ where: { diff --git a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts index 69db8c273c..114c25e4c3 100644 --- a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts @@ -15,6 +15,7 @@ type AppCredentialsByTypeOptions = { export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentialsByTypeOptions) => { const { user } = ctx; const userAdminTeams = await UserRepository.getUserAdminTeams(ctx.user.id); + const userAdminTeamsIds = userAdminTeams?.teams?.map(({ team }) => team.id) ?? []; const credentials = await prisma.credential.findMany({ where: { @@ -22,7 +23,7 @@ export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentials { userId: user.id }, { teamId: { - in: userAdminTeams, + in: userAdminTeamsIds, }, }, ], @@ -34,6 +35,6 @@ export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentials // return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id); return { credentials, - userAdminTeams, + userAdminTeams: userAdminTeamsIds, }; };