diff --git a/apps/web/lib/app-providers.tsx b/apps/web/lib/app-providers.tsx index b302f7336f..e576094e68 100644 --- a/apps/web/lib/app-providers.tsx +++ b/apps/web/lib/app-providers.tsx @@ -10,6 +10,8 @@ import type { ComponentProps, ReactNode } from "react"; import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic"; import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic"; +import { FeatureProvider } from "@calcom/features/flags/context/provider"; +import { useFlags } from "@calcom/features/flags/hooks"; import { trpc } from "@calcom/trpc/react"; import { MetaProvider } from "@calcom/ui"; @@ -58,6 +60,11 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => { return ; }; +function FeatureFlagsProvider({ children }: { children: React.ReactNode }) { + const flags = useFlags(); + return {children}; +} + const AppProviders = (props: AppPropsWithChildren) => { const session = trpc.viewer.public.session.useQuery().data; // No need to have intercom on public pages - Good for Page Performance @@ -85,7 +92,9 @@ const AppProviders = (props: AppPropsWithChildren) => { storageKey={storageKey} forcedTheme={forcedTheme} attribute="class"> - {props.children} + + {props.children} + diff --git a/apps/web/pages/settings/admin/flags.tsx b/apps/web/pages/settings/admin/flags.tsx new file mode 100644 index 0000000000..85635fd3de --- /dev/null +++ b/apps/web/pages/settings/admin/flags.tsx @@ -0,0 +1,9 @@ +import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view"; + +import { getLayout } from "@components/auth/layouts/AdminLayout"; + +const FlagsPage = () => ; + +FlagsPage.getLayout = getLayout; + +export default FlagsPage; diff --git a/packages/emails/templates/_base-email.ts b/packages/emails/templates/_base-email.ts index c8994dc47c..0191489b31 100644 --- a/packages/emails/templates/_base-email.ts +++ b/packages/emails/templates/_base-email.ts @@ -4,8 +4,10 @@ import { z } from "zod"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; +import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import { serverConfig } from "@calcom/lib/serverConfig"; +import prisma from "@calcom/prisma"; declare let global: { E2E_EMAILS?: Record[]; @@ -29,7 +31,13 @@ export default class BaseEmail { protected getNodeMailerPayload(): Record { return {}; } - public sendEmail() { + public async sendEmail() { + const featureFlags = await getFeatureFlagMap(prisma); + /** If email kill switch exists and is active, we prevent emails being sent. */ + if (featureFlags.emails) { + console.warn("Skipped Sending Email due to active Kill Switch"); + return new Promise((r) => r("Skipped Sending Email due to active Kill Switch")); + } if (process.env.NEXT_PUBLIC_IS_E2E) { global.E2E_EMAILS = global.E2E_EMAILS || []; global.E2E_EMAILS.push(this.getNodeMailerPayload()); diff --git a/packages/features/flags/components/FlagAdminList.tsx b/packages/features/flags/components/FlagAdminList.tsx new file mode 100644 index 0000000000..5ad2e3c02a --- /dev/null +++ b/packages/features/flags/components/FlagAdminList.tsx @@ -0,0 +1,49 @@ +import { trpc } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { Badge, List, ListItem, ListItemText, ListItemTitle, Switch } from "@calcom/ui"; + +export const FlagAdminList = () => { + const [data] = trpc.viewer.features.list.useSuspenseQuery(); + return ( + + {data.map((flag) => ( + + + + {flag.slug} + + {flag.type} + + {flag.description} + + + + + + ))} + + ); +}; + +type Flag = RouterOutputs["viewer"]["features"]["list"][number]; + +const FlagToggle = (props: { flag: Flag }) => { + const { + flag: { slug, enabled }, + } = props; + const utils = trpc.useContext(); + const mutation = trpc.viewer.features.toggle.useMutation({ + onSuccess: () => { + utils.viewer.features.list.invalidate(); + utils.viewer.features.map.invalidate(); + }, + }); + return ( + { + mutation.mutate({ slug, enabled: checked }); + }} + /> + ); +}; diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts new file mode 100644 index 0000000000..9292a6dc0c --- /dev/null +++ b/packages/features/flags/config.ts @@ -0,0 +1,11 @@ +/** + * Right now we only support boolean flags. + * Maybe later on we can add string variants or numeric ones + **/ +export type AppFlags = { + emails: boolean; + teams: boolean; + webhooks: boolean; + workflows: boolean; + "booking-page-v2": boolean; +}; diff --git a/packages/features/flags/context/provider.ts b/packages/features/flags/context/provider.ts new file mode 100644 index 0000000000..9a8cb64e48 --- /dev/null +++ b/packages/features/flags/context/provider.ts @@ -0,0 +1,56 @@ +import * as React from "react"; + +import type { AppFlags } from "../config"; + +/** + * Generic Feature Flags + * + * Entries consist of the feature flag name as the key and the resolved variant's value as the value. + */ +export type Flags = AppFlags; + +/** + * Allows you to access the flags from context + */ +const FeatureContext = React.createContext(null); + +/** + * Accesses the evaluated flags from context. + * + * You need to render a further up to be able to use + * this component. + */ +export function useFlagMap() { + const flagMapContext = React.useContext(FeatureContext); + if (flagMapContext === null) throw new Error("Error: useFlagMap was used outside of FeatureProvider."); + return flagMapContext as Flags; +} + +/** + * If you want to be able to access the flags from context using `useFlagMap()`, + * you can render the FeatureProvider at the top of your Next.js pages, like so: + * + * ```ts + * import { useFlags } from "@calcom/features/flags/hooks/useFlag" + * import { FeatureProvider, useFlagMap } from @calcom/features/flags/context/provider" + * + * export default function YourPage () { + * const flags = useFlags() + * + * return ( + * + * + * + * ) + * } + * ``` + * + * You can then call `useFlagMap()` to access your `flagMap` from within + * `YourOwnComponent` or further down. + * + * _Note that it's generally better to explicitly pass your flags down as props, + * so you might not need this at all._ + */ +export function FeatureProvider(props: { value: F; children: React.ReactNode }) { + return React.createElement(FeatureContext.Provider, { value: props.value }, props.children); +} diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts new file mode 100644 index 0000000000..9778081b39 --- /dev/null +++ b/packages/features/flags/hooks/index.ts @@ -0,0 +1,6 @@ +import { trpc } from "@calcom/trpc/react"; + +export function useFlags() { + const query = trpc.viewer.features.map.useQuery(undefined, { initialData: {} }); + return query.data; +} diff --git a/packages/features/flags/pages/flag-listing-view.tsx b/packages/features/flags/pages/flag-listing-view.tsx new file mode 100644 index 0000000000..4b633299ed --- /dev/null +++ b/packages/features/flags/pages/flag-listing-view.tsx @@ -0,0 +1,17 @@ +import { Suspense } from "react"; + +import { Meta } from "@calcom/ui"; +import { FiLoader } from "@calcom/ui/components/icon"; + +import { FlagAdminList } from "../components/FlagAdminList"; + +export const FlagListingView = () => { + return ( + <> + + }> + + + > + ); +}; diff --git a/packages/features/flags/server/router.ts b/packages/features/flags/server/router.ts new file mode 100644 index 0000000000..413fbd8a02 --- /dev/null +++ b/packages/features/flags/server/router.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +import { authedAdminProcedure, publicProcedure, router } from "@calcom/trpc/server/trpc"; + +import { getFeatureFlagMap } from "./utils"; + +export const featureFlagRouter = router({ + list: publicProcedure.query(async ({ ctx }) => { + const { prisma } = ctx; + return prisma.feature.findMany({ + orderBy: { slug: "asc" }, + }); + }), + map: publicProcedure.query(async ({ ctx }) => { + const { prisma } = ctx; + return getFeatureFlagMap(prisma); + }), + toggle: authedAdminProcedure + .input(z.object({ slug: z.string(), enabled: z.boolean() })) + .mutation(({ ctx, input }) => { + const { prisma, user } = ctx; + const { slug, enabled } = input; + return prisma.feature.update({ + where: { slug }, + data: { enabled, updatedBy: user.id }, + }); + }), +}); diff --git a/packages/features/flags/server/utils.ts b/packages/features/flags/server/utils.ts new file mode 100644 index 0000000000..0686381925 --- /dev/null +++ b/packages/features/flags/server/utils.ts @@ -0,0 +1,13 @@ +import type { PrismaClient } from "@prisma/client"; + +import type { AppFlags } from "../config"; + +export async function getFeatureFlagMap(prisma: PrismaClient) { + const flags = await prisma.feature.findMany({ + orderBy: { slug: "asc" }, + }); + return flags.reduce((acc, flag) => { + acc[flag.slug as keyof AppFlags] = flag.enabled; + return acc; + }, {} as AppFlags); +} diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 26c191f837..45dff5b432 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -83,6 +83,7 @@ const tabs: VerticalTabItemProps[] = [ icon: FiLock, children: [ // + { name: "features", href: "/settings/admin/flags" }, { name: "license", href: "/auth/setup?step=1" }, { name: "impersonation", href: "/settings/admin/impersonation" }, { name: "apps", href: "/settings/admin/apps/calendar" }, diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index b7684602a1..5af2067c2f 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -13,6 +13,7 @@ import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookin import ImpersonatingBanner from "@calcom/features/ee/impersonation/components/ImpersonatingBanner"; import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem"; import { TeamsUpgradeBanner } from "@calcom/features/ee/teams/components"; +import { useFlagMap } from "@calcom/features/flags/context/provider"; import { KBarContent, KBarRoot, KBarTrigger } from "@calcom/features/kbar/Kbar"; import TimezoneChangeDialog from "@calcom/features/settings/TimezoneChangeDialog"; import { Tips } from "@calcom/features/tips"; @@ -22,6 +23,7 @@ import classNames from "@calcom/lib/classNames"; import { APP_NAME, DESKTOP_APP_LINK, JOIN_SLACK, ROADMAP, WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; +import { isKeyInObject } from "@calcom/lib/isKeyInObject"; import { trpc } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import type { SVGComponent } from "@calcom/types/SVGComponent"; @@ -29,39 +31,39 @@ import { Button, Credits, Dropdown, + DropdownItem, DropdownMenuContent, DropdownMenuItem, - DropdownItem, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuTrigger, ErrorBoundary, - Logo, HeadSeo, - showToast, + Logo, SkeletonText, + showToast, } from "@calcom/ui"; import { - FiMoreVertical, - FiMoon, - FiExternalLink, - FiLink, - FiSlack, - FiMap, - FiHelpCircle, - FiDownload, - FiLogOut, + FiArrowLeft, + FiArrowRight, + FiBarChart, FiCalendar, FiClock, - FiUsers, - FiGrid, - FiMoreHorizontal, + FiDownload, + FiExternalLink, FiFileText, - FiZap, + FiGrid, + FiHelpCircle, + FiLink, + FiLogOut, + FiMap, + FiMoon, + FiMoreHorizontal, + FiMoreVertical, FiSettings, - FiBarChart, - FiArrowRight, - FiArrowLeft, + FiSlack, + FiUsers, + FiZap } from "@calcom/ui/components/icon"; import FreshChatProvider from "../ee/support/lib/freshchat/FreshChatProvider"; @@ -584,6 +586,8 @@ function useShouldDisplayNavigationItem(item: NavigationItemType) { trpc: {}, } ); + const flags = useFlagMap(); + if (isKeyInObject(item.name, flags)) return flags[item.name]; return !requiredCredentialNavigationItems.includes(item.name) || routingForms?.isInstalled; } diff --git a/packages/features/tailwind.config.js b/packages/features/tailwind.config.js new file mode 100644 index 0000000000..825b805357 --- /dev/null +++ b/packages/features/tailwind.config.js @@ -0,0 +1,6 @@ +const base = require("@calcom/config/tailwind-preset"); + +module.exports = { + ...base, + content: [...base.content, "/**/*.{js,ts,jsx,tsx}"], +}; diff --git a/packages/lib/isKeyInObject.ts b/packages/lib/isKeyInObject.ts new file mode 100644 index 0000000000..e653874f75 --- /dev/null +++ b/packages/lib/isKeyInObject.ts @@ -0,0 +1 @@ +export const isKeyInObject = (k: PropertyKey, o: T): k is keyof T => k in o; diff --git a/packages/prisma/migrations/20230303195431_add_feature_flags/migration.sql b/packages/prisma/migrations/20230303195431_add_feature_flags/migration.sql new file mode 100644 index 0000000000..708592ee67 --- /dev/null +++ b/packages/prisma/migrations/20230303195431_add_feature_flags/migration.sql @@ -0,0 +1,20 @@ +-- CreateEnum +CREATE TYPE "FeatureType" AS ENUM ('RELEASE', 'EXPERIMENT', 'OPERATIONAL', 'KILL_SWITCH', 'PERMISSION'); + +-- CreateTable +CREATE TABLE "Feature" ( + "slug" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "description" TEXT, + "type" "FeatureType" DEFAULT 'RELEASE', + "stale" BOOLEAN DEFAULT false, + "lastUsedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, + "updatedBy" INTEGER, + + CONSTRAINT "Feature_pkey" PRIMARY KEY ("slug") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Feature_slug_key" ON "Feature"("slug"); diff --git a/packages/prisma/migrations/20230303195432_add_feature_flag_default_values/migration.sql b/packages/prisma/migrations/20230303195432_add_feature_flag_default_values/migration.sql new file mode 100644 index 0000000000..2cbf88aefa --- /dev/null +++ b/packages/prisma/migrations/20230303195432_add_feature_flag_default_values/migration.sql @@ -0,0 +1,28 @@ +-- Insert initial feature flags with their default values +INSERT INTO + "Feature" (slug, enabled, description, "type") +VALUES + ( + 'emails', + false, + 'Enable to prevent any emails being send', + 'KILL_SWITCH' + ), + ( + 'workflows', + true, + 'Enable workflows for this instance', + 'OPERATIONAL' + ), + ( + 'teams', + true, + 'Enable teams for this instance', + 'OPERATIONAL' + ), + ( + 'webhooks', + true, + 'Enable webhooks for this instance', + 'OPERATIONAL' + ) ON CONFLICT (slug) DO NOTHING; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 23206928c9..bf32aebf57 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -686,3 +686,28 @@ model VerifiedNumber { team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) phoneNumber String } + +model Feature { + // The feature slug, ex: 'v2-workflows' + slug String @id @unique + // If the feature is currently enabled + enabled Boolean @default(false) + // A short description of the feature + description String? + // The type of feature flag + type FeatureType? @default(RELEASE) + // If the flag is considered stale + stale Boolean? @default(false) + lastUsedAt DateTime? + createdAt DateTime? @default(now()) + updatedAt DateTime? @default(now()) @updatedAt + updatedBy Int? +} + +enum FeatureType { + RELEASE + EXPERIMENT + OPERATIONAL + KILL_SWITCH + PERMISSION +} diff --git a/packages/trpc/react/trpc.ts b/packages/trpc/react/trpc.ts index e05de1af77..5c5c1d3e11 100644 --- a/packages/trpc/react/trpc.ts +++ b/packages/trpc/react/trpc.ts @@ -1,3 +1,4 @@ +import type { NextPageContext } from "next/types"; import superjson from "superjson"; import { httpLink } from "../client/links/httpLink"; @@ -15,7 +16,7 @@ import type { AppRouter } from "../server/routers/_app"; * A set of strongly-typed React hooks from your `AppRouter` type signature with `createTRPCReact`. * @link https://trpc.io/docs/v10/react#2-create-trpc-hooks */ -export const trpc = createTRPCNext({ +export const trpc = createTRPCNext({ config() { const url = typeof window !== "undefined" diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index de13e9a64b..b4ba5e7bc2 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -23,6 +23,7 @@ import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml"; +import { featureFlagRouter } from "@calcom/features/flags/server/router"; import { insightsRouter } from "@calcom/features/insights/server/trpc-router"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; @@ -1278,6 +1279,7 @@ export const viewerRouter = mergeRouters( // After that there would just one merge call here for all the apps. appRoutingForms: app_RoutingForms, eth: ethRouter, + features: featureFlagRouter, appsRouter, }) );