From 6b304f8456888113a4707475cefccab1c5dbf869 Mon Sep 17 00:00:00 2001 From: David Corbitt Date: Sun, 6 Aug 2023 23:23:20 -0700 Subject: [PATCH] Show selected org --- app/@types/nextjs-routes.d.ts | 1 + .../migration.sql | 2 + app/prisma/schema.prisma | 3 + .../ExperimentSettingsDrawer/DeleteButton.tsx | 5 ++ app/src/components/nav/AppShell.tsx | 90 +++++++------------ app/src/components/nav/IconLink.tsx | 23 +++++ app/src/components/nav/NavSidebarOption.tsx | 26 ++++++ app/src/components/nav/ProjectMenu.tsx | 74 +++++++++++++++ app/src/components/nav/UserMenu.tsx | 56 +++++++----- app/src/pages/data/[id].tsx | 5 +- app/src/pages/data/index.tsx | 4 +- app/src/pages/experiments/[id].tsx | 3 +- app/src/pages/experiments/index.tsx | 4 +- app/src/pages/home/index.tsx | 56 ++++++++++++ app/src/pages/index.tsx | 2 +- app/src/server/api/root.router.ts | 2 + .../api/routers/organizations.router.ts | 71 +++++++++++++++ app/src/server/tasks/worker.ts | 2 +- app/src/server/utils/openai.ts | 5 +- app/src/state/store.ts | 7 ++ app/src/utils/accessControl.ts | 21 +++++ app/src/utils/hooks.ts | 9 ++ client-libs/typescript/openai/index.ts | 6 +- 23 files changed, 380 insertions(+), 97 deletions(-) create mode 100644 app/prisma/migrations/20230807044936_add_name_to_organization/migration.sql create mode 100644 app/src/components/nav/IconLink.tsx create mode 100644 app/src/components/nav/NavSidebarOption.tsx create mode 100644 app/src/components/nav/ProjectMenu.tsx create mode 100644 app/src/pages/home/index.tsx create mode 100644 app/src/server/api/routers/organizations.router.ts diff --git a/app/@types/nextjs-routes.d.ts b/app/@types/nextjs-routes.d.ts index 32c34bd..fbccfff 100644 --- a/app/@types/nextjs-routes.d.ts +++ b/app/@types/nextjs-routes.d.ts @@ -22,6 +22,7 @@ declare module "nextjs-routes" { | StaticRoute<"/data"> | DynamicRoute<"/experiments/[id]", { "id": string }> | StaticRoute<"/experiments"> + | StaticRoute<"/home"> | StaticRoute<"/"> | StaticRoute<"/sentry-example-page"> | StaticRoute<"/world-champs"> diff --git a/app/prisma/migrations/20230807044936_add_name_to_organization/migration.sql b/app/prisma/migrations/20230807044936_add_name_to_organization/migration.sql new file mode 100644 index 0000000..8ec9300 --- /dev/null +++ b/app/prisma/migrations/20230807044936_add_name_to_organization/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "name" TEXT NOT NULL DEFAULT 'Project 1'; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 7ed4bfe..387084a 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -200,8 +200,11 @@ model DatasetEntry { updatedAt DateTime @updatedAt } +// TODO rename Organization to Project model Organization { id String @id @default(uuid()) @db.Uuid + name String @default("Project 1") + personalOrgUserId String? @unique @db.Uuid PersonalOrgUser User? @relation(fields: [personalOrgUserId], references: [id], onDelete: Cascade) diff --git a/app/src/components/ExperimentSettingsDrawer/DeleteButton.tsx b/app/src/components/ExperimentSettingsDrawer/DeleteButton.tsx index 6c5f6c7..c5e7a9f 100644 --- a/app/src/components/ExperimentSettingsDrawer/DeleteButton.tsx +++ b/app/src/components/ExperimentSettingsDrawer/DeleteButton.tsx @@ -14,6 +14,7 @@ import { import { useRouter } from "next/router"; import { useRef } from "react"; import { BsTrash } from "react-icons/bs"; +import { useAppStore } from "~/state/store"; import { api } from "~/utils/api"; import { useExperiment, useHandledAsyncCallback } from "~/utils/hooks"; @@ -23,6 +24,8 @@ export const DeleteButton = () => { const utils = api.useContext(); const router = useRouter(); + const closeDrawer = useAppStore((s) => s.closeDrawer); + const { isOpen, onOpen, onClose } = useDisclosure(); const cancelRef = useRef(null); @@ -31,6 +34,8 @@ export const DeleteButton = () => { await mutation.mutateAsync({ id: experiment.data.id }); await utils.experiments.list.invalidate(); await router.push({ pathname: "/experiments" }); + closeDrawer(); + onClose(); }, [mutation, experiment.data?.id, router]); diff --git a/app/src/components/nav/AppShell.tsx b/app/src/components/nav/AppShell.tsx index b6aec3e..2356dca 100644 --- a/app/src/components/nav/AppShell.tsx +++ b/app/src/components/nav/AppShell.tsx @@ -7,48 +7,21 @@ import { Image, Text, Box, - type BoxProps, Link as ChakraLink, Flex, } from "@chakra-ui/react"; import Head from "next/head"; -import Link, { type LinkProps } from "next/link"; +import Link from "next/link"; import { BsGithub, BsPersonCircle } from "react-icons/bs"; -import { useRouter } from "next/router"; -import { type IconType } from "react-icons"; import { RiDatabase2Line, RiFlaskLine } from "react-icons/ri"; import { signIn, useSession } from "next-auth/react"; import UserMenu from "./UserMenu"; import { env } from "~/env.mjs"; +import ProjectMenu from "./ProjectMenu"; +import NavSidebarOption from "./NavSidebarOption"; +import IconLink from "./IconLink"; -type IconLinkProps = BoxProps & LinkProps & { label?: string; icon: IconType; href: string }; - -const IconLink = ({ icon, label, href, color, ...props }: IconLinkProps) => { - const router = useRouter(); - const isActive = href && router.pathname.startsWith(href); - return ( - - - - - {label} - - - - ); -}; - -const Divider = () => ; +const Divider = () => ; const NavSidebar = () => { const user = useSession().data; @@ -56,22 +29,28 @@ const NavSidebar = () => { return ( - + OpenPipe - + + {user != null && ( <> + + {env.NEXT_PUBLIC_SHOW_DATA && ( @@ -79,29 +58,26 @@ const NavSidebar = () => { )} {user === null && ( - { - signIn("github").catch(console.error); - }} - > - - - Sign In - - + + { + signIn("github").catch(console.error); + }} + > + + + Sign In + + + )} - {user ? ( - - ) : ( - - )} + {user && } + { + return ( + + + + + + {label} + + + + + ); +}; + +export default IconLink; diff --git a/app/src/components/nav/NavSidebarOption.tsx b/app/src/components/nav/NavSidebarOption.tsx new file mode 100644 index 0000000..9c3afea --- /dev/null +++ b/app/src/components/nav/NavSidebarOption.tsx @@ -0,0 +1,26 @@ +import { Box, type BoxProps } from "@chakra-ui/react"; +import { useRouter } from "next/router"; + +const NavSidebarOption = ({ + activeHrefPattern, + ...props +}: { activeHrefPattern?: string } & BoxProps) => { + const router = useRouter(); + const isActive = activeHrefPattern && router.pathname.startsWith(activeHrefPattern); + return ( + + {props.children} + + ); +}; + +export default NavSidebarOption; diff --git a/app/src/components/nav/ProjectMenu.tsx b/app/src/components/nav/ProjectMenu.tsx new file mode 100644 index 0000000..faa2e4c --- /dev/null +++ b/app/src/components/nav/ProjectMenu.tsx @@ -0,0 +1,74 @@ +import { + HStack, + VStack, + Text, + Popover, + PopoverTrigger, + PopoverContent, + Flex, +} from "@chakra-ui/react"; +import { useEffect } from "react"; +import Link from "next/link"; + +import { useAppStore } from "~/state/store"; +import { api } from "~/utils/api"; +import NavSidebarOption from "./NavSidebarOption"; +import { useSelectedOrg } from "~/utils/hooks"; + +export default function ProjectMenu() { + const selectedOrgId = useAppStore((s) => s.selectedOrgId); + const setSelectedOrgId = useAppStore((s) => s.setSelectedOrgId); + + const { data } = api.organizations.list.useQuery(); + + useEffect(() => { + if (data && data[0] && (!selectedOrgId || !data.find((org) => org.id === selectedOrgId))) { + setSelectedOrgId(data[0].id); + } + }, [selectedOrgId, setSelectedOrgId, data]); + + const { data: selectedOrg } = useSelectedOrg(); + + return ( + <> + + + + + PROJECT + + + + + + {selectedOrg?.name[0]?.toUpperCase()} + + + {selectedOrg?.name} + + + + + + + + + + ); +} diff --git a/app/src/components/nav/UserMenu.tsx b/app/src/components/nav/UserMenu.tsx index 0f70e31..7abf17b 100644 --- a/app/src/components/nav/UserMenu.tsx +++ b/app/src/components/nav/UserMenu.tsx @@ -14,6 +14,7 @@ import { import { type Session } from "next-auth"; import { signOut } from "next-auth/react"; import { BsBoxArrowRight, BsChevronRight, BsPersonCircle } from "react-icons/bs"; +import NavSidebarOption from "./NavSidebarOption"; export default function UserMenu({ user, ...rest }: { user: Session } & StackProps) { const { colorMode } = useColorMode(); @@ -27,30 +28,39 @@ export default function UserMenu({ user, ...rest }: { user: Session } & StackPro return ( <> - - + - {profileImage} - - - {user.user.name} - - - {user.user.email} - - - - - + ACCOUNT + + + + + {profileImage} + + + {user.user.name} + + + {/* {user.user.email} */} + + + + + + + {/* sign out */} diff --git a/app/src/pages/data/[id].tsx b/app/src/pages/data/[id].tsx index 8d06b53..5b68a02 100644 --- a/app/src/pages/data/[id].tsx +++ b/app/src/pages/data/[id].tsx @@ -56,8 +56,7 @@ export default function Dataset() { - + {datasetId && } diff --git a/app/src/pages/data/index.tsx b/app/src/pages/data/index.tsx index b1e581e..5c9a411 100644 --- a/app/src/pages/data/index.tsx +++ b/app/src/pages/data/index.tsx @@ -50,7 +50,7 @@ export default function DatasetsPage() { return ( - + @@ -60,7 +60,7 @@ export default function DatasetsPage() { - + {datasets.data && !datasets.isLoading ? ( datasets?.data?.map((dataset) => ( diff --git a/app/src/pages/experiments/[id].tsx b/app/src/pages/experiments/[id].tsx index 998b19c..83dfcbb 100644 --- a/app/src/pages/experiments/[id].tsx +++ b/app/src/pages/experiments/[id].tsx @@ -105,7 +105,8 @@ export default function Experiment() { - + @@ -60,7 +60,7 @@ export default function ExperimentsPage() { - + {experiments.data && !experiments.isLoading ? ( experiments?.data?.map((exp) => ) diff --git a/app/src/pages/home/index.tsx b/app/src/pages/home/index.tsx new file mode 100644 index 0000000..b79caec --- /dev/null +++ b/app/src/pages/home/index.tsx @@ -0,0 +1,56 @@ +import { Breadcrumb, BreadcrumbItem, HStack, Input } from "@chakra-ui/react"; +import { useEffect, useState } from "react"; +import AppShell from "~/components/nav/AppShell"; +import { api } from "~/utils/api"; +import { useHandledAsyncCallback, useSelectedOrg } from "~/utils/hooks"; + +export default function HomePage() { + const utils = api.useContext(); + const { data: selectedOrg } = useSelectedOrg(); + + const updateMutation = api.organizations.update.useMutation(); + const [onSaveName] = useHandledAsyncCallback(async () => { + if (name && name !== selectedOrg?.name && selectedOrg?.id) { + await updateMutation.mutateAsync({ + id: selectedOrg.id, + updates: { name }, + }); + await Promise.all([utils.organizations.get.invalidate({ id: selectedOrg.id })]); + } + }, [updateMutation, selectedOrg]); + + const [name, setName] = useState(selectedOrg?.name); + useEffect(() => { + setName(selectedOrg?.name); + }, [selectedOrg?.name]); + return ( + + + + + setName(e.target.value)} + onBlur={onSaveName} + borderWidth={1} + borderColor="transparent" + fontSize={16} + px={0} + minW={{ base: 100, lg: 300 }} + flex={1} + _hover={{ borderColor: "gray.300" }} + _focus={{ borderColor: "blue.500", outline: "none" }} + /> + + + + + ); +} diff --git a/app/src/pages/index.tsx b/app/src/pages/index.tsx index ac4897c..7ddaa6b 100644 --- a/app/src/pages/index.tsx +++ b/app/src/pages/index.tsx @@ -4,7 +4,7 @@ import { type GetServerSideProps } from "next"; export const getServerSideProps: GetServerSideProps = async () => { return { redirect: { - destination: "/experiments", + destination: "/home", permanent: false, }, }; diff --git a/app/src/server/api/root.router.ts b/app/src/server/api/root.router.ts index 9ff807f..d50d907 100644 --- a/app/src/server/api/root.router.ts +++ b/app/src/server/api/root.router.ts @@ -9,6 +9,7 @@ import { worldChampsRouter } from "./routers/worldChamps.router"; import { datasetsRouter } from "./routers/datasets.router"; import { datasetEntries } from "./routers/datasetEntries.router"; import { externalApiRouter } from "./routers/externalApi.router"; +import { organizationsRouter } from "./routers/organizations.router"; /** * This is the primary router for your server. @@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({ worldChamps: worldChampsRouter, datasets: datasetsRouter, datasetEntries: datasetEntries, + organizations: organizationsRouter, externalApi: externalApiRouter, }); diff --git a/app/src/server/api/routers/organizations.router.ts b/app/src/server/api/routers/organizations.router.ts new file mode 100644 index 0000000..195532c --- /dev/null +++ b/app/src/server/api/routers/organizations.router.ts @@ -0,0 +1,71 @@ +import { v4 as uuidv4 } from "uuid"; +import { z } from "zod"; + +import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; +import { prisma } from "~/server/db"; +import { requireCanModifyOrganization, requireNothing } from "~/utils/accessControl"; + +export const organizationsRouter = createTRPCRouter({ + list: protectedProcedure.query(async ({ ctx }) => { + const userId = ctx.session.user.id; + requireNothing(ctx); + + if (!userId) { + return null; + } + + const organizations = await prisma.organization.findMany({ + where: { + organizationUsers: { + some: { userId: ctx.session.user.id }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (!organizations.length) { + const newOrgId = uuidv4(); + const [newOrg] = await prisma.$transaction([ + prisma.organization.create({ + data: { + id: newOrgId, + personalOrgUserId: userId, + }, + }), + prisma.organizationUser.create({ + data: { + userId, + organizationId: newOrgId, + role: "ADMIN", + }, + }), + ]); + organizations.push(newOrg); + } + + return organizations; + }), + get: protectedProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => { + requireNothing(ctx); + return await prisma.organization.findUnique({ + where: { + id: input.id, + }, + }); + }), + update: protectedProcedure + .input(z.object({ id: z.string(), updates: z.object({ name: z.string() }) })) + .mutation(async ({ input, ctx }) => { + await requireCanModifyOrganization(input.id, ctx); + return await prisma.organization.update({ + where: { + id: input.id, + }, + data: { + name: input.updates.name, + }, + }); + }), +}); diff --git a/app/src/server/tasks/worker.ts b/app/src/server/tasks/worker.ts index 74be92f..c49b960 100644 --- a/app/src/server/tasks/worker.ts +++ b/app/src/server/tasks/worker.ts @@ -17,7 +17,7 @@ const taskList = registeredTasks.reduce((acc, task) => { // Run a worker to execute jobs: const runner = await run({ connectionString: env.DATABASE_URL, - concurrency: 50, + concurrency: 10, // Install signal handlers for graceful shutdown on SIGINT, SIGTERM, etc noHandleSignals: false, pollInterval: 1000, diff --git a/app/src/server/utils/openai.ts b/app/src/server/utils/openai.ts index 9112474..5397e2c 100644 --- a/app/src/server/utils/openai.ts +++ b/app/src/server/utils/openai.ts @@ -1,10 +1,7 @@ import { env } from "~/env.mjs"; // import OpenAI from "openai"; - -// import { OpenPipe } from "../../../../client-libs/js/openai/index"; - import { OpenAI } from "openpipe"; // Set a dummy key so it doesn't fail at build time -export const openai = new OpenAI.OpenPipe({ apiKey: env.OPENAI_API_KEY ?? "dummy-key" }); +export const openai = new OpenAI.OpenAI({ apiKey: env.OPENAI_API_KEY ?? "dummy-key" }); diff --git a/app/src/state/store.ts b/app/src/state/store.ts index ba8a506..5584b21 100644 --- a/app/src/state/store.ts +++ b/app/src/state/store.ts @@ -14,6 +14,8 @@ export type State = { api: APIClient | null; setApi: (api: APIClient) => void; sharedVariantEditor: SharedVariantEditorSlice; + selectedOrgId: string | null; + setSelectedOrgId: (orgId: string) => void; }; export type SliceCreator = StateCreator; @@ -39,6 +41,11 @@ const useBaseStore = create( state.drawerOpen = false; }), sharedVariantEditor: createVariantEditorSlice(set, get, ...rest), + selectedOrgId: null, + setSelectedOrgId: (orgId: string) => + set((state) => { + state.selectedOrgId = orgId; + }), })), ); diff --git a/app/src/utils/accessControl.ts b/app/src/utils/accessControl.ts index 5fc1fd4..98aca75 100644 --- a/app/src/utils/accessControl.ts +++ b/app/src/utils/accessControl.ts @@ -16,6 +16,27 @@ export const requireNothing = (ctx: TRPCContext) => { ctx.markAccessControlRun(); }; +export const requireCanModifyOrganization = async (organizationId: string, ctx: TRPCContext) => { + const userId = ctx.session?.user.id; + if (!userId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const canModify = await prisma.organizationUser.findFirst({ + where: { + userId, + organizationId, + role: { in: [OrganizationUserRole.ADMIN, OrganizationUserRole.MEMBER] }, + }, + }); + + if (!canModify) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + ctx.markAccessControlRun(); +} + export const requireCanViewDataset = async (datasetId: string, ctx: TRPCContext) => { const dataset = await prisma.dataset.findFirst({ where: { diff --git a/app/src/utils/hooks.ts b/app/src/utils/hooks.ts index 451c7dc..3c1518f 100644 --- a/app/src/utils/hooks.ts +++ b/app/src/utils/hooks.ts @@ -2,6 +2,7 @@ import { useRouter } from "next/router"; import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; import { api } from "~/utils/api"; import { NumberParam, useQueryParam, withDefault } from "use-query-params"; +import { useAppStore } from "~/state/store"; export const useExperiment = () => { const router = useRouter(); @@ -132,3 +133,11 @@ export const useScenario = (scenarioId: string) => { }; export const useVisibleScenarioIds = () => useScenarios().data?.scenarios.map((s) => s.id) ?? []; + +export const useSelectedOrg = () => { + const selectedOrgId = useAppStore((state) => state.selectedOrgId); + return api.organizations.get.useQuery( + { id: selectedOrgId ?? "" }, + { enabled: !!selectedOrgId }, + ); +}; diff --git a/client-libs/typescript/openai/index.ts b/client-libs/typescript/openai/index.ts index 8c43fed..81df9f3 100644 --- a/client-libs/typescript/openai/index.ts +++ b/client-libs/typescript/openai/index.ts @@ -1,15 +1,15 @@ -import * as OpenAI from "openai-beta"; +import * as openai from "openai-beta"; import { readEnv } from "openai-beta/core"; // Anything we don't override we want to pass through to openai directly export * as openai from "openai-beta"; -interface ClientOptions extends OpenAI.ClientOptions { +interface ClientOptions extends openai.ClientOptions { openPipeApiKey?: string; openPipeBaseUrl?: string; } -export class OpenPipe extends OpenAI.OpenAI { +export class OpenAI extends openai.OpenAI { openPipeApiKey: string; openPipeBaseUrl: string;