diff --git a/apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts b/apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts index bc92a0a7817bd0..01138d3d665644 100644 --- a/apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts +++ b/apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts @@ -1,344 +1,391 @@ -import type { GetServerSidePropsContext } from "next"; +import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next"; import { z } from "zod"; +import { createDefaultInstallation } from "@calcom/app-store/_utils/installation"; import { filterEventTypesWhereLocationUpdateIsAllowed } from "@calcom/app-store/_utils/getBulkEventTypes"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import type { LocationObject } from "@calcom/app-store/locations"; import { isConferencing as isConferencingApp } from "@calcom/app-store/utils"; -import { getLocale } from "@calcom/features/auth/lib/getLocale"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; import { CAL_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import prisma from "@calcom/prisma"; -import type { Prisma } from "@calcom/prisma/client"; +import { Prisma } from "@calcom/prisma/client"; import { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; -import { STEPS } from "~/apps/installation/[[...step]]/constants"; -import type { OnboardingPageProps, TEventTypeGroup } from "~/apps/installation/[[...step]]/step-view"; +import { STEPS } from "../../../../modules/apps/installation/[[...step]]/constants"; +import type { OnboardingPageProps, TEventTypeGroup, TEventType } from "../../../../modules/apps/installation/[[...step]]/step-view"; -const getUser = async (userId: number) => { - const userRepo = new UserRepository(prisma); - const userAdminTeams = await userRepo.getUserAdminTeams({ userId }); +const eventTypeSelect: Prisma.EventTypeSelect = { + id: true, description: true, durationLimits: true, metadata: true, length: true, title: true, + position: true, recurringEvent: true, requiresConfirmation: true, canSendCalVideoTranscriptionEmails: 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, calVideoSettings: true, parentId: true, +}; - if (!userAdminTeams?.id) { - return null; - } +type EventTypeFromDb = Prisma.EventTypeGetPayload<{ + select: typeof eventTypeSelect; +}>; - const teams = userAdminTeams.teams.map(({ team }) => ({ - ...team, - logoUrl: team.parent - ? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name) - : getPlaceholderAvatar(team.logoUrl, team.name), - })); +type MinimalUser = { + id: number; + username: string | null; + name: string | null; + avatarUrl: string | null; +}; - return { - ...userAdminTeams, - teams, - }; +type MinimalTeam = { + id: number; + name: string; + logoUrl: string | null; + isOrganization: boolean; }; -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), - })); +type RedirectResult = { redirect: { permanent: boolean; destination: string } }; + +const mapEventType = (item: EventTypeFromDb): TEventType => { + let teamSlug = ""; + if (item.team) { + teamSlug = `team/${item.team.slug}`; + } + const userSlug = item?.users?.[0]?.username; + let urlPart = userSlug; + if (teamSlug) { + urlPart = teamSlug; + } + + return { + ...item, + URL: `${CAL_URL}/${urlPart}/${item.slug}`, + selected: false, + locations: item.locations as unknown as LocationObject[], + bookingFields: eventTypeBookingFields.parse(item.bookingFields || []), + }; }; -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 }, +const getUser = async (userId: number): Promise<(MinimalUser & { + teams: { id: number; name: string; logoUrl: string | null; isOrganization: boolean; parent: { id: number; name: string; logoUrl: string | null } | null }[]; +}) | null> => { + const userRepo = new UserRepository(prisma); + const userAdminTeams = await userRepo.getUserAdminTeams({ userId }); + + if (!userAdminTeams?.id) { + return null; + } + + const teams = userAdminTeams.teams.map(({ team }) => { + const parentLogoUrl = team.parent?.logoUrl; + const parentName = team.parent?.name; + + let logoUrl = ""; + if (team.parent) { + logoUrl = getPlaceholderAvatar(parentLogoUrl, parentName); + } else { + logoUrl = getPlaceholderAvatar(team.logoUrl, team.name); + } + return { + ...team, + logoUrl, + }; }); - return app; + + return { + ...userAdminTeams, + teams, + }; + }; + +const getOrgSubTeams = async (parentId: number): Promise<{ id: number; name: string; logoUrl: string | null; isOrganization: boolean; parent: { id: number; name: string; logoUrl: string | null } | null }[]> => { + 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) => { + const parentLogoUrl = team.parent?.logoUrl; + const parentName = team.parent?.name; + let logoUrl = ""; + if (team.parent) { + logoUrl = getPlaceholderAvatar(parentLogoUrl, parentName); + } else { + logoUrl = getPlaceholderAvatar(team.logoUrl, team.name); + } + return { + ...team, + logoUrl, + } + }); + }; + +const getAppBySlug = async (appSlug: string): Promise<{ slug: string; keys: Prisma.JsonValue; enabled: boolean; dirName: string; } | null> => { + const app = await prisma.app.findUnique({ + where: { slug: appSlug, enabled: true }, + select: { slug: true, keys: true, enabled: true, dirName: true }, + }); + return app; }; -const getEventTypes = async ({ - userId, - teamIds, - isConferencing = false, -}: { - userId: number; - teamIds?: number[]; - isConferencing?: boolean; -}) => { - const eventTypeSelect = { - id: true, - description: true, - durationLimits: true, - metadata: true, - length: true, - title: true, - position: true, - recurringEvent: true, - requiresConfirmation: true, - canSendCalVideoTranscriptionEmails: 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, - calVideoSettings: true, - parentId: true, - } satisfies Prisma.EventTypeSelect; - - let eventTypeGroups: TEventTypeGroup[] | null = []; - - if (teamIds && teamIds.length > 0) { +const getTeamEventTypes = async (teamIds: number[], isConferencing: boolean, eventTypeSelect: Prisma.EventTypeSelect): Promise => { const teams = await prisma.team.findMany({ - where: { - id: { - in: teamIds, - }, - isOrganization: false, - }, - select: { - id: true, - name: true, - logoUrl: true, - slug: true, - isOrganization: true, - eventTypes: { - select: eventTypeSelect, - }, - }, + where: { id: { in: teamIds }, isOrganization: false }, + select: { id: true, name: true, logoUrl: true, slug: true, isOrganization: true, eventTypes: { select: eventTypeSelect } }, }); - eventTypeGroups = teams.map((team) => { - const filteredEventTypes = isConferencing - ? filterEventTypesWhereLocationUpdateIsAllowed(team.eventTypes) - : team.eventTypes; - - return { - teamId: team.id, - slug: team.slug, - name: team.name, - isOrganisation: team.isOrganization, - image: getPlaceholderAvatar(team.logoUrl, team.name), - eventTypes: filteredEventTypes - .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 teams.map((team) => { + let filteredEventTypes = team.eventTypes; + if (isConferencing) { + filteredEventTypes = filterEventTypesWhereLocationUpdateIsAllowed(team.eventTypes); + } + return { + teamId: team.id, slug: team.slug, name: team.name, isOrganisation: team.isOrganization, + image: getPlaceholderAvatar(team.logoUrl, team.name), + eventTypes: (filteredEventTypes as unknown as EventTypeFromDb[]).map(mapEventType).sort((a, b) => (b.position || 0) - (a.position || 0)), + }; }); - } else { +}; + +const getUserEventTypes = async (userId: number, isConferencing: boolean, eventTypeSelect: Prisma.EventTypeSelect): Promise => { const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - select: { - id: true, - username: true, - name: true, - avatarUrl: true, - eventTypes: { - where: { - teamId: null, - }, - select: eventTypeSelect, - }, - }, + where: { id: userId }, + select: { id: true, username: true, name: true, avatarUrl: true, eventTypes: { where: { teamId: null }, select: eventTypeSelect } }, }); - if (user) { - const filteredEventTypes = isConferencing - ? filterEventTypesWhereLocationUpdateIsAllowed(user.eventTypes) - : user.eventTypes; - - eventTypeGroups.push({ - userId: user.id, - slug: user.username, - name: user.name, - image: getPlaceholderAvatar(user.avatarUrl, user.name), - eventTypes: filteredEventTypes - .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), - }); + if (!user) return []; + + let filteredEventTypes = user.eventTypes; + if (isConferencing) { + filteredEventTypes = filterEventTypesWhereLocationUpdateIsAllowed(user.eventTypes); } - } - return eventTypeGroups; + return [{ + userId: user.id, slug: user.username, name: user.name, image: getPlaceholderAvatar(user.avatarUrl, user.name), + eventTypes: (filteredEventTypes as unknown as EventTypeFromDb[]).map(mapEventType).sort((a, b) => (b.position || 0) - (a.position || 0)), + }]; }; -const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: number[]) => { - const appInstalls = await prisma.credential.findMany({ - where: { - OR: [ - { - appId: appSlug, - userId: userId, - }, - teamIds && Boolean(teamIds.length) - ? { - appId: appSlug, - teamId: { in: teamIds }, - } - : {}, - ], - }, - }); - return appInstalls; +const getEventTypes = async ({ + userId, + teamIds, + isConferencing = false, +}: { + userId: number; + teamIds?: number[]; + isConferencing?: boolean; +}): Promise => { + if (teamIds && teamIds.length > 0) { + return getTeamEventTypes(teamIds, isConferencing, eventTypeSelect); + } + + return getUserEventTypes(userId, isConferencing, eventTypeSelect); }; -export const getServerSideProps = async (context: GetServerSidePropsContext) => { - const { req, 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 }); - 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; - - const user = await getUser(session.user.id); - if (!user) return { redirect: { permanent: false, destination: "/apps" } }; - - let userTeams = user.teams; - const hasTeams = Boolean(userTeams.length); - - if (parsedTeamIdParam) { - const currentTeam = userTeams.find((team) => team.id === parsedTeamIdParam); - if (!currentTeam?.id) { - return { redirect: { permanent: false, destination: "/apps" } }; - } - if (currentTeam.isOrganization) { - const subTeams = await getOrgSubTeams(parsedTeamIdParam); - userTeams = [...userTeams, ...subTeams]; - isOrg = true; +const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: number[]): Promise>[]> => { + if (teamIds?.length) { + return prisma.credential.findMany({ + where: { + OR: [ + { appId: appSlug, userId: userId }, + { appId: appSlug, teamId: { in: teamIds } } + ] + } + }); } - } - - if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) { - if (!showEventTypesStep) { - return { - redirect: { - permanent: false, - destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`, - }, - }; + + return prisma.credential.findMany({ + where: { appId: appSlug, userId: userId }, + }); + }; + +const handleAutoInstall = async (user: MinimalUser, appMetadata: import("@calcom/types/App").AppMeta, parsedAppSlug: string): Promise => { + try { + const newCredential = await createDefaultInstallation({ + appType: appMetadata.type, + user: { id: user.id }, + slug: parsedAppSlug, + key: {}, + teamId: undefined, + }); + return newCredential.id; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + const existing = await prisma.credential.findFirst({ + where: { appId: parsedAppSlug, userId: user.id, teamId: null }, + select: { id: true }, + }); + return existing?.id ?? null; + } + return null; } +}; + +const getEventTypeGroupsForStep = async (user: MinimalUser, userTeams: MinimalTeam[], parsedTeamIdParam: number | undefined, isOrg: boolean, isConferencing: boolean): Promise => { + let groups: TEventTypeGroup[] | null = null; if (isOrg) { - const teamIds = userTeams.map((item) => item.id); - eventTypeGroups = await getEventTypes({ userId: user.id, teamIds, isConferencing }); + groups = await getEventTypes({ userId: user.id, teamIds: userTeams.map((item) => item.id), isConferencing }); } else if (parsedTeamIdParam) { - eventTypeGroups = await getEventTypes({ userId: user.id, teamIds: [parsedTeamIdParam], isConferencing }); + groups = await getEventTypes({ userId: user.id, teamIds: [parsedTeamIdParam], isConferencing }); } else { - eventTypeGroups = await getEventTypes({ userId: user.id, isConferencing }); + groups = await getEventTypes({ userId: user.id, isConferencing }); } - 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; + + if (isConferencing && groups) { + const destinationCalendar = await prisma.destinationCalendar.findFirst({ + where: { userId: user.id, eventTypeId: null }, + }); + + groups.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) => !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: { - 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: !!appMetadata?.concurrentMeetings || !isConferencing, - } as OnboardingPageProps, - }; + return groups; +}; + +const getInitialStep = (parsedStepParam: string | undefined, hasTeams: boolean, showEventTypesStep: boolean, parsedAppSlug: string): { step: string | undefined; redirect?: RedirectResult } => { + if (!hasTeams && parsedStepParam === AppOnboardingSteps.ACCOUNTS_STEP && showEventTypesStep) { + return { + step: parsedStepParam, + redirect: { redirect: { permanent: false, destination: `/apps/installation/event-types?slug=${parsedAppSlug}` } } + }; + } + let step = parsedStepParam; + if (!parsedStepParam) { + if (hasTeams) { + step = AppOnboardingSteps.ACCOUNTS_STEP; + } else { + step = AppOnboardingSteps.EVENT_TYPES_STEP; + } + } + return { step }; +}; + +const getAppAndMetadata = async (parsedAppSlug: string): Promise<{ app: { slug: string; keys: Prisma.JsonValue; enabled: boolean; dirName: string } | null; appMetadata: import("@calcom/types/App").AppMeta | null; redirect?: RedirectResult }> => { + const app = await getAppBySlug(parsedAppSlug); + if (!app) return { app: null, appMetadata: null, redirect: { redirect: { permanent: false, destination: "/apps" } } }; + const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata]; + if (!appMetadata) return { app: null, appMetadata: null, redirect: { redirect: { permanent: false, destination: "/apps" } } }; + return { app, appMetadata }; +}; + +const getCredentialId = (parsedTeamIdParam: number | undefined, appInstalls: Prisma.CredentialGetPayload>[], userId: number): number | null => { + if (parsedTeamIdParam) { + return appInstalls.find((item) => item.teamId === parsedTeamIdParam)?.id ?? null; + } + return appInstalls.find((item) => item.userId === userId)?.id ?? null; +}; + +const prepareUserTeams = async (user: MinimalUser & { teams: MinimalTeam[] }, parsedTeamIdParam: number | undefined): Promise<{ userTeams: MinimalTeam[]; isOrg: boolean; redirect?: RedirectResult }> => { + let userTeams = user.teams; + let isOrg = false; + if (parsedTeamIdParam) { + const currentTeam = userTeams.find((team: MinimalTeam) => team.id === parsedTeamIdParam); + if (!currentTeam?.id) return { userTeams: [], isOrg: false, redirect: { redirect: { permanent: false, destination: "/apps" } } }; + if (currentTeam.isOrganization) { + userTeams = [...userTeams, ...await getOrgSubTeams(parsedTeamIdParam)]; + isOrg = true; + } + } + return { userTeams, isOrg }; +}; + +const getCredential = async (parsedTeamIdParam: number | undefined, appInstalls: Prisma.CredentialGetPayload>[], user: MinimalUser & { teams: MinimalTeam[] }, initialStep: string, appMetadata: import("@calcom/types/App").AppMeta, parsedAppSlug: string): Promise<{ credentialId: number | null; redirect?: RedirectResult }> => { + let credentialId = getCredentialId(parsedTeamIdParam, appInstalls, user.id); + if (!credentialId && !user.teams.length && initialStep === AppOnboardingSteps.EVENT_TYPES_STEP) { + credentialId = await handleAutoInstall(user, appMetadata, parsedAppSlug); + if (!credentialId) return { credentialId: null, redirect: { redirect: { permanent: false, destination: "/apps" } } }; + } + return { credentialId }; +}; + +const getTeamsInstalled = (hasTeams: boolean, userTeams: MinimalTeam[], appInstalls: Prisma.CredentialGetPayload>[]): import("../../../../modules/apps/installation/[[...step]]/step-view").TTeams => { + if (!hasTeams) return []; + return userTeams.map((team: MinimalTeam) => ({ + ...team, alreadyInstalled: appInstalls.some((install) => !!install.teamId && install.teamId === team.id), + })); +}; + +const getParsedStep = (paramsStep: string | string[] | undefined): string | undefined => { + if (Array.isArray(paramsStep)) return paramsStep[0]; + return paramsStep; +}; + +const getInstallationParams = async (context: GetServerSidePropsContext): Promise<{ parsedAppSlug: string; parsedStepParam: string | undefined; parsedTeamIdParam: number | undefined; sessionId: number | null }> => { + const { query, params, req } = context; + const parsedAppSlug = z.coerce.string().parse(query?.slug); + const parsedStepParam = z.string().optional().parse(getParsedStep(params?.step)); + const parsedTeamIdParam = z.coerce.number().optional().parse(query?.teamId); + const session = await getServerSession({ req }); + const sessionId = (session?.user as { id?: number })?.id ?? null; + return { parsedAppSlug, parsedStepParam, parsedTeamIdParam, sessionId }; +}; + +export const getServerSideProps = async (context: GetServerSidePropsContext): Promise> => { + const { parsedAppSlug, parsedStepParam, parsedTeamIdParam, sessionId } = await getInstallationParams(context); + if (!sessionId) return { redirect: { permanent: false, destination: "/auth/login" } }; + + const { app, appMetadata, redirect: appRedirect } = await getAppAndMetadata(parsedAppSlug); + if (appRedirect) return appRedirect; + + const user = await getUser(sessionId); + if (!user?.username || !appMetadata) return { redirect: { permanent: false, destination: "/apps" } }; + const appMetadataFinal = appMetadata; + + const showEventTypesStep = appMetadataFinal.extendsFeature === "EventType" || isConferencingApp(appMetadataFinal.categories); + const { userTeams, isOrg, redirect: teamRedirect } = await prepareUserTeams(user, parsedTeamIdParam); + if (teamRedirect) return teamRedirect; + const { step: initialStep, redirect: stepRedirect } = getInitialStep(parsedStepParam, !!userTeams.length, showEventTypesStep, parsedAppSlug); + if (stepRedirect) return stepRedirect; + + let initialStepValidated: (typeof STEPS)[number]; + try { + initialStepValidated = z.enum(STEPS).parse(initialStep); + } catch { + return { redirect: { permanent: false, destination: "/apps" } }; + } + + const appInstalls = await getAppInstallsBySlug(parsedAppSlug, user.id, userTeams.map(({ id }: MinimalTeam) => id)); + const { credentialId, redirect: credRedirect } = await getCredential(parsedTeamIdParam, appInstalls, user, initialStepValidated, appMetadataFinal, parsedAppSlug); + if (credRedirect) return credRedirect; + + let eventTypeGroups = null; + if (initialStepValidated === AppOnboardingSteps.EVENT_TYPES_STEP) { + if (!showEventTypesStep) { + return { redirect: { permanent: false, destination: `/apps/installed/${appMetadataFinal.categories[0]}?hl=${appMetadataFinal.slug}` } }; + } + eventTypeGroups = await getEventTypeGroupsForStep(user, userTeams, parsedTeamIdParam, isOrg, isConferencingApp(appMetadataFinal.categories)); + if (!credentialId) return { redirect: { permanent: false, destination: "/apps" } }; + } + + const personalAccount = { + id: user.id, name: user.name, avatarUrl: user.avatarUrl, + alreadyInstalled: appInstalls.some((install) => !install.teamId && install.userId === user.id), + }; + const teams = getTeamsInstalled(!!user.teams.length, userTeams, appInstalls); + + return { + props: { + app, appMetadata: appMetadataFinal, showEventTypesStep, step: initialStepValidated, personalAccount, teams, + eventTypeGroups, userName: user.username, credentialId: credentialId || undefined, + isConferencing: isConferencingApp(appMetadataFinal.categories), isOrg, + installableOnTeams: !!appMetadataFinal?.concurrentMeetings || !isConferencingApp(appMetadataFinal.categories), + } 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 480cc7f57411c1..d9f594df2f4dbf 100644 --- a/apps/web/modules/apps/installation/[[...step]]/step-view.tsx +++ b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx @@ -102,7 +102,7 @@ type TUpdateObject = { const OnboardingPage = ({ step, - teams, + teams = [], personalAccount, appMetadata, eventTypeGroups, @@ -116,6 +116,8 @@ const OnboardingPage = ({ const pathname = usePathname(); const router = useRouter(); + const [configureStep, setConfigureStep] = useState(false); + const STEPS_MAP: StepObj = { [AppOnboardingSteps.ACCOUNTS_STEP]: { getTitle: () => `${t("select_account_header")}`, @@ -127,19 +129,18 @@ const OnboardingPage = ({ getTitle: () => `${t("select_event_types_header")}`, getDescription: (appName) => `${t("select_event_types_description", { appName, interpolation: { escapeValue: false } })}`, - stepNumber: installableOnTeams ? 2 : 1, + stepNumber: teams.length > 0 && installableOnTeams ? 2 : 1, }, [AppOnboardingSteps.CONFIGURE_STEP]: { getTitle: (appName) => `${t("configure_app_header", { appName, interpolation: { escapeValue: false } })}`, getDescription: () => `${t("configure_app_description")}`, - stepNumber: installableOnTeams ? 3 : 2, + stepNumber: teams.length > 0 && installableOnTeams ? 3 : 2, }, } as const; - const [configureStep, setConfigureStep] = useState(false); const currentStep: AppOnboardingSteps = useMemo(() => { - if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) { + if (step === AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) { return AppOnboardingSteps.CONFIGURE_STEP; } return step; @@ -150,8 +151,8 @@ const OnboardingPage = ({ if (!showEventTypesStep) { return 1; } - return installableOnTeams ? STEPS.length : STEPS.length - 1; - }, [showEventTypesStep, installableOnTeams]); + return teams.length > 0 && installableOnTeams ? STEPS.length : STEPS.length - 1; + }, [showEventTypesStep, teams, installableOnTeams]); const utils = trpc.useContext(); diff --git a/apps/web/playwright/fixtures/apps.ts b/apps/web/playwright/fixtures/apps.ts index e44f64909c0678..05127f978b21d2 100644 --- a/apps/web/playwright/fixtures/apps.ts +++ b/apps/web/playwright/fixtures/apps.ts @@ -18,20 +18,27 @@ export function createAppsFixture(page: Page) { installAnalyticsAppSkipConfigure: async (app: string) => { await page.getByTestId(`app-store-app-card-${app}`).click(); await page.getByTestId("install-app-button").click(); - await page.waitForURL(`apps/installation/accounts?slug=${app}`); + await page.waitForURL(`apps/installation/**?slug=${app}`); await page.reload(); - await page.click('[data-testid="install-app-button-personal"]'); - await page.waitForURL(`apps/installation/event-types?slug=${app}`); + + const currentUrl = page.url(); + if (currentUrl.includes("apps/installation/accounts")) { + await page.click('[data-testid="install-app-button-personal"]'); + await page.waitForURL(`apps/installation/event-types?slug=${app}`); + } await page.click('[data-testid="set-up-later"]'); }, installAnalyticsApp: async (app: string, eventTypeIds: number[]) => { await page.getByTestId(`app-store-app-card-${app}`).click(); - (await page.waitForSelector('[data-testid="install-app-button"]')).click(); - await page.waitForURL(`apps/installation/accounts?slug=${app}`); + await page.getByTestId("install-app-button").click(); + await page.waitForURL(`apps/installation/**?slug=${app}`); await page.reload(); - await page.click('[data-testid="install-app-button-personal"]'); - await page.waitForURL(`apps/installation/event-types?slug=${app}`); + const currentUrl = page.url(); + if (currentUrl.includes("apps/installation/accounts")) { + await page.click('[data-testid="install-app-button-personal"]'); + await page.waitForURL(`apps/installation/event-types?slug=${app}`); + } // eslint-disable-next-line playwright/no-wait-for-timeout await page.waitForTimeout(1000);