From d2f7a427d0e19b11fe281178ee2b9ba373e53ada Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Fri, 6 Sep 2024 11:40:19 -0400 Subject: [PATCH] chore: App-router-migration`(/apps`) fix metadata, extract components into `/module` and finish migration (#16354) * fix metadata for apps page * migrate apps route to modules * migrate apps/[slug] * migrate apps/[slug]/setup * migrate apps/categories/**/* * add apps/installed page to app router * migrate apps/[slug]/[...pages] * migrate apps/installed/**/* * Add apps/installation/[[...step]] to App router * fix imports * add missing use client directive * finish migration * fix metadata * fix metadata for apps/categories * fix type for installation/[[...step]] * fix type for apps/[slug]/[...pages] * remove unnecessary code * refactor * fix apps/installation * move getServerSideProps to lib * fix import of apps/installation * fix import for app slug page * refactor * fix installation page in app router * remove res in getServerSideProps * replace setHeader with NextResponse header setHeader * refactor installed pages --------- Co-authored-by: Keith Williams Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Peer Richelsen --- .../future/apps/[slug]/[...pages]/page.tsx | 172 +---- apps/web/app/future/apps/[slug]/page.tsx | 16 +- .../web/app/future/apps/[slug]/setup/page.tsx | 11 +- .../apps/categories/[category]/page.tsx | 8 +- apps/web/app/future/apps/categories/page.tsx | 7 +- .../apps/installation/[[...step]]/page.tsx | 25 + .../future/apps/installed/[category]/page.tsx | 9 +- apps/web/app/future/apps/installed/page.tsx | 7 + apps/web/app/future/apps/page.tsx | 16 +- .../apps/installation/ConfigureStepCard.tsx | 3 +- .../EventTypeAppSettingsWrapper.tsx | 3 +- .../EventTypeConferencingAppSettings.tsx | 3 +- .../apps/installation/EventTypesStepCard.tsx | 3 +- .../[slug]/[...pages]/getServerSideProps.ts | 135 ++++ .../[[...step]]/getServerSideProps.ts | 296 +++++++++ .../[category]/getServerSideProps.tsx | 35 + .../apps/[slug]/[...pages]/pages-view.tsx | 99 +++ .../modules/apps/[slug]/setup/setup-view.tsx | 38 ++ apps/web/modules/apps/[slug]/slug-view.tsx | 81 +++ apps/web/modules/apps/apps-view.tsx | 110 ++++ .../categories/[category]/category-view.tsx | 54 ++ .../apps/categories/categories-view.tsx | 53 ++ .../installation/[[...step]]/constants.ts | 7 + .../installation/[[...step]]/step-view.tsx | 306 +++++++++ .../[category]/installed-category-view.tsx | 162 +++++ apps/web/pages/apps/[slug]/[...pages].tsx | 180 +----- apps/web/pages/apps/[slug]/index.tsx | 82 +-- apps/web/pages/apps/[slug]/setup.tsx | 40 +- apps/web/pages/apps/categories/[category].tsx | 58 +- apps/web/pages/apps/categories/index.tsx | 55 +- apps/web/pages/apps/index.tsx | 113 +--- .../pages/apps/installation/[[...step]].tsx | 598 +----------------- apps/web/pages/apps/installed/[category].tsx | 162 +---- packages/lib/server/repository/app.ts | 6 +- 34 files changed, 1523 insertions(+), 1430 deletions(-) create mode 100644 apps/web/app/future/apps/installation/[[...step]]/page.tsx create mode 100644 apps/web/app/future/apps/installed/page.tsx create mode 100644 apps/web/lib/apps/[slug]/[...pages]/getServerSideProps.ts create mode 100644 apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts create mode 100644 apps/web/modules/apps/[slug]/[...pages]/pages-view.tsx create mode 100644 apps/web/modules/apps/[slug]/setup/setup-view.tsx create mode 100644 apps/web/modules/apps/[slug]/slug-view.tsx create mode 100644 apps/web/modules/apps/apps-view.tsx create mode 100644 apps/web/modules/apps/categories/[category]/category-view.tsx create mode 100644 apps/web/modules/apps/categories/categories-view.tsx create mode 100644 apps/web/modules/apps/installation/[[...step]]/constants.ts create mode 100644 apps/web/modules/apps/installation/[[...step]]/step-view.tsx create mode 100644 apps/web/modules/apps/installed/[category]/installed-category-view.tsx diff --git a/apps/web/app/future/apps/[slug]/[...pages]/page.tsx b/apps/web/app/future/apps/[slug]/[...pages]/page.tsx index 8370bbd36a..ad854995bd 100644 --- a/apps/web/app/future/apps/[slug]/[...pages]/page.tsx +++ b/apps/web/app/future/apps/[slug]/[...pages]/page.tsx @@ -1,46 +1,17 @@ -import LegacyPage, { getLayout } from "@pages/apps/[slug]/[...pages]"; -import type { PageProps, SearchParams } from "app/_types"; +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { SearchParams } from "app/_types"; import { _generateMetadata } from "app/_utils"; -import type { GetServerSidePropsContext } from "next"; +import { WithLayout } from "app/layoutHOC"; +import type { GetServerSidePropsResult } from "next"; import type { Params } from "next/dist/shared/lib/router/utils/route-matcher"; import { cookies, headers } from "next/headers"; -import { notFound, redirect } from "next/navigation"; +import { notFound } from "next/navigation"; import z from "zod"; -import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; -import RoutingFormsRoutingConfig, { - serverSidePropsConfig, -} from "@calcom/app-store/routing-forms/pages/app-routing.config"; -import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import prisma from "@calcom/prisma"; -import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps"; - -import type { AppProps } from "@lib/app-providers"; +import { getServerSideProps } from "@lib/apps/[slug]/[...pages]/getServerSideProps"; import { buildLegacyCtx } from "@lib/buildLegacyCtx"; -import PageWrapper from "@components/PageWrapperAppDir"; - -import { ssrInit } from "@server/lib/ssr"; - -type AppPageType = { - getServerSideProps: AppGetServerSideProps; - // A component than can accept any properties - // eslint-disable-next-line @typescript-eslint/no-explicit-any - default: ((props: any) => JSX.Element) & - Pick; -}; - -type Found = { - notFound: false; - Component: AppPageType["default"]; - getServerSideProps: AppPageType["getServerSideProps"]; -}; - -const AppsRouting = { - "routing-forms": RoutingFormsRoutingConfig, - typeform: TypeformRoutingConfig, -}; +import LegacyPage, { getLayout } from "~/apps/[slug]/[...pages]/pages-view"; const paramsSchema = z.object({ slug: z.string(), @@ -60,127 +31,22 @@ export const generateMetadata = async ({ return notFound(); } - const mainPage = p.data.pages[0]; - - if (mainPage === "forms") { - return await _generateMetadata( - () => `Forms`, - () => "" - ); - } - const legacyContext = buildLegacyCtx(headers(), cookies(), params, searchParams); - const { form } = await getPageProps(legacyContext); + const data = await getData(legacyContext); + const form = "form" in data ? (data.form as { name?: string; description?: string }) : null; + const formName = form?.name ?? "Forms"; + const formDescription = form?.description ?? ""; return await _generateMetadata( - () => `${form.name}`, - () => form.description + () => formName, + () => formDescription ); }; -function getRoute(appName: string, pages: string[]) { - const routingConfig = AppsRouting[appName as keyof typeof AppsRouting] as Record; +const getData = withAppDirSsr>(getServerSideProps); - if (!routingConfig) { - notFound(); - } - - const mainPage = pages[0]; - const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as AppPageType); - - const getServerSidePropsHandler = serverSidePropsConfig[mainPage]; - - if (!appPage) { - notFound(); - } - - return { - notFound: false, - Component: appPage.default, - ...appPage, - getServerSideProps: getServerSidePropsHandler, - } as Found; -} - -const getPageProps = async ({ params, query, req }: GetServerSidePropsContext) => { - const p = paramsSchema.safeParse(params); - - if (!p.success) { - return notFound(); - } - - const { slug: appName, pages } = p.data; - - const route = getRoute(appName, pages); - - if (route.notFound) { - return route; - } - - if (route.getServerSideProps) { - // TODO: Document somewhere that right now it is just a convention that filename should have appPages in it's name. - // appPages is actually hardcoded here and no matter the fileName the same variable would be used. - // We can write some validation logic later on that ensures that [...appPages].tsx file exists - params!.appPages = pages.slice(1); - - const ctx = { req, params, query }; - - const session = await getServerSession({ req }); - const user = session?.user; - const app = await getAppWithMetadata({ slug: appName }); - - if (!app) { - notFound(); - } - - const result = await route.getServerSideProps( - { - ...ctx, - params: { - ...ctx.params, - appPages: pages.slice(1), - }, - } as GetServerSidePropsContext<{ - slug: string; - pages: string[]; - appPages: string[]; - }>, - prisma, - user, - ssrInit - ); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - - if (result.notFound) { - notFound(); - } - - if (result.redirect) { - redirect(result.redirect.destination); - } - - return { - appName, - appUrl: app.simplePath || `/apps/${appName}`, - ...result.props, - }; - } else { - return { - appName, - }; - } -}; - -export default async function Page({ params, searchParams }: PageProps) { - const h = headers(); - const nonce = h.get("x-nonce") ?? undefined; - - const legacyContext = buildLegacyCtx(h, cookies(), params, searchParams); - const props = await getPageProps(legacyContext); - return ( - - - - ); -} +export default WithLayout({ + getLayout, + getData, + Page: LegacyPage, +}); diff --git a/apps/web/app/future/apps/[slug]/page.tsx b/apps/web/app/future/apps/[slug]/page.tsx index ee7681c0d6..a4c2d7dcc5 100644 --- a/apps/web/app/future/apps/[slug]/page.tsx +++ b/apps/web/app/future/apps/[slug]/page.tsx @@ -1,21 +1,21 @@ -import Page from "@pages/apps/[slug]/index"; import { Prisma } from "@prisma/client"; import { withAppDirSsg } from "app/WithAppDirSsg"; -import type { PageProps } from "app/_types"; +import type { PageProps as _PageProps } from "app/_types"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; -import type { InferGetStaticPropsType } from "next"; import { cookies, headers } from "next/headers"; -import prisma from "@calcom/prisma"; +import { AppRepository } from "@calcom/lib/server/repository/app"; import { getStaticProps } from "@lib/apps/[slug]/getStaticProps"; import { buildLegacyCtx } from "@lib/buildLegacyCtx"; -type Y = InferGetStaticPropsType; -const getData = withAppDirSsg(getStaticProps); +import type { PageProps } from "~/apps/[slug]/slug-view"; +import Page from "~/apps/[slug]/slug-view"; -export const generateMetadata = async ({ params, searchParams }: PageProps) => { +const getData = withAppDirSsg(getStaticProps); + +export const generateMetadata = async ({ params, searchParams }: _PageProps) => { const legacyContext = buildLegacyCtx(headers(), cookies(), params, searchParams); const res = await getData(legacyContext); @@ -27,7 +27,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => { export const generateStaticParams = async () => { try { - const appStore = await prisma.app.findMany({ select: { slug: true } }); + const appStore = await AppRepository.findAppStore(); return appStore.map(({ slug }) => ({ slug })); } catch (e: unknown) { if (e instanceof Prisma.PrismaClientInitializationError) { diff --git a/apps/web/app/future/apps/[slug]/setup/page.tsx b/apps/web/app/future/apps/[slug]/setup/page.tsx index 5dc4737df1..c10edd12a2 100644 --- a/apps/web/app/future/apps/[slug]/setup/page.tsx +++ b/apps/web/app/future/apps/[slug]/setup/page.tsx @@ -1,20 +1,19 @@ -import Page from "@pages/apps/[slug]/setup"; import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as _PageProps } from "app/_types"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; -import type { InferGetServerSidePropsType } from "next"; import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps"; -export const generateMetadata = async ({ params }: { params: Record }) => { +import Page, { type PageProps } from "~/apps/[slug]/setup/setup-view"; + +export const generateMetadata = async ({ params }: _PageProps) => { return await _generateMetadata( () => `${params.slug}`, () => "" ); }; -type T = InferGetServerSidePropsType; - -const getData = withAppDirSsr(getServerSideProps); +const getData = withAppDirSsr(getServerSideProps); export default WithLayout({ getLayout: null, Page, getData }); diff --git a/apps/web/app/future/apps/categories/[category]/page.tsx b/apps/web/app/future/apps/categories/[category]/page.tsx index 4901b10861..fb2d540387 100644 --- a/apps/web/app/future/apps/categories/[category]/page.tsx +++ b/apps/web/app/future/apps/categories/[category]/page.tsx @@ -1,18 +1,18 @@ -import CategoryPage, { type PageProps } from "@pages/apps/categories/[category]"; import { withAppDirSsg } from "app/WithAppDirSsg"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; -import { APP_NAME } from "@calcom/lib/constants"; import { AppCategories } from "@calcom/prisma/enums"; import { isPrismaAvailableCheck } from "@calcom/prisma/is-prisma-available-check"; import { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps"; +import CategoryPage, { type PageProps } from "~/apps/categories/[category]/category-view"; + export const generateMetadata = async () => { return await _generateMetadata( - () => `${APP_NAME}`, - () => "" + () => "Apps Store", + () => "Connecting people, technology and the workplace." ); }; diff --git a/apps/web/app/future/apps/categories/page.tsx b/apps/web/app/future/apps/categories/page.tsx index 79e6b63319..9a42edb7d9 100644 --- a/apps/web/app/future/apps/categories/page.tsx +++ b/apps/web/app/future/apps/categories/page.tsx @@ -1,14 +1,15 @@ -import Page from "@pages/apps/categories/index"; import { withAppDirSsr } from "app/WithAppDirSsr"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; import { getServerSideProps } from "@lib/apps/categories/getServerSideProps"; +import Page from "~/apps/categories/categories-view"; + export const generateMetadata = async () => { return await _generateMetadata( - () => `Categories`, - () => "" + () => "Apps Store", + () => "Connecting people, technology and the workplace." ); }; diff --git a/apps/web/app/future/apps/installation/[[...step]]/page.tsx b/apps/web/app/future/apps/installation/[[...step]]/page.tsx new file mode 100644 index 0000000000..650f732d9a --- /dev/null +++ b/apps/web/app/future/apps/installation/[[...step]]/page.tsx @@ -0,0 +1,25 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; +import { cookies, headers } from "next/headers"; + +import { getServerSideProps } from "@lib/apps/installation/[[...step]]/getServerSideProps"; +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import type { OnboardingPageProps } from "~/apps/installation/[[...step]]/step-view"; +import Page from "~/apps/installation/[[...step]]/step-view"; + +export const generateMetadata = async ({ params, searchParams }: PageProps) => { + const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams); + + const { appMetadata } = await getData(legacyCtx); + return await _generateMetadata( + (t) => `${t("install")} ${appMetadata?.name ?? ""}`, + () => "" + ); +}; + +const getData = withAppDirSsr(getServerSideProps); + +export default WithLayout({ getLayout: null, getData, Page }); diff --git a/apps/web/app/future/apps/installed/[category]/page.tsx b/apps/web/app/future/apps/installed/[category]/page.tsx index f67c1a588f..c20bbf0d32 100644 --- a/apps/web/app/future/apps/installed/[category]/page.tsx +++ b/apps/web/app/future/apps/installed/[category]/page.tsx @@ -1,17 +1,18 @@ -import Page from "@pages/apps/installed/[category]"; import { withAppDirSsr } from "app/WithAppDirSsr"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; -import { getServerSideProps } from "@lib/apps/installed/[category]/getServerSideProps"; +import { getServerSidePropsAppDir } from "@lib/apps/installed/[category]/getServerSideProps"; + +import Page from "~/apps/installed/[category]/installed-category-view"; export const generateMetadata = async () => { return await _generateMetadata( - (t) => `${t("installed_apps")}`, + (t) => t("installed_apps"), (t) => t("manage_your_connected_apps") ); }; -const getData = withAppDirSsr(getServerSideProps); +const getData = withAppDirSsr(getServerSidePropsAppDir); export default WithLayout({ getLayout: null, getData, Page }); diff --git a/apps/web/app/future/apps/installed/page.tsx b/apps/web/app/future/apps/installed/page.tsx new file mode 100644 index 0000000000..c602a66bd7 --- /dev/null +++ b/apps/web/app/future/apps/installed/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation"; + +const Page = () => { + redirect("/apps/installed/calendar"); +}; + +export default Page; diff --git a/apps/web/app/future/apps/page.tsx b/apps/web/app/future/apps/page.tsx index 4fdd8bea97..6334d61e4a 100644 --- a/apps/web/app/future/apps/page.tsx +++ b/apps/web/app/future/apps/page.tsx @@ -1,18 +1,20 @@ -import AppsPage from "@pages/apps"; import { withAppDirSsr } from "app/WithAppDirSsr"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; -import { getLayout } from "@calcom/features/MainLayoutAppDir"; -import { APP_NAME } from "@calcom/lib/constants"; - import { getServerSideProps } from "@lib/apps/getServerSideProps"; +import AppsPage, { LayoutWrapper } from "~/apps/apps-view"; + export const generateMetadata = async () => { return await _generateMetadata( - () => `Apps | ${APP_NAME}`, - () => "" + (t) => t("app_store"), + (t) => t("app_store_description") ); }; -export default WithLayout({ getLayout, getData: withAppDirSsr(getServerSideProps), Page: AppsPage }); +export default WithLayout({ + getLayout: LayoutWrapper, + getData: withAppDirSsr(getServerSideProps), + Page: AppsPage, +}); diff --git a/apps/web/components/apps/installation/ConfigureStepCard.tsx b/apps/web/components/apps/installation/ConfigureStepCard.tsx index ca61bd9e0b..f4815a28bb 100644 --- a/apps/web/components/apps/installation/ConfigureStepCard.tsx +++ b/apps/web/components/apps/installation/ConfigureStepCard.tsx @@ -1,5 +1,4 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]"; import type { Dispatch, SetStateAction } from "react"; import type { FC } from "react"; import React, { forwardRef, useEffect, useRef, useState } from "react"; @@ -18,6 +17,8 @@ import { Button, Form, Icon } from "@calcom/ui"; import EventTypeAppSettingsWrapper from "@components/apps/installation/EventTypeAppSettingsWrapper"; import EventTypeConferencingAppSettings from "@components/apps/installation/EventTypeConferencingAppSettings"; +import type { TEventType, TEventTypesForm } from "~/apps/installation/[[...step]]/step-view"; + export type TFormType = { id: number; metadata: z.infer; diff --git a/apps/web/components/apps/installation/EventTypeAppSettingsWrapper.tsx b/apps/web/components/apps/installation/EventTypeAppSettingsWrapper.tsx index c52eba4f8a..49b9015c38 100644 --- a/apps/web/components/apps/installation/EventTypeAppSettingsWrapper.tsx +++ b/apps/web/components/apps/installation/EventTypeAppSettingsWrapper.tsx @@ -1,4 +1,3 @@ -import type { TEventType } from "@pages/apps/installation/[[...step]]"; import { useEffect, type FC } from "react"; import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface"; @@ -7,6 +6,8 @@ import useAppsData from "@calcom/lib/hooks/useAppsData"; import type { ConfigureStepCardProps } from "@components/apps/installation/ConfigureStepCard"; +import type { TEventType } from "~/apps/installation/[[...step]]/step-view"; + type EventTypeAppSettingsWrapperProps = Pick< ConfigureStepCardProps, "slug" | "userName" | "categories" | "credentialId" diff --git a/apps/web/components/apps/installation/EventTypeConferencingAppSettings.tsx b/apps/web/components/apps/installation/EventTypeConferencingAppSettings.tsx index 0aebe42561..092f077e21 100644 --- a/apps/web/components/apps/installation/EventTypeConferencingAppSettings.tsx +++ b/apps/web/components/apps/installation/EventTypeConferencingAppSettings.tsx @@ -1,4 +1,3 @@ -import type { TEventType } from "@pages/apps/installation/[[...step]]"; import { useMemo } from "react"; import { useFormContext } from "react-hook-form"; import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form"; @@ -18,6 +17,8 @@ import { QueryCell } from "@lib/QueryCell"; import type { TFormType } from "@components/apps/installation/ConfigureStepCard"; +import type { TEventType } from "~/apps/installation/[[...step]]/step-view"; + const LocationsWrapper = ({ eventType, slug, diff --git a/apps/web/components/apps/installation/EventTypesStepCard.tsx b/apps/web/components/apps/installation/EventTypesStepCard.tsx index c1fbb1b3ab..309b9a6550 100644 --- a/apps/web/components/apps/installation/EventTypesStepCard.tsx +++ b/apps/web/components/apps/installation/EventTypesStepCard.tsx @@ -1,4 +1,3 @@ -import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]"; import type { Dispatch, SetStateAction } from "react"; import type { FC } from "react"; import React from "react"; @@ -8,6 +7,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { ScrollableArea, Badge, Button } from "@calcom/ui"; +import type { TEventType, TEventTypesForm } from "~/apps/installation/[[...step]]/step-view"; + type EventTypesCardProps = { userName: string; setConfigureStep: Dispatch>; diff --git a/apps/web/lib/apps/[slug]/[...pages]/getServerSideProps.ts b/apps/web/lib/apps/[slug]/[...pages]/getServerSideProps.ts new file mode 100644 index 0000000000..77591446c8 --- /dev/null +++ b/apps/web/lib/apps/[slug]/[...pages]/getServerSideProps.ts @@ -0,0 +1,135 @@ +import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next"; +import { z } from "zod"; + +import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; +import RoutingFormsRoutingConfig from "@calcom/app-store/routing-forms/pages/app-routing.config"; +import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import prisma from "@calcom/prisma"; +import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps"; + +import type { AppProps } from "@lib/app-providers"; + +import { ssrInit } from "@server/lib/ssr"; + +type AppPageType = { + getServerSideProps: AppGetServerSideProps; + // A component than can accept any properties + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default: ((props: any) => JSX.Element) & + Pick; +}; + +type Found = { + notFound: false; + Component: AppPageType["default"]; + getServerSideProps: AppPageType["getServerSideProps"]; +}; + +type NotFound = { + notFound: true; +}; + +// TODO: It is a candidate for apps.*.generated.* +const AppsRouting = { + "routing-forms": RoutingFormsRoutingConfig, + typeform: TypeformRoutingConfig, +}; + +function getRoute(appName: string, pages: string[]) { + const routingConfig = AppsRouting[appName as keyof typeof AppsRouting] as Record; + + if (!routingConfig) { + return { + notFound: true, + } as NotFound; + } + const mainPage = pages[0]; + const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as AppPageType); + if (!appPage) { + return { + notFound: true, + } as NotFound; + } + return { notFound: false, Component: appPage.default, ...appPage } as Found; +} + +const paramsSchema = z.object({ + slug: z.string(), + pages: z.array(z.string()), +}); + +export async function getServerSideProps( + context: GetServerSidePropsContext +): Promise> { + const { params, req } = context; + if (!params) { + return { + notFound: true, + }; + } + + const parsedParams = paramsSchema.safeParse(params); + if (!parsedParams.success) { + return { + notFound: true, + }; + } + + const appName = parsedParams.data.slug; + const pages = parsedParams.data.pages; + const route = getRoute(appName, pages); + + if (route.notFound) { + return { notFound: true }; + } + + if (route.getServerSideProps) { + // TODO: Document somewhere that right now it is just a convention that filename should have appPages in it's name. + // appPages is actually hardcoded here and no matter the fileName the same variable would be used. + // We can write some validation logic later on that ensures that [...appPages].tsx file exists + params.appPages = pages.slice(1); + const session = await getServerSession({ req }); + const user = session?.user; + const app = await getAppWithMetadata({ slug: appName }); + + if (!app) { + return { + notFound: true, + }; + } + + const result = await route.getServerSideProps( + context as GetServerSidePropsContext<{ + slug: string; + pages: string[]; + appPages: string[]; + }>, + prisma, + user, + ssrInit + ); + + if (result.notFound) { + return { notFound: true }; + } + + if (result.redirect) { + return { redirect: result.redirect }; + } + + return { + props: { + appName, + appUrl: app.simplePath || `/apps/${appName}`, + ...result.props, + }, + }; + } else { + return { + props: { + appName, + }, + }; + } +} diff --git a/apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts b/apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts new file mode 100644 index 0000000000..f6df20731b --- /dev/null +++ b/apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts @@ -0,0 +1,296 @@ +import type { GetServerSidePropsContext } from "next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { z } from "zod"; + +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { isConferencing as isConferencingApp } from "@calcom/app-store/utils"; +import type { LocationObject } from "@calcom/core/location"; +import { getLocale } from "@calcom/features/auth/lib/getLocale"; +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 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; + +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, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!user) { + throw new Error(ERROR_MESSAGES.userNotFound); + } + + const teams = user.teams.map(({ team }) => ({ + ...team, + logoUrl: team.parent + ? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name) + : getPlaceholderAvatar(team.logoUrl, team.name), + })); + return { + ...user, + teams, + }; +}; + +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({ + 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, + slug: true, + locations: true, + userId: true, + destinationCalendar: true, + bookingFields: true, + }, + /** + * 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; + }); + + if (eventTypes.length === 0) { + return []; + } + + 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 || []), + })); +}; + +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; +}; + +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 isConferencing = isConferencingApp(appMetadata.categories); + const showEventTypesStep = extendsEventType || isConferencing; + console.log("sshowEventTypesStephowEventTypesStep: ", showEventTypesStep); + + if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed); + + const user = await getUser(session.user.id); + + 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) { + 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" } }; + } + } +}; diff --git a/apps/web/lib/apps/installed/[category]/getServerSideProps.tsx b/apps/web/lib/apps/installed/[category]/getServerSideProps.tsx index 9be2a03ab1..109032a239 100644 --- a/apps/web/lib/apps/installed/[category]/getServerSideProps.tsx +++ b/apps/web/lib/apps/installed/[category]/getServerSideProps.tsx @@ -41,3 +41,38 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { }, }; } + +export async function getServerSidePropsAppDir(ctx: GetServerSidePropsContext) { + // get return-to cookie and redirect if needed + const { cookies } = ctx.req; + + const returnTo = cookies["return-to"]; + + if (cookies && returnTo) { + const NextResponse = await import("next/server").then((mod) => mod.NextResponse); + const response = NextResponse.next(); + response.headers.set("Set-Cookie", "return-to=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"); + const redirect = { + redirect: { + destination: `${returnTo}`, + permanent: false, + }, + } as const; + + return redirect; + } + + const params = querySchema.safeParse(ctx.params); + + if (!params.success) { + const notFound = { notFound: true } as const; + + return notFound; + } + + return { + props: { + category: params.data.category, + }, + }; +} diff --git a/apps/web/modules/apps/[slug]/[...pages]/pages-view.tsx b/apps/web/modules/apps/[slug]/[...pages]/pages-view.tsx new file mode 100644 index 0000000000..b6e8c455c6 --- /dev/null +++ b/apps/web/modules/apps/[slug]/[...pages]/pages-view.tsx @@ -0,0 +1,99 @@ +"use client"; + +import RoutingFormsRoutingConfig from "@calcom/app-store/routing-forms/pages/app-routing.config"; +import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config"; +import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; +import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps"; +import type { inferSSRProps } from "@calcom/types/inferSSRProps"; + +import type { AppProps } from "@lib/app-providers"; +import type { getServerSideProps } from "@lib/apps/[slug]/[...pages]/getServerSideProps"; + +type AppPageType = { + getServerSideProps: AppGetServerSideProps; + // A component than can accept any properties + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default: ((props: any) => JSX.Element) & + Pick; +}; + +type Found = { + notFound: false; + Component: AppPageType["default"]; + getServerSideProps: AppPageType["getServerSideProps"]; +}; + +type NotFound = { + notFound: true; +}; + +// TODO: It is a candidate for apps.*.generated.* +const AppsRouting = { + "routing-forms": RoutingFormsRoutingConfig, + typeform: TypeformRoutingConfig, +}; + +function getRoute(appName: string, pages: string[]) { + const routingConfig = AppsRouting[appName as keyof typeof AppsRouting] as Record; + + if (!routingConfig) { + return { + notFound: true, + } as NotFound; + } + const mainPage = pages[0]; + const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as AppPageType); + if (!appPage) { + return { + notFound: true, + } as NotFound; + } + return { notFound: false, Component: appPage.default, ...appPage } as Found; +} +export type PageProps = inferSSRProps; + +const AppPage: AppPageType["default"] = function AppPage(props: PageProps) { + const appName = props.appName; + const params = useParamsWithFallback(); + const pages = Array.isArray(params.pages) ? params.pages : params.pages?.split("/") ?? []; + const route = getRoute(appName, pages); + + const componentProps = { + ...props, + pages: pages.slice(1), + }; + + if (!route || route.notFound) { + throw new Error("Route can't be undefined"); + } + return ; +}; + +AppPage.isBookingPage = ({ router }) => { + const route = getRoute(router.query.slug as string, router.query.pages as string[]); + if (route.notFound) { + return false; + } + const isBookingPage = route.Component.isBookingPage; + if (typeof isBookingPage === "function") { + return isBookingPage({ router }); + } + + return !!isBookingPage; +}; + +export const getLayout: NonNullable<(typeof AppPage)["getLayout"]> = (page) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { slug, pages } = useParamsWithFallback(); + const route = getRoute(slug as string, pages as string[]); + + if (route.notFound) { + return null; + } + if (!route.Component.getLayout) { + return page; + } + return route.Component.getLayout(page); +}; + +export default AppPage; diff --git a/apps/web/modules/apps/[slug]/setup/setup-view.tsx b/apps/web/modules/apps/[slug]/setup/setup-view.tsx new file mode 100644 index 0000000000..921423ce79 --- /dev/null +++ b/apps/web/modules/apps/[slug]/setup/setup-view.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; + +import { AppSetupPage } from "@calcom/app-store/_pages/setup"; +import type { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps"; +import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; +import type { inferSSRProps } from "@calcom/types/inferSSRProps"; +import { HeadSeo } from "@calcom/ui"; + +export type PageProps = inferSSRProps; + +export default function SetupInformation(props: PageProps) { + const searchParams = useCompatSearchParams(); + const router = useRouter(); + const slug = searchParams?.get("slug") as string; + const { status } = useSession(); + + if (status === "loading") { + return
; + } + + if (status === "unauthenticated") { + const urlSearchParams = new URLSearchParams({ + callbackUrl: `/apps/${slug}/setup`, + }); + router.replace(`/auth/login?${urlSearchParams.toString()}`); + } + + return ( + <> + {/* So that the set up page does not get indexed by search engines */} + + + + ); +} diff --git a/apps/web/modules/apps/[slug]/slug-view.tsx b/apps/web/modules/apps/[slug]/slug-view.tsx new file mode 100644 index 0000000000..4161086d18 --- /dev/null +++ b/apps/web/modules/apps/[slug]/slug-view.tsx @@ -0,0 +1,81 @@ +"use client"; + +import MarkdownIt from "markdown-it"; +import type { InferGetStaticPropsType } from "next"; +import Link from "next/link"; + +import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { showToast } from "@calcom/ui"; + +import type { getStaticProps } from "@lib/apps/[slug]/getStaticProps"; +import useRouterQuery from "@lib/hooks/useRouterQuery"; + +import App from "@components/apps/App"; + +const md = new MarkdownIt("default", { html: true, breaks: true }); + +export type PageProps = InferGetStaticPropsType; + +function SingleAppPage(props: PageProps) { + const { error, setQuery: setError } = useRouterQuery("error"); + const { t } = useLocale(); + if (error === "account_already_linked") { + showToast(t(error), "error", { id: error }); + setError(undefined); + } + // If it's not production environment, it would be a better idea to inform that the App is disabled. + if (props.isAppDisabled) { + if (!IS_PRODUCTION) { + // TODO: Improve disabled App UI. This is just a placeholder. + return ( +
+ This App seems to be disabled. If you are an admin, you can enable this app from{" "} + + here + +
+ ); + } + + // Disabled App should give 404 any ways in production. + return null; + } + + const { source, data } = props; + return ( + +
+ + } + /> + ); +} + +export default SingleAppPage; diff --git a/apps/web/modules/apps/apps-view.tsx b/apps/web/modules/apps/apps-view.tsx new file mode 100644 index 0000000000..331e691049 --- /dev/null +++ b/apps/web/modules/apps/apps-view.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { ChangeEventHandler } from "react"; +import { useState } from "react"; + +import Shell from "@calcom/features/shell/Shell"; +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { inferSSRProps } from "@calcom/types/inferSSRProps"; +import type { HorizontalTabItemProps } from "@calcom/ui"; +import { + AllApps, + AppStoreCategories, + HorizontalTabs, + TextField, + PopularAppsSlider, + RecentAppsSlider, +} from "@calcom/ui"; +import { Icon } from "@calcom/ui"; + +import { type getServerSideProps } from "@lib/apps/getServerSideProps"; + +import AppsLayout from "@components/apps/layouts/AppsLayout"; + +const tabs: HorizontalTabItemProps[] = [ + { + name: "app_store", + href: "/apps", + }, + { + name: "installed_apps", + href: "/apps/installed", + }, +]; + +function AppsSearch({ + onChange, + className, +}: { + onChange: ChangeEventHandler; + className?: string; +}) { + const { t } = useLocale(); + return ( + } + addOnClassname="!border-muted" + containerClassName={classNames("focus:!ring-offset-0 m-1", className)} + type="search" + autoComplete="false" + onChange={onChange} + placeholder={t("search")} + /> + ); +} + +export type PageProps = inferSSRProps; + +export default function Apps({ categories, appStore, userAdminTeams }: PageProps) { + const { t } = useLocale(); + const [searchText, setSearchText] = useState(undefined); + + return ( + ( +
+
+ +
+
+ setSearchText(e.target.value)} /> +
+
+ )} + headerClassName="sm:hidden lg:block hidden" + emptyStore={!appStore.length}> +
+ {!searchText && ( + <> + + + + + )} + category.name)} + userAdminTeams={userAdminTeams} + /> +
+
+ ); +} + +export const LayoutWrapper = (page: React.ReactElement) => { + return ( + + {page} + + ); +}; diff --git a/apps/web/modules/apps/categories/[category]/category-view.tsx b/apps/web/modules/apps/categories/[category]/category-view.tsx new file mode 100644 index 0000000000..00654742c3 --- /dev/null +++ b/apps/web/modules/apps/categories/[category]/category-view.tsx @@ -0,0 +1,54 @@ +"use client"; + +import type { InferGetStaticPropsType } from "next"; +import Link from "next/link"; + +import Shell from "@calcom/features/shell/Shell"; +import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { AppCard, SkeletonText } from "@calcom/ui"; + +import type { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps"; + +export type PageProps = InferGetStaticPropsType; +export default function Apps({ apps }: PageProps) { + const searchParams = useCompatSearchParams(); + const { t, isLocaleReady } = useLocale(); + const category = searchParams?.get("category"); + + return ( + <> + + + {isLocaleReady ? t("app_store") : }{" "} + + {category && ( + +  /  + {t("category_apps", { category: category[0].toUpperCase() + category?.slice(1) })} + + )} + + }> +
+
+ {apps + ?.sort((a, b) => (b.installCount || 0) - (a.installCount || 0)) + .map((app) => { + return ; + })} +
+
+
+ + ); +} diff --git a/apps/web/modules/apps/categories/categories-view.tsx b/apps/web/modules/apps/categories/categories-view.tsx new file mode 100644 index 0000000000..11f9b9dcd5 --- /dev/null +++ b/apps/web/modules/apps/categories/categories-view.tsx @@ -0,0 +1,53 @@ +"use client"; + +import Link from "next/link"; + +import Shell from "@calcom/features/shell/Shell"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { inferSSRProps } from "@calcom/types/inferSSRProps"; +import { Icon, SkeletonText } from "@calcom/ui"; + +import type { getServerSideProps } from "@lib/apps/categories/getServerSideProps"; + +export type PageProps = inferSSRProps; + +export default function Apps({ categories }: PageProps) { + const { t, isLocaleReady } = useLocale(); + + return ( + +
+ + + {isLocaleReady ? t("app_store") : }{" "} + +
+
+
+ {categories.map((category) => ( + +
+

{category.name}

+

+ {t("number_apps", { count: category.count })}{" "} + +

+
+ + ))} +
+
+
+ ); +} diff --git a/apps/web/modules/apps/installation/[[...step]]/constants.ts b/apps/web/modules/apps/installation/[[...step]]/constants.ts new file mode 100644 index 0000000000..b95d8ebefd --- /dev/null +++ b/apps/web/modules/apps/installation/[[...step]]/constants.ts @@ -0,0 +1,7 @@ +import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; + +export const STEPS = [ + AppOnboardingSteps.ACCOUNTS_STEP, + AppOnboardingSteps.EVENT_TYPES_STEP, + AppOnboardingSteps.CONFIGURE_STEP, +] as const; diff --git a/apps/web/modules/apps/installation/[[...step]]/step-view.tsx b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx new file mode 100644 index 0000000000..e72cf8bbf8 --- /dev/null +++ b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx @@ -0,0 +1,306 @@ +"use client"; + +import Head from "next/head"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { Toaster } from "react-hot-toast"; +import type { z } from "zod"; + +import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps"; +import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; +import type { EventTypeAppSettingsComponentProps, EventTypeModel } from "@calcom/app-store/types"; +import type { LocationObject } from "@calcom/core/location"; +import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types"; +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 { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; +import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import { trpc } from "@calcom/trpc/react"; +import type { AppMeta } from "@calcom/types/App"; +import { Form, Steps, showToast } from "@calcom/ui"; + +import { HttpError } from "@lib/core/http/error"; + +import type { PersonalAccountProps, TeamsProp } 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"; +import { StepHeader } from "@components/apps/installation/StepHeader"; + +import { STEPS } from "~/apps/installation/[[...step]]/constants"; + +export type TEventType = EventTypeAppSettingsComponentProps["eventType"] & + Pick< + EventTypeModel, + "metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position" | "destinationCalendar" + > & { + selected: boolean; + locations: LocationFormValues["locations"]; + bookingFields?: LocationFormValues["bookingFields"]; + }; + +export type TEventTypesForm = { + eventTypes: TEventType[]; +}; + +type StepType = (typeof STEPS)[number]; + +type StepObj = Record< + StepType, + { + getTitle: (appName: string) => string; + getDescription: (appName: string) => string; + stepNumber: number; + } +>; + +export type OnboardingPageProps = { + appMetadata: AppMeta; + step: StepType; + teams?: TeamsProp; + personalAccount: PersonalAccountProps; + eventTypes?: TEventType[]; + userName: string; + credentialId?: number; + showEventTypesStep: boolean; + isConferencing: boolean; + installableOnTeams: boolean; +}; + +type TUpdateObject = { + id: number; + metadata?: z.infer; + bookingFields?: z.infer; + locations?: LocationObject[]; +}; + +const OnboardingPage = ({ + step, + teams, + personalAccount, + appMetadata, + eventTypes, + userName, + credentialId, + showEventTypesStep, + isConferencing, + installableOnTeams, +}: OnboardingPageProps) => { + const { t } = useLocale(); + const pathname = usePathname(); + const router = useRouter(); + + const STEPS_MAP: StepObj = { + [AppOnboardingSteps.ACCOUNTS_STEP]: { + getTitle: () => `${t("select_account_header")}`, + getDescription: (appName) => `${t("select_account_description", { appName })}`, + stepNumber: 1, + }, + [AppOnboardingSteps.EVENT_TYPES_STEP]: { + getTitle: () => `${t("select_event_types_header")}`, + getDescription: (appName) => `${t("select_event_types_description", { appName })}`, + stepNumber: installableOnTeams ? 2 : 1, + }, + [AppOnboardingSteps.CONFIGURE_STEP]: { + getTitle: (appName) => `${t("configure_app_header", { appName })}`, + getDescription: () => `${t("configure_app_description")}`, + stepNumber: installableOnTeams ? 3 : 2, + }, + } as const; + const [configureStep, setConfigureStep] = useState(false); + + const currentStep: AppOnboardingSteps = useMemo(() => { + if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) { + return AppOnboardingSteps.CONFIGURE_STEP; + } + return step; + }, [step, configureStep]); + const stepObj = STEPS_MAP[currentStep]; + + const maxSteps = useMemo(() => { + if (!showEventTypesStep) { + return 1; + } + return installableOnTeams ? STEPS.length : STEPS.length - 1; + }, [showEventTypesStep, installableOnTeams]); + + const utils = trpc.useContext(); + + const formPortalRef = useRef(null); + + const formMethods = useForm({ + defaultValues: { + eventTypes, + }, + }); + const mutation = useAddAppMutation(null, { + onSuccess: (data) => { + if (data?.setupPending) return; + showToast(t("app_successfully_installed"), "success"); + }, + onError: (error) => { + if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); + }, + }); + + useEffect(() => { + eventTypes && formMethods.setValue("eventTypes", eventTypes); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventTypes]); + + const updateMutation = trpc.viewer.eventTypes.update.useMutation({ + onSuccess: async (data) => { + showToast(t("event_type_updated_successfully", { eventTypeTitle: data.eventType?.title }), "success"); + }, + async onSettled() { + await utils.viewer.eventTypes.get.invalidate(); + }, + onError: (err) => { + let message = ""; + if (err instanceof HttpError) { + const message = `${err.statusCode}: ${err.message}`; + showToast(message, "error"); + } + + if (err.data?.code === "UNAUTHORIZED") { + message = `${err.data.code}: ${t("error_event_type_unauthorized_update")}`; + } + + if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") { + message = `${err.data.code}: ${t(err.message)}`; + } + + if (err.data?.code === "INTERNAL_SERVER_ERROR") { + message = t("unexpected_error_try_again"); + } + + showToast(message ? t(message) : t(err.message), "error"); + }, + }); + + const handleSelectAccount = async (teamId?: number) => { + mutation.mutate({ + type: appMetadata.type, + variant: appMetadata.variant, + slug: appMetadata.slug, + ...(teamId && { teamId }), + // for oAuth apps + ...(showEventTypesStep && { + returnTo: + WEBAPP_URL + + getAppOnboardingUrl({ + slug: appMetadata.slug, + teamId, + step: AppOnboardingSteps.EVENT_TYPES_STEP, + }), + }), + }); + }; + + const handleSetUpLater = () => { + router.push(`/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`); + }; + + return ( +
+ + + {t("install")} {appMetadata?.name ?? ""} + + + +
+
+
+
{ + 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, + }; + } + + return updateMutation.mutateAsync(updateObject); + }); + try { + await Promise.all(mutationPromises); + router.push("/event-types"); + } catch (err) { + console.error(err); + } + }}> + + + + {currentStep === AppOnboardingSteps.ACCOUNTS_STEP && ( + + )} + {currentStep === AppOnboardingSteps.EVENT_TYPES_STEP && + eventTypes && + Boolean(eventTypes?.length) && ( + + )} + {currentStep === AppOnboardingSteps.CONFIGURE_STEP && formPortalRef.current && ( + + )} + +
+
+
+ +
+ ); +}; + +export default OnboardingPage; diff --git a/apps/web/modules/apps/installed/[category]/installed-category-view.tsx b/apps/web/modules/apps/installed/[category]/installed-category-view.tsx new file mode 100644 index 0000000000..3e9a15293b --- /dev/null +++ b/apps/web/modules/apps/installed/[category]/installed-category-view.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useReducer } from "react"; + +import getAppCategoryTitle from "@calcom/app-store/_utils/getAppCategoryTitle"; +import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal"; +import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { AppCategories } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; +import type { inferSSRProps } from "@calcom/types/inferSSRProps"; +import type { Icon } from "@calcom/ui"; +import { AppSkeletonLoader as SkeletonLoader, Button, EmptyScreen, ShellSubHeading } from "@calcom/ui"; + +import { QueryCell } from "@lib/QueryCell"; +import type { querySchemaType, getServerSideProps } from "@lib/apps/installed/[category]/getServerSideProps"; + +import { AppList } from "@components/apps/AppList"; +import { CalendarListContainer } from "@components/apps/CalendarListContainer"; +import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout"; + +interface IntegrationsContainerProps { + variant?: AppCategories; + exclude?: AppCategories[]; + handleDisconnect: (credentialId: number) => void; +} + +const IntegrationsContainer = ({ + variant, + exclude, + handleDisconnect, +}: IntegrationsContainerProps): JSX.Element => { + const { t } = useLocale(); + const query = trpc.viewer.integrations.useQuery({ + variant, + exclude, + onlyInstalled: true, + includeTeamInstalledApps: true, + }); + + // TODO: Refactor and reuse getAppCategories? + const emptyIcon: Record["name"]> = { + calendar: "calendar", + conferencing: "video", + automation: "share-2", + analytics: "chart-bar", + payment: "credit-card", + other: "grid-3x3", + web3: "credit-card", // deprecated + video: "video", // deprecated + messaging: "mail", + crm: "contact", + }; + + return ( + } + success={({ data }) => { + if (!data.items.length) { + const emptyHeaderCategory = getAppCategoryTitle(variant || "other", true); + + return ( + + {t(`connect_${variant || "other"}_apps`)} + + } + /> + ); + } + return ( +
+ + {t("add")} + + } + /> + + +
+ ); + }} + /> + ); +}; + +type ModalState = { + isOpen: boolean; + credentialId: null | number; + teamId?: number; +}; + +export type PageProps = inferSSRProps; + +export default function InstalledApps(props: PageProps) { + const searchParams = useCompatSearchParams(); + const { t } = useLocale(); + const category = searchParams?.get("category") as querySchemaType["category"]; + const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => { + // Exclude calendar and other from categoryList, we handle those slightly differently below + return !(category in { other: null, calendar: null }); + }); + + const [data, updateData] = useReducer( + (data: ModalState, partialData: Partial) => ({ ...data, ...partialData }), + { + isOpen: false, + credentialId: null, + } + ); + + const handleModelClose = () => { + updateData({ isOpen: false, credentialId: null }); + }; + + const handleDisconnect = (credentialId: number, teamId?: number) => { + updateData({ isOpen: true, credentialId, teamId }); + }; + + return ( + <> + + {categoryList.includes(category) && ( + + )} + {category === "calendar" && } + {category === "other" && ( + + )} + + + + ); +} diff --git a/apps/web/pages/apps/[slug]/[...pages].tsx b/apps/web/pages/apps/[slug]/[...pages].tsx index 82aab0a669..6de4cdfd96 100644 --- a/apps/web/pages/apps/[slug]/[...pages].tsx +++ b/apps/web/pages/apps/[slug]/[...pages].tsx @@ -1,179 +1,15 @@ -"use client"; - -import type { GetServerSidePropsContext } from "next"; - -import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; -import RoutingFormsRoutingConfig from "@calcom/app-store/routing-forms/pages/app-routing.config"; -import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; -import prisma from "@calcom/prisma"; -import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps"; - -import type { AppProps } from "@lib/app-providers"; +import { getServerSideProps } from "@lib/apps/[slug]/[...pages]/getServerSideProps"; import PageWrapper from "@components/PageWrapper"; -import { ssrInit } from "@server/lib/ssr"; +import type { PageProps } from "~/apps/[slug]/[...pages]/pages-view"; +import PagesView, { getLayout } from "~/apps/[slug]/[...pages]/pages-view"; -type AppPageType = { - getServerSideProps: AppGetServerSideProps; - // A component than can accept any properties - // eslint-disable-next-line @typescript-eslint/no-explicit-any - default: ((props: any) => JSX.Element) & - Pick; -}; +const Page = (props: PageProps) => ; -type Found = { - notFound: false; - Component: AppPageType["default"]; - getServerSideProps: AppPageType["getServerSideProps"]; -}; +Page.PageWrapper = PageWrapper; +Page.getLayout = getLayout; -type NotFound = { - notFound: true; -}; +export { getServerSideProps }; -// TODO: It is a candidate for apps.*.generated.* -const AppsRouting = { - "routing-forms": RoutingFormsRoutingConfig, - typeform: TypeformRoutingConfig, -}; - -function getRoute(appName: string, pages: string[]) { - const routingConfig = AppsRouting[appName as keyof typeof AppsRouting] as Record; - - if (!routingConfig) { - return { - notFound: true, - } as NotFound; - } - const mainPage = pages[0]; - const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as AppPageType); - if (!appPage) { - return { - notFound: true, - } as NotFound; - } - return { notFound: false, Component: appPage.default, ...appPage } as Found; -} - -const AppPage: AppPageType["default"] = function AppPage(props) { - const appName = props.appName; - const params = useParamsWithFallback(); - const pages = Array.isArray(params.pages) ? params.pages : params.pages?.split("/") ?? []; - const route = getRoute(appName, pages); - - const componentProps = { - ...props, - pages: pages.slice(1), - }; - - if (!route || route.notFound) { - throw new Error("Route can't be undefined"); - } - return ; -}; - -AppPage.isBookingPage = ({ router }) => { - const route = getRoute(router.query.slug as string, router.query.pages as string[]); - if (route.notFound) { - return false; - } - const isBookingPage = route.Component.isBookingPage; - if (typeof isBookingPage === "function") { - return isBookingPage({ router }); - } - - return !!isBookingPage; -}; - -export const getLayout: NonNullable<(typeof AppPage)["getLayout"]> = (page) => { - // eslint-disable-next-line react-hooks/rules-of-hooks - const { slug, pages } = useParamsWithFallback(); - const route = getRoute(slug as string, pages as string[]); - - if (route.notFound) { - return null; - } - if (!route.Component.getLayout) { - return page; - } - return route.Component.getLayout(page); -}; - -AppPage.PageWrapper = PageWrapper; -AppPage.getLayout = getLayout; - -export default AppPage; - -export async function getServerSideProps( - context: GetServerSidePropsContext<{ - slug: string; - pages: string[]; - appPages?: string[]; - }> -) { - const { params, req, res } = context; - if (!params) { - return { - notFound: true, - }; - } - const appName = params.slug; - const pages = params.pages; - const route = getRoute(appName, pages); - if (route.notFound) { - return route; - } - if (route.getServerSideProps) { - // TODO: Document somewhere that right now it is just a convention that filename should have appPages in it's name. - // appPages is actually hardcoded here and no matter the fileName the same variable would be used. - // We can write some validation logic later on that ensures that [...appPages].tsx file exists - params.appPages = pages.slice(1); - const session = await getServerSession({ req, res }); - const user = session?.user; - const app = await getAppWithMetadata({ slug: appName }); - if (!app) { - return { - notFound: true, - }; - } - - const result = await route.getServerSideProps( - context as GetServerSidePropsContext<{ - slug: string; - pages: string[]; - appPages: string[]; - }>, - prisma, - user, - ssrInit - ); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - if (result.notFound) { - return { - notFound: true, - }; - } - if (result.redirect) { - return { - redirect: result.redirect, - }; - } - return { - props: { - appName, - appUrl: app.simplePath || `/apps/${appName}`, - ...result.props, - }, - }; - } else { - return { - props: { - appName, - }, - }; - } -} +export default Page; diff --git a/apps/web/pages/apps/[slug]/index.tsx b/apps/web/pages/apps/[slug]/index.tsx index 8eee52c3f5..b3419ceb9b 100644 --- a/apps/web/pages/apps/[slug]/index.tsx +++ b/apps/web/pages/apps/[slug]/index.tsx @@ -1,90 +1,22 @@ -"use client"; - import { Prisma } from "@prisma/client"; -import MarkdownIt from "markdown-it"; import type { GetStaticPaths } from "next"; -import Link from "next/link"; -import { IS_PRODUCTION } from "@calcom/lib/constants"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import prisma from "@calcom/prisma"; -import { showToast } from "@calcom/ui"; +import { AppRepository } from "@calcom/lib/server/repository/app"; import { getStaticProps } from "@lib/apps/[slug]/getStaticProps"; -import useRouterQuery from "@lib/hooks/useRouterQuery"; -import type { inferSSRProps } from "@lib/types/inferSSRProps"; import PageWrapper from "@components/PageWrapper"; -import App from "@components/apps/App"; -const md = new MarkdownIt("default", { html: true, breaks: true }); +import type { PageProps } from "~/apps/[slug]/slug-view"; +import SingleAppPage from "~/apps/[slug]/slug-view"; -function SingleAppPage(props: inferSSRProps) { - const { error, setQuery: setError } = useRouterQuery("error"); - const { t } = useLocale(); - if (error === "account_already_linked") { - showToast(t(error), "error", { id: error }); - setError(undefined); - } - // If it's not production environment, it would be a better idea to inform that the App is disabled. - if (props.isAppDisabled) { - if (!IS_PRODUCTION) { - // TODO: Improve disabled App UI. This is just a placeholder. - return ( -
- This App seems to be disabled. If you are an admin, you can enable this app from{" "} - - here - -
- ); - } - - // Disabled App should give 404 any ways in production. - return null; - } - - const { source, data } = props; - return ( - -
- - } - /> - ); -} +const Page = (props: PageProps) => ; export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => { let paths: { params: { slug: string } }[] = []; try { - const appStore = await prisma.app.findMany({ select: { slug: true } }); + const appStore = await AppRepository.findAppStore(); paths = appStore.map(({ slug }) => ({ params: { slug } })); } catch (e: unknown) { if (e instanceof Prisma.PrismaClientInitializationError) { @@ -102,6 +34,6 @@ export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => { export { getStaticProps }; -SingleAppPage.PageWrapper = PageWrapper; +Page.PageWrapper = PageWrapper; -export default SingleAppPage; +export default Page; diff --git a/apps/web/pages/apps/[slug]/setup.tsx b/apps/web/pages/apps/[slug]/setup.tsx index 9dd65d9faf..625040aa7a 100644 --- a/apps/web/pages/apps/[slug]/setup.tsx +++ b/apps/web/pages/apps/[slug]/setup.tsx @@ -1,42 +1,14 @@ -"use client"; - -import type { InferGetServerSidePropsType } from "next"; -import { useSession } from "next-auth/react"; -import { useRouter } from "next/navigation"; - -import { AppSetupPage } from "@calcom/app-store/_pages/setup"; import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps"; -import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; -import { HeadSeo } from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; -export default function SetupInformation(props: InferGetServerSidePropsType) { - const searchParams = useCompatSearchParams(); - const router = useRouter(); - const slug = searchParams?.get("slug") as string; - const { status } = useSession(); +import type { PageProps } from "~/apps/[slug]/setup/setup-view"; +import SetupView from "~/apps/[slug]/setup/setup-view"; - if (status === "loading") { - return
; - } +const Page = (props: PageProps) => ; - if (status === "unauthenticated") { - const urlSearchParams = new URLSearchParams({ - callbackUrl: `/apps/${slug}/setup`, - }); - router.replace(`/auth/login?${urlSearchParams.toString()}`); - } - - return ( - <> - {/* So that the set up page does not get indexed by search engines */} - - - - ); -} - -SetupInformation.PageWrapper = PageWrapper; +Page.PageWrapper = PageWrapper; export { getServerSideProps }; + +export default Page; diff --git a/apps/web/pages/apps/categories/[category].tsx b/apps/web/pages/apps/categories/[category].tsx index 36b49a2bcd..7c1a1a39d6 100644 --- a/apps/web/pages/apps/categories/[category].tsx +++ b/apps/web/pages/apps/categories/[category].tsx @@ -1,63 +1,15 @@ -"use client"; - -import type { InferGetStaticPropsType } from "next"; -import Link from "next/link"; - -import Shell from "@calcom/features/shell/Shell"; -import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; import { AppCategories } from "@calcom/prisma/enums"; import { isPrismaAvailableCheck } from "@calcom/prisma/is-prisma-available-check"; -import { AppCard, SkeletonText } from "@calcom/ui"; import { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps"; import PageWrapper from "@components/PageWrapper"; -export type PageProps = InferGetStaticPropsType; -export default function Apps({ apps }: PageProps) { - const searchParams = useCompatSearchParams(); - const { t, isLocaleReady } = useLocale(); - const category = searchParams?.get("category"); +import type { PageProps } from "~/apps/categories/[category]/category-view"; +import CategoryView from "~/apps/categories/[category]/category-view"; - return ( - <> - - - {isLocaleReady ? t("app_store") : }{" "} - - {category && ( - -  /  - {t("category_apps", { category: category[0].toUpperCase() + category?.slice(1) })} - - )} - - }> -
-
- {apps - ?.sort((a, b) => (b.installCount || 0) - (a.installCount || 0)) - .map((app) => { - return ; - })} -
-
-
- - ); -} - -Apps.PageWrapper = PageWrapper; +const Page = (props: PageProps) => ; +Page.PageWrapper = PageWrapper; export const getStaticPaths = async () => { const paths = Object.keys(AppCategories); @@ -76,4 +28,6 @@ export const getStaticPaths = async () => { }; }; +export default Page; + export { getStaticProps }; diff --git a/apps/web/pages/apps/categories/index.tsx b/apps/web/pages/apps/categories/index.tsx index b3a065c6c3..54c738a5c9 100644 --- a/apps/web/pages/apps/categories/index.tsx +++ b/apps/web/pages/apps/categories/index.tsx @@ -1,57 +1,12 @@ -"use client"; - -import Link from "next/link"; - -import Shell from "@calcom/features/shell/Shell"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { inferSSRProps } from "@calcom/types/inferSSRProps"; -import { Icon, SkeletonText } from "@calcom/ui"; - import { getServerSideProps } from "@lib/apps/categories/getServerSideProps"; import PageWrapper from "@components/PageWrapper"; -export default function Apps({ categories }: Omit, "trpcState">) { - const { t, isLocaleReady } = useLocale(); +import type { PageProps } from "~/apps/categories/categories-view"; +import Apps from "~/apps/categories/categories-view"; - return ( - -
- - - {isLocaleReady ? t("app_store") : }{" "} - -
-
-
- {categories.map((category) => ( - -
-

{category.name}

-

- {t("number_apps", { count: category.count })}{" "} - -

-
- - ))} -
-
-
- ); -} - -Apps.PageWrapper = PageWrapper; +const Page = (props: PageProps) => ; +Page.PageWrapper = PageWrapper; +export default Page; export { getServerSideProps }; diff --git a/apps/web/pages/apps/index.tsx b/apps/web/pages/apps/index.tsx index 46188fab96..60dfa237b9 100644 --- a/apps/web/pages/apps/index.tsx +++ b/apps/web/pages/apps/index.tsx @@ -1,116 +1,15 @@ -"use client"; - -import type { ChangeEventHandler } from "react"; -import { useState } from "react"; - -import Shell from "@calcom/features/shell/Shell"; -import { classNames } from "@calcom/lib"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { inferSSRProps } from "@calcom/types/inferSSRProps"; -import type { HorizontalTabItemProps } from "@calcom/ui"; -import { - AllApps, - AppStoreCategories, - HorizontalTabs, - TextField, - PopularAppsSlider, - RecentAppsSlider, -} from "@calcom/ui"; -import { Icon } from "@calcom/ui"; - import { getServerSideProps } from "@lib/apps/getServerSideProps"; import PageWrapper from "@components/PageWrapper"; -import AppsLayout from "@components/apps/layouts/AppsLayout"; -const tabs: HorizontalTabItemProps[] = [ - { - name: "app_store", - href: "/apps", - }, - { - name: "installed_apps", - href: "/apps/installed", - }, -]; +import type { PageProps } from "~/apps/apps-view"; +import Apps, { LayoutWrapper } from "~/apps/apps-view"; -function AppsSearch({ - onChange, - className, -}: { - onChange: ChangeEventHandler; - className?: string; -}) { - const { t } = useLocale(); - return ( - } - addOnClassname="!border-muted" - containerClassName={classNames("focus:!ring-offset-0 m-1", className)} - type="search" - autoComplete="false" - onChange={onChange} - placeholder={t("search")} - /> - ); -} +const Page = (props: PageProps) => ; -export default function Apps({ - categories, - appStore, - userAdminTeams, -}: Omit, "trpcState">) { - const { t } = useLocale(); - const [searchText, setSearchText] = useState(undefined); - - return ( - ( -
-
- -
-
- setSearchText(e.target.value)} /> -
-
- )} - headerClassName="sm:hidden lg:block hidden" - emptyStore={!appStore.length}> -
- {!searchText && ( - <> - - - - - )} - category.name)} - userAdminTeams={userAdminTeams} - /> -
-
- ); -} +Page.PageWrapper = PageWrapper; +Page.getLayout = LayoutWrapper; export { getServerSideProps }; -Apps.PageWrapper = PageWrapper; -Apps.getLayout = (page: React.ReactElement) => { - return ( - - {page} - - ); -}; +export default Page; diff --git a/apps/web/pages/apps/installation/[[...step]].tsx b/apps/web/pages/apps/installation/[[...step]].tsx index bcb307a9eb..d50152f1be 100644 --- a/apps/web/pages/apps/installation/[[...step]].tsx +++ b/apps/web/pages/apps/installation/[[...step]].tsx @@ -1,598 +1,12 @@ -import type { GetServerSidePropsContext } from "next"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import Head from "next/head"; -import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { Toaster } from "react-hot-toast"; -import { z } from "zod"; - -import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps"; -import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; -import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; -import type { EventTypeAppSettingsComponentProps, EventTypeModel } from "@calcom/app-store/types"; -import { isConferencing as isConferencingApp } from "@calcom/app-store/utils"; -import type { LocationObject } from "@calcom/core/location"; -import { getLocale } from "@calcom/features/auth/lib/getLocale"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types"; -import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; -import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; -import { WEBAPP_URL } from "@calcom/lib/constants"; -import { CAL_URL } from "@calcom/lib/constants"; -import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import prisma from "@calcom/prisma"; -import { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; -import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; -import { trpc } from "@calcom/trpc/react"; -import type { AppMeta } from "@calcom/types/App"; -import { Form, Steps, showToast } from "@calcom/ui"; - -import { HttpError } from "@lib/core/http/error"; - import PageWrapper from "@components/PageWrapper"; -import type { PersonalAccountProps, TeamsProp } 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"; -import { StepHeader } from "@components/apps/installation/StepHeader"; -export type TEventType = EventTypeAppSettingsComponentProps["eventType"] & - Pick< - EventTypeModel, - "metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position" | "destinationCalendar" - > & { - selected: boolean; - locations: LocationFormValues["locations"]; - bookingFields?: LocationFormValues["bookingFields"]; - }; +import type { OnboardingPageProps } from "~/apps/installation/[[...step]]/step-view"; +import StepView from "~/apps/installation/[[...step]]/step-view"; -export type TEventTypesForm = { - eventTypes: TEventType[]; -}; +const Page = (props: OnboardingPageProps) => ; -const STEPS = [ - AppOnboardingSteps.ACCOUNTS_STEP, - AppOnboardingSteps.EVENT_TYPES_STEP, - AppOnboardingSteps.CONFIGURE_STEP, -] as const; +Page.PageWrapper = PageWrapper; -type StepType = (typeof STEPS)[number]; +export { getServerSideProps } from "@lib/apps/installation/[[...step]]/getServerSideProps"; -type StepObj = Record< - StepType, - { - getTitle: (appName: string) => string; - getDescription: (appName: string) => string; - stepNumber: number; - } ->; - -type OnboardingPageProps = { - appMetadata: AppMeta; - step: StepType; - teams?: TeamsProp; - personalAccount: PersonalAccountProps; - eventTypes?: TEventType[]; - userName: string; - credentialId?: number; - showEventTypesStep: boolean; - isConferencing: boolean; - installableOnTeams: boolean; -}; - -type TUpdateObject = { - id: number; - metadata?: z.infer; - bookingFields?: z.infer; - locations?: LocationObject[]; -}; - -const OnboardingPage = ({ - step, - teams, - personalAccount, - appMetadata, - eventTypes, - userName, - credentialId, - showEventTypesStep, - isConferencing, - installableOnTeams, -}: OnboardingPageProps) => { - const { t } = useLocale(); - const pathname = usePathname(); - const router = useRouter(); - - const STEPS_MAP: StepObj = { - [AppOnboardingSteps.ACCOUNTS_STEP]: { - getTitle: () => `${t("select_account_header")}`, - getDescription: (appName) => `${t("select_account_description", { appName })}`, - stepNumber: 1, - }, - [AppOnboardingSteps.EVENT_TYPES_STEP]: { - getTitle: () => `${t("select_event_types_header")}`, - getDescription: (appName) => `${t("select_event_types_description", { appName })}`, - stepNumber: installableOnTeams ? 2 : 1, - }, - [AppOnboardingSteps.CONFIGURE_STEP]: { - getTitle: (appName) => `${t("configure_app_header", { appName })}`, - getDescription: () => `${t("configure_app_description")}`, - stepNumber: installableOnTeams ? 3 : 2, - }, - } as const; - const [configureStep, setConfigureStep] = useState(false); - - const currentStep: AppOnboardingSteps = useMemo(() => { - if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) { - return AppOnboardingSteps.CONFIGURE_STEP; - } - return step; - }, [step, configureStep]); - const stepObj = STEPS_MAP[currentStep]; - - const maxSteps = useMemo(() => { - if (!showEventTypesStep) { - return 1; - } - return installableOnTeams ? STEPS.length : STEPS.length - 1; - }, [showEventTypesStep, installableOnTeams]); - - const utils = trpc.useContext(); - - const formPortalRef = useRef(null); - - const formMethods = useForm({ - defaultValues: { - eventTypes, - }, - }); - const mutation = useAddAppMutation(null, { - onSuccess: (data) => { - if (data?.setupPending) return; - showToast(t("app_successfully_installed"), "success"); - }, - onError: (error) => { - if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); - }, - }); - - useEffect(() => { - eventTypes && formMethods.setValue("eventTypes", eventTypes); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [eventTypes]); - - const updateMutation = trpc.viewer.eventTypes.update.useMutation({ - onSuccess: async (data) => { - showToast(t("event_type_updated_successfully", { eventTypeTitle: data.eventType?.title }), "success"); - }, - async onSettled() { - await utils.viewer.eventTypes.get.invalidate(); - }, - onError: (err) => { - let message = ""; - if (err instanceof HttpError) { - const message = `${err.statusCode}: ${err.message}`; - showToast(message, "error"); - } - - if (err.data?.code === "UNAUTHORIZED") { - message = `${err.data.code}: ${t("error_event_type_unauthorized_update")}`; - } - - if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") { - message = `${err.data.code}: ${t(err.message)}`; - } - - if (err.data?.code === "INTERNAL_SERVER_ERROR") { - message = t("unexpected_error_try_again"); - } - - showToast(message ? t(message) : t(err.message), "error"); - }, - }); - - const handleSelectAccount = async (teamId?: number) => { - mutation.mutate({ - type: appMetadata.type, - variant: appMetadata.variant, - slug: appMetadata.slug, - ...(teamId && { teamId }), - // for oAuth apps - ...(showEventTypesStep && { - returnTo: - WEBAPP_URL + - getAppOnboardingUrl({ - slug: appMetadata.slug, - teamId, - step: AppOnboardingSteps.EVENT_TYPES_STEP, - }), - }), - }); - }; - - const handleSetUpLater = () => { - router.push(`/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`); - }; - - return ( -
- - - {t("install")} {appMetadata?.name ?? ""} - - - -
-
-
-
{ - 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, - }; - } - - return updateMutation.mutateAsync(updateObject); - }); - try { - await Promise.all(mutationPromises); - router.push("/event-types"); - } catch (err) { - console.error(err); - } - }}> - - - - {currentStep === AppOnboardingSteps.ACCOUNTS_STEP && ( - - )} - {currentStep === AppOnboardingSteps.EVENT_TYPES_STEP && - eventTypes && - Boolean(eventTypes?.length) && ( - - )} - {currentStep === AppOnboardingSteps.CONFIGURE_STEP && formPortalRef.current && ( - - )} - -
-
-
- -
- ); -}; - -// 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; - -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, - }, - }, - }, - }, - }, - }, - }, - }); - - if (!user) { - throw new Error(ERROR_MESSAGES.userNotFound); - } - - const teams = user.teams.map(({ team }) => ({ - ...team, - logoUrl: team.parent - ? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name) - : getPlaceholderAvatar(team.logoUrl, team.name), - })); - return { - ...user, - teams, - }; -}; - -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({ - 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, - slug: true, - locations: true, - userId: true, - destinationCalendar: true, - bookingFields: true, - }, - /** - * 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; - }); - - if (eventTypes.length === 0) { - return []; - } - - 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 || []), - })); -}; - -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; -}; - -export const getServerSideProps = async (context: GetServerSidePropsContext) => { - try { - let eventTypes: TEventType[] | null = null; - const { req, res, 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, res }); - 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 isConferencing = isConferencingApp(appMetadata.categories); - const showEventTypesStep = extendsEventType || isConferencing; - console.log("sshowEventTypesStephowEventTypesStep: ", showEventTypesStep); - - if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed); - - const user = await getUser(session.user.id); - - 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) { - console.log("eerrerrerrerrerrerrerrerrrr: ", err); - if (err instanceof z.ZodError) { - 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" } }; - } - } - } -}; - -OnboardingPage.PageWrapper = PageWrapper; - -export default OnboardingPage; +export default Page; diff --git a/apps/web/pages/apps/installed/[category].tsx b/apps/web/pages/apps/installed/[category].tsx index 3ed57baea2..7a8e670ce1 100644 --- a/apps/web/pages/apps/installed/[category].tsx +++ b/apps/web/pages/apps/installed/[category].tsx @@ -1,164 +1,12 @@ -"use client"; - -import { useReducer } from "react"; - -import getAppCategoryTitle from "@calcom/app-store/_utils/getAppCategoryTitle"; -import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal"; -import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { AppCategories } from "@calcom/prisma/enums"; -import { trpc } from "@calcom/trpc/react"; -import type { Icon } from "@calcom/ui"; -import { AppSkeletonLoader as SkeletonLoader, Button, EmptyScreen, ShellSubHeading } from "@calcom/ui"; - -import { QueryCell } from "@lib/QueryCell"; -import type { querySchemaType } from "@lib/apps/installed/[category]/getServerSideProps"; - import PageWrapper from "@components/PageWrapper"; -import { AppList } from "@components/apps/AppList"; -import { CalendarListContainer } from "@components/apps/CalendarListContainer"; -import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout"; -interface IntegrationsContainerProps { - variant?: AppCategories; - exclude?: AppCategories[]; - handleDisconnect: (credentialId: number) => void; -} +import type { PageProps } from "~/apps/installed/[category]/installed-category-view"; +import InstalledApps from "~/apps/installed/[category]/installed-category-view"; -const IntegrationsContainer = ({ - variant, - exclude, - handleDisconnect, -}: IntegrationsContainerProps): JSX.Element => { - const { t } = useLocale(); - const query = trpc.viewer.integrations.useQuery({ - variant, - exclude, - onlyInstalled: true, - includeTeamInstalledApps: true, - }); +const Page = (props: PageProps) => ; - // TODO: Refactor and reuse getAppCategories? - const emptyIcon: Record["name"]> = { - calendar: "calendar", - conferencing: "video", - automation: "share-2", - analytics: "chart-bar", - payment: "credit-card", - other: "grid-3x3", - web3: "credit-card", // deprecated - video: "video", // deprecated - messaging: "mail", - crm: "contact", - }; - - return ( - } - success={({ data }) => { - if (!data.items.length) { - const emptyHeaderCategory = getAppCategoryTitle(variant || "other", true); - - return ( - - {t(`connect_${variant || "other"}_apps`)} - - } - /> - ); - } - return ( -
- - {t("add")} - - } - /> - - -
- ); - }} - /> - ); -}; - -type ModalState = { - isOpen: boolean; - credentialId: null | number; - teamId?: number; -}; - -export default function InstalledApps() { - const searchParams = useCompatSearchParams(); - const { t } = useLocale(); - const category = searchParams?.get("category") as querySchemaType["category"]; - const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => { - // Exclude calendar and other from categoryList, we handle those slightly differently below - return !(category in { other: null, calendar: null }); - }); - - const [data, updateData] = useReducer( - (data: ModalState, partialData: Partial) => ({ ...data, ...partialData }), - { - isOpen: false, - credentialId: null, - } - ); - - const handleModelClose = () => { - updateData({ isOpen: false, credentialId: null }); - }; - - const handleDisconnect = (credentialId: number, teamId?: number) => { - updateData({ isOpen: true, credentialId, teamId }); - }; - - return ( - <> - - {categoryList.includes(category) && ( - - )} - {category === "calendar" && } - {category === "other" && ( - - )} - - - - ); -} +Page.PageWrapper = PageWrapper; export { getServerSideProps } from "@lib/apps/installed/[category]/getServerSideProps"; -InstalledApps.PageWrapper = PageWrapper; +export default Page; diff --git a/packages/lib/server/repository/app.ts b/packages/lib/server/repository/app.ts index b60d5378a3..090f663a2c 100644 --- a/packages/lib/server/repository/app.ts +++ b/packages/lib/server/repository/app.ts @@ -1,8 +1,6 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; -import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; -const log = logger.getSubLogger({ prefix: ["repository/eventType"] }); export class AppRepository { static async seedApp(dirName: string, keys?: any) { const appMetadata = appStoreMetadata[dirName as keyof typeof appStoreMetadata]; @@ -21,4 +19,8 @@ export class AppRepository { }, }); } + + static async findAppStore() { + return await prisma.app.findMany({ select: { slug: true } }); + } }