Openstatus www.openstatus.dev

chore: move workspace schema to trpc context (#424)

* chore: move page schema to context

* chore: remove comment

* wip:

* fix: pointer events none issue on alert dialog

* fix: build

* test: update ctx

* chore: remove slug from query

* chore: check many-to-many updates

* wip: clean up

authored by

Maximilian Kaske and committed by
GitHub
eee0bbbf da0ff839

+530 -933
-1
apps/web/next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 - /// <reference types="next/navigation-types/compat/navigation" /> 4 3 5 4 // NOTE: This file should not be edited 6 5 // see https://nextjs.org/docs/basic-features/typescript for more information.
+6 -8
apps/web/src/app/app/(dashboard)/[workspaceSlug]/incidents/edit/page.tsx
··· 26 26 } 27 27 28 28 const { id } = search.data; 29 - const { workspaceSlug } = params; 30 29 31 30 const incident = id 32 31 ? await api.incident.getIncidentById.query({ ··· 34 33 }) 35 34 : undefined; 36 35 37 - const monitors = await api.monitor.getMonitorsByWorkspace.query({ 38 - workspaceSlug, 39 - }); 36 + const monitors = await api.monitor.getMonitorsByWorkspace.query(); 40 37 41 - const pages = await api.page.getPagesByWorkspace.query({ workspaceSlug }); 38 + const pages = await api.page.getPagesByWorkspace.query(); 42 39 43 40 return ( 44 41 <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 45 42 <Header title="Incident" description="Upsert your incident." /> 46 43 <div className="col-span-full"> 47 44 <IncidentForm 48 - workspaceSlug={workspaceSlug} 49 45 monitors={monitors} 50 46 pages={pages} 51 47 defaultValues={ 52 48 incident 53 - ? { 49 + ? // TODO: we should move the mapping to the trpc layer 50 + // so we don't have to do this in the UI 51 + // it should be something like defaultValues={incident} 52 + { 54 53 ...incident, 55 - workspaceSlug, 56 54 monitors: incident?.monitorsToIncidents.map( 57 55 ({ monitorId }) => monitorId, 58 56 ),
+2 -8
apps/web/src/app/app/(dashboard)/[workspaceSlug]/incidents/page.tsx
··· 21 21 }: { 22 22 params: { workspaceSlug: string }; 23 23 }) { 24 - const incidents = await api.incident.getIncidentByWorkspace.query({ 25 - workspaceSlug: params.workspaceSlug, 26 - }); 24 + const incidents = await api.incident.getIncidentByWorkspace.query(); 27 25 return ( 28 26 <div className="grid min-h-full grid-cols-1 grid-rows-[auto,1fr,auto] gap-6 md:grid-cols-1 md:gap-8"> 29 27 <Header ··· 66 64 New Update 67 65 </Link> 68 66 </Button>, 69 - <ActionButton 70 - key="action-button" 71 - id={incident.id} 72 - workspaceSlug={params.workspaceSlug} 73 - />, 67 + <ActionButton key="action-button" id={incident.id} />, 74 68 ]} 75 69 > 76 70 <div className="grid gap-4">
+1 -9
apps/web/src/app/app/(dashboard)/[workspaceSlug]/incidents/update/edit/page.tsx
··· 42 42 /> 43 43 <div className="col-span-full"> 44 44 <IncidentUpdateForm 45 - workspaceSlug={params.workspaceSlug} 46 45 incidentId={incidentId} 47 - defaultValues={ 48 - incidentUpdate 49 - ? { 50 - workspaceSlug: params.workspaceSlug, 51 - ...incidentUpdate, 52 - } 53 - : undefined 54 - } 46 + defaultValues={incidentUpdate || undefined} 55 47 /> 56 48 </div> 57 49 </div>
+3 -7
apps/web/src/app/app/(dashboard)/[workspaceSlug]/integrations/page.tsx
··· 1 - "use client"; 2 - 3 1 import * as React from "react"; 4 2 5 - import { Badge, Button } from "@openstatus/ui"; 3 + import { Button } from "@openstatus/ui"; 6 4 7 5 import { Container } from "@/components/dashboard/container"; 8 6 import { Header } from "@/components/dashboard/header"; 9 - import { api } from "@/trpc/client"; 7 + import { api } from "@/trpc/server"; 10 8 11 9 export default async function IncidentPage({ 12 10 params, 13 11 }: { 14 12 params: { workspaceSlug: string }; 15 13 }) { 16 - const workspace = await api.workspace.getWorkspace.query({ 17 - slug: params.workspaceSlug, 18 - }); 14 + const workspace = await api.workspace.getWorkspace.query(); 19 15 20 16 return ( 21 17 <div className="grid gap-6 md:grid-cols-2 md:gap-8">
+1 -3
apps/web/src/app/app/(dashboard)/[workspaceSlug]/layout.tsx
··· 15 15 children: React.ReactNode; 16 16 params: { workspaceSlug: string }; 17 17 }) { 18 - const workspace = await api.workspace.getWorkspace.query({ 19 - slug: params.workspaceSlug, 20 - }); 18 + const workspace = await api.workspace.getWorkspace.query(); 21 19 if (!workspace) return notFound(); // TODO: discuss if we should move to middleware 22 20 23 21 return (
+1 -1
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/[id]/data/page.tsx
··· 38 38 const id = params.id; 39 39 const search = searchParamsSchema.safeParse(searchParams); 40 40 41 - const monitor = await api.monitor.getMonitorByID.query({ 41 + const monitor = await api.monitor.getMonitorById.query({ 42 42 id: Number(id), 43 43 }); 44 44
+1 -1
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/_components/action-button.tsx
··· 40 40 startTransition(async () => { 41 41 try { 42 42 if (!props.id) return; 43 - await api.monitor.deleteMonitor.mutate({ id: props.id }); 43 + await api.monitor.delete.mutate({ id: props.id }); 44 44 toast("deleted"); 45 45 router.refresh(); 46 46 setAlertOpen(false);
+6 -9
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/edit/page.tsx
··· 26 26 } 27 27 28 28 const { id } = search.data; 29 - const { workspaceSlug } = params; 30 29 31 - const monitor = id && (await api.monitor.getMonitorByID.query({ id })); 32 - const workspace = await api.workspace.getWorkspace.query({ 33 - slug: workspaceSlug, 34 - }); 30 + const monitor = id 31 + ? await api.monitor.getMonitorById.query({ id }) 32 + : undefined; 33 + const workspace = await api.workspace.getWorkspace.query(); 35 34 36 35 const monitorNotifications = id 37 36 ? await api.monitor.getAllNotificationsForMonitor.query({ ··· 40 39 : []; 41 40 42 41 const notifications = 43 - await api.notification.getNotificationsByWorkspace.query({ 44 - workspaceSlug, 45 - }); 42 + await api.notification.getNotificationsByWorkspace.query(); 46 43 47 44 return ( 48 45 <div className="grid gap-6 md:grid-cols-2 md:gap-8"> ··· 61 58 : undefined 62 59 } 63 60 plan={workspace?.plan} 64 - {...{ workspaceSlug, notifications }} 61 + notifications={notifications} 65 62 /> 66 63 </div> 67 64 </div>
+2 -6
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/page.tsx
··· 18 18 }: { 19 19 params: { workspaceSlug: string }; 20 20 }) { 21 - const monitors = await api.monitor.getMonitorsByWorkspace.query({ 22 - workspaceSlug: params.workspaceSlug, 23 - }); 24 - const workspace = await api.workspace.getWorkspace.query({ 25 - slug: params.workspaceSlug, 26 - }); 21 + const monitors = await api.monitor.getMonitorsByWorkspace.query(); 22 + const workspace = await api.workspace.getWorkspace.query(); 27 23 28 24 const isLimit = 29 25 (monitors?.length || 0) >=
+4 -6
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/edit/page.tsx
··· 27 27 28 28 const { id } = search.data; 29 29 30 - const notification = 31 - id && (await api.notification.getNotificationById.query({ id })); 30 + const notification = id 31 + ? await api.notification.getNotificationById.query({ id }) 32 + : undefined; 32 33 33 34 return ( 34 35 <div className="grid gap-6 md:grid-cols-2 md:gap-8"> ··· 41 42 } 42 43 /> 43 44 <div className="col-span-full"> 44 - <NotificationForm 45 - workspaceSlug={params.workspaceSlug} 46 - defaultValues={notification || undefined} 47 - /> 45 + <NotificationForm defaultValues={notification} /> 48 46 </div> 49 47 </div> 50 48 );
+1 -3
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/page.tsx
··· 16 16 params: { workspaceSlug: string }; 17 17 }) { 18 18 const notifications = 19 - await api.notification.getNotificationsByWorkspace.query({ 20 - workspaceSlug: params.workspaceSlug, 21 - }); 19 + await api.notification.getNotificationsByWorkspace.query(); 22 20 23 21 return ( 24 22 <div className="grid min-h-full grid-cols-1 grid-rows-[auto,1fr,auto] gap-6 md:grid-cols-2 md:gap-8">
+1 -3
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/page.tsx
··· 19 19 }: { 20 20 params: { workspaceSlug: string }; 21 21 }) { 22 - const data = await api.workspace.getWorkspace.query({ 23 - slug: params.workspaceSlug, 24 - }); 22 + const data = await api.workspace.getWorkspace.query(); 25 23 26 24 if (!data) { 27 25 return <>Workspace not found</>;
+1 -1
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/_components/action-button.tsx
··· 41 41 startTransition(async () => { 42 42 try { 43 43 if (!page.id) return; 44 - await api.page.deletePage.mutate({ id: page.id }); 44 + await api.page.delete.mutate({ id: page.id }); 45 45 toast("deleted"); 46 46 router.refresh(); 47 47 setAlertOpen(false);
+3 -10
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/edit/page.tsx
··· 30 30 } 31 31 32 32 const { id } = search.data; 33 - const { workspaceSlug } = params; 34 33 35 34 // TODO: too many requests to db 36 - const page = id && (await api.page.getPageByID.query({ id })); 37 - const monitors = await api.monitor.getMonitorsByWorkspace.query({ 38 - workspaceSlug, 39 - }); 40 - const workspace = await api.workspace.getWorkspace.query({ 41 - slug: workspaceSlug, 42 - }); 35 + const page = id ? await api.page.getPageById.query({ id }) : undefined; 36 + const monitors = await api.monitor.getMonitorsByWorkspace.query(); 37 + const workspace = await api.workspace.getWorkspace.query(); 43 38 44 39 const isProPlan = workspace?.plan === "pro"; 45 40 ··· 67 62 <TabsContent value="settings" className="pt-3"> 68 63 <StatusPageForm 69 64 allMonitors={monitors} 70 - workspaceSlug={params.workspaceSlug} 71 65 defaultValues={ 72 66 page 73 67 ? { 74 68 ...page, 75 - workspaceSlug: params.workspaceSlug, 76 69 monitors: page.monitorsToPages.map( 77 70 ({ monitor }) => monitor.id, 78 71 ),
+3 -9
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/page.tsx
··· 20 20 }: { 21 21 params: { workspaceSlug: string }; 22 22 }) { 23 - const pages = await api.page.getPagesByWorkspace.query({ 24 - workspaceSlug: params.workspaceSlug, 25 - }); 26 - const monitors = await api.monitor.getMonitorsByWorkspace.query({ 27 - workspaceSlug: params.workspaceSlug, 28 - }); 23 + const pages = await api.page.getPagesByWorkspace.query(); 24 + const monitors = await api.monitor.getMonitorsByWorkspace.query(); 29 25 30 - const workspace = await api.workspace.getWorkspace.query({ 31 - slug: params.workspaceSlug, 32 - }); 26 + const workspace = await api.workspace.getWorkspace.query(); 33 27 34 28 const isLimit = 35 29 (pages?.length || 0) >=
+2 -4
apps/web/src/app/app/(dashboard)/onboarding/page.tsx
··· 45 45 ); 46 46 } 47 47 48 - const allMonitors = await api.monitor.getMonitorsByWorkspace.query({ 49 - workspaceSlug, 50 - }); 48 + const allMonitors = await api.monitor.getMonitorsByWorkspace.query(); 51 49 52 50 if (!monitorId) { 53 51 return ( ··· 63 61 /> 64 62 <div className="grid h-full w-full gap-6 md:grid-cols-3 md:gap-8"> 65 63 <div className="md:col-span-2"> 66 - <MonitorForm {...{ workspaceSlug }} /> 64 + <MonitorForm /> 67 65 </div> 68 66 <div className="hidden h-full md:col-span-1 md:block"> 69 67 <Description step="monitor" />
+4 -4
apps/web/src/components/data-table/monitor/data-table-row-actions.tsx
··· 6 6 import type { Row } from "@tanstack/react-table"; 7 7 import { MoreHorizontal } from "lucide-react"; 8 8 9 - import { insertMonitorSchema } from "@openstatus/db/src/schema"; 9 + import { selectMonitorSchema } from "@openstatus/db/src/schema"; 10 10 import { 11 11 AlertDialog, 12 12 AlertDialogAction, ··· 36 36 export function DataTableRowActions<TData>({ 37 37 row, 38 38 }: DataTableRowActionsProps<TData>) { 39 - const monitor = insertMonitorSchema.parse(row.original); 39 + const monitor = selectMonitorSchema.parse(row.original); 40 40 const router = useRouter(); 41 41 const { toast } = useToastAction(); 42 42 const [alertOpen, setAlertOpen] = React.useState(false); ··· 46 46 startTransition(async () => { 47 47 try { 48 48 if (!monitor.id) return; 49 - await api.monitor.deleteMonitor.mutate({ id: monitor.id }); 49 + await api.monitor.delete.mutate({ id: monitor.id }); 50 50 toast("deleted"); 51 51 router.refresh(); 52 52 setAlertOpen(false); ··· 61 61 try { 62 62 const { jobType, ...rest } = monitor; 63 63 if (!monitor.id) return; 64 - await api.monitor.updateMonitor.mutate({ 64 + await api.monitor.update.mutate({ 65 65 ...rest, 66 66 active: !monitor.active, 67 67 });
+1 -1
apps/web/src/components/data-table/status-page/data-table-row-actions.tsx
··· 45 45 startTransition(async () => { 46 46 try { 47 47 if (!page.id) return; 48 - await api.page.deletePage.mutate({ id: page.id }); 48 + await api.page.delete.mutate({ id: page.id }); 49 49 toast("deleted"); 50 50 router.refresh(); 51 51 setAlertOpen(false);
+2 -12
apps/web/src/components/forms/incident-form.tsx
··· 48 48 defaultValues?: InsertIncident; 49 49 monitors?: Monitor[]; 50 50 pages?: Page[]; 51 - workspaceSlug: string; 52 51 } 53 52 54 - export function IncidentForm({ 55 - defaultValues, 56 - monitors, 57 - pages, 58 - workspaceSlug, 59 - }: Props) { 53 + export function IncidentForm({ defaultValues, monitors, pages }: Props) { 60 54 const form = useForm<InsertIncident>({ 61 55 resolver: zodResolver(insertIncidentSchema), 62 56 defaultValues: defaultValues ··· 66 60 status: defaultValues.status, 67 61 monitors: defaultValues.monitors, 68 62 pages: defaultValues.pages, 69 - workspaceSlug, 70 63 // include update on creation 71 64 message: defaultValues.message, 72 65 date: defaultValues.date, ··· 74 67 : { 75 68 status: "investigating", 76 69 date: new Date(), 77 - workspaceSlug, 78 70 }, 79 71 }); 80 72 const router = useRouter(); ··· 88 80 await api.incident.updateIncident.mutate({ ...props }); 89 81 } else { 90 82 // or use createIncident to create automaticaaly an IncidentUpdate? 91 - const { message, date, status, workspaceSlug, ...rest } = props; 83 + const { message, date, status, ...rest } = props; 92 84 const incident = await api.incident.createIncident.mutate({ 93 - workspaceSlug, 94 85 status, 95 86 message, 96 87 ...rest, ··· 101 92 message, 102 93 date, 103 94 status, 104 - workspaceSlug, 105 95 incidentId: incident.id, 106 96 }); 107 97 }
+1 -7
apps/web/src/components/forms/incident-update-form.tsx
··· 39 39 40 40 interface Props { 41 41 defaultValues?: InsertIncidentUpdate; 42 - workspaceSlug: string; 43 42 incidentId: number; 44 43 } 45 44 46 - export function IncidentUpdateForm({ 47 - defaultValues, 48 - workspaceSlug, 49 - incidentId, 50 - }: Props) { 45 + export function IncidentUpdateForm({ defaultValues, incidentId }: Props) { 51 46 const form = useForm<InsertIncidentUpdate>({ 52 47 resolver: zodResolver(insertIncidentUpdateSchema), 53 48 defaultValues: { ··· 56 51 message: defaultValues?.message, 57 52 date: defaultValues?.date || new Date(), 58 53 incidentId, 59 - workspaceSlug, 60 54 }, 61 55 }); 62 56 const router = useRouter();
+4 -12
apps/web/src/components/forms/monitor-form.tsx
··· 81 81 82 82 interface Props { 83 83 defaultValues?: InsertMonitor; 84 - workspaceSlug: string; 85 84 plan?: WorkspacePlan; 86 85 notifications?: Notification[]; 87 86 } 88 87 89 88 export function MonitorForm({ 90 89 defaultValues, 91 - workspaceSlug, 92 90 plan = "free", 93 91 notifications, 94 92 }: Props) { ··· 100 98 description: defaultValues?.description || "", 101 99 periodicity: defaultValues?.periodicity || "30m", 102 100 active: defaultValues?.active ?? true, 103 - id: defaultValues?.id || undefined, 101 + id: defaultValues?.id || 0, 104 102 regions: 105 103 defaultValues?.regions || (flyRegions as Writeable<typeof flyRegions>), 106 104 headers: Boolean(defaultValues?.headers?.length) ··· 128 126 const handleDataUpdateOrInsertion = async (props: InsertMonitor) => { 129 127 try { 130 128 if (defaultValues) { 131 - await api.monitor.updateMonitor.mutate(props); 129 + await api.monitor.update.mutate(props); 132 130 } else { 133 - const monitor = await api.monitor.createMonitor.mutate({ 134 - data: props, 135 - workspaceSlug, 136 - }); 131 + const monitor = await api.monitor.create.mutate(props); 137 132 const id = monitor?.id || null; 138 133 router.replace(`?${updateSearchParams({ id })}`); 139 134 } ··· 707 702 Get alerted when your endpoint is down. 708 703 </DialogDescription> 709 704 </DialogHeader> 710 - <NotificationForm 711 - onSubmit={() => setOpenDialog(false)} 712 - {...{ workspaceSlug }} 713 - /> 705 + <NotificationForm onSubmit={() => setOpenDialog(false)} /> 714 706 </DialogContent> 715 707 <FailedPingAlertConfirmation 716 708 monitor={form.getValues()}
+2 -5
apps/web/src/components/forms/notification-form.tsx
··· 86 86 87 87 interface Props { 88 88 defaultValues?: InsertNotification; 89 - workspaceSlug: string; 90 89 onSubmit?: () => void; 91 90 } 92 91 93 92 export function NotificationForm({ 94 - workspaceSlug, 95 93 defaultValues, 96 94 onSubmit: onExternalSubmit, 97 95 }: Props) { ··· 118 116 startTransition(async () => { 119 117 try { 120 118 if (defaultValues) { 121 - await api.notification.updateNotification.mutate({ 119 + await api.notification.update.mutate({ 122 120 provider, 123 121 data: JSON.stringify(setProviderData(provider, data)), 124 122 ...rest, 125 123 }); 126 124 } else { 127 - await api.notification.createNotification.mutate({ 128 - workspaceSlug, 125 + await api.notification.create.mutate({ 129 126 provider, 130 127 data: JSON.stringify(setProviderData(provider, data)), 131 128 ...rest,
+3 -11
apps/web/src/components/forms/status-page-form.tsx
··· 36 36 import { api } from "@/trpc/client"; 37 37 import { LoadingAnimation } from "../loading-animation"; 38 38 39 - // REMINDER: only use the props you need! 40 - 41 39 interface Props { 42 40 defaultValues?: InsertPage; 43 - workspaceSlug: string; 44 41 allMonitors?: Monitor[]; 45 42 /** 46 43 * gives the possibility to check all the monitors ··· 54 51 55 52 export function StatusPageForm({ 56 53 defaultValues, 57 - workspaceSlug, 58 54 allMonitors, 59 55 checkAllMonitors, 60 56 nextUrl, ··· 73 69 ? allMonitors.map(({ id }) => id) 74 70 : defaultValues?.monitors ?? [], 75 71 customDomain: defaultValues?.customDomain || "", 76 - workspaceSlug: "", 77 72 icon: defaultValues?.icon || "", 78 73 }, 79 74 }); ··· 121 116 122 117 const onSubmit = async ({ ...props }: InsertPage) => { 123 118 startTransition(async () => { 124 - // TODO: we could use an upsertPage function instead - insert if not exist otherwise update 125 119 try { 126 120 if (defaultValues) { 127 - await api.page.updatePage.mutate(props); 121 + await api.page.update.mutate(props); 128 122 } else { 129 - const page = await api.page.createPage.mutate({ 130 - ...props, 131 - workspaceSlug, 132 - }); 123 + const page = await api.page.create.mutate(props); 133 124 const id = page?.id || null; 134 125 router.replace(`?${updateSearchParams({ id })}`); // to stay on same page and enable 'Advanced' tab 135 126 } 127 + 136 128 defaultToast({ 137 129 title: "Saved successfully.", 138 130 description: "Your status page is ready to go.",
+1 -1
apps/web/src/components/marketing/stats.tsx
··· 6 6 export async function Stats() { 7 7 const tbTotalStats = await getHomeStatsData({}); 8 8 const tbLastHourStats = await getHomeStatsData({ period: "1h" }); 9 - // FIXME: 9 + // FIXME: is it time? 10 10 // const totalActiveMonitors = await api.monitor.getTotalActiveMonitors.query( 11 11 // {}, 12 12 // );
+1 -4
apps/web/src/components/modals/notification-dialog.tsx
··· 34 34 Get alerted when your endpoint is down. 35 35 </DialogDescription> 36 36 </DialogHeader> 37 - <NotificationForm 38 - onSubmit={() => setOpen(false)} 39 - {...{ workspaceSlug }} 40 - /> 37 + <NotificationForm onSubmit={() => setOpen(false)} /> 41 38 </DialogContent> 42 39 </Dialog> 43 40 );
+47
packages/api/src/analytics.ts
··· 1 + import { analytics, trackAnalytics } from "@openstatus/analytics"; 2 + import { User } from "@openstatus/db/src/schema"; 3 + 4 + export async function trackNewPage(user: User, config: { slug: string }) { 5 + await analytics.identify(user.id, { 6 + userId: user.id, 7 + email: user.email, 8 + }); 9 + await trackAnalytics({ 10 + event: "Page Created", 11 + ...config, 12 + }); 13 + } 14 + 15 + export async function trackNewMonitor( 16 + user: User, 17 + config: { url: string; periodicity: string }, 18 + ) { 19 + await analytics.identify(user.id, { 20 + userId: user.id, 21 + email: user.email, 22 + }); 23 + await trackAnalytics({ 24 + event: "Monitor Created", 25 + ...config, 26 + }); 27 + } 28 + 29 + export async function trackNewUser() {} 30 + 31 + export async function trackNewWorkspace() {} 32 + 33 + export async function trackNewNotification( 34 + user: User, 35 + config: { provider: string }, 36 + ) { 37 + await analytics.identify(user.id, { 38 + userId: user.id, 39 + email: user.email, 40 + }); 41 + await trackAnalytics({ 42 + event: "Notification Created", 43 + ...config, 44 + }); 45 + } 46 + 47 + export async function trackNewIncident() {}
+73 -162
packages/api/src/router/incident.ts
··· 12 12 selectIncidentSchema, 13 13 selectIncidentUpdateSchema, 14 14 selectMonitorSchema, 15 - user, 16 - usersToWorkspaces, 17 - workspace, 18 15 } from "@openstatus/db/src/schema"; 19 16 20 17 import { createTRPCRouter, protectedProcedure } from "../trpc"; 21 - import { hasUserAccessToWorkspace } from "./utils"; 22 18 23 19 export const incidentRouter = createTRPCRouter({ 24 20 createIncident: protectedProcedure 25 21 .input(insertIncidentSchema) 26 22 .mutation(async (opts) => { 27 - const result = await hasUserAccessToWorkspace({ 28 - workspaceSlug: opts.input.workspaceSlug, 29 - ctx: opts.ctx, 30 - }); 31 - if (!result) return; 32 - 33 - const { 34 - id, 35 - workspaceSlug, 36 - monitors, 37 - pages, 38 - date, 39 - message, 40 - ...incidentInput 41 - } = opts.input; 23 + const { id, monitors, pages, date, message, ...incidentInput } = 24 + opts.input; 42 25 43 26 const newIncident = await opts.ctx.db 44 27 .insert(incident) 45 28 .values({ 46 - workspaceId: result.workspace.id, 29 + workspaceId: opts.ctx.workspace.id, 47 30 ...incidentInput, 48 31 }) 49 32 .returning() 50 33 .get(); 51 34 52 - // if (monitors && monitors.length > 0) { 53 - // // We should make sure the user has access to the monitors 54 - // const allMonitors = await opts.ctx.db.query.monitor.findMany({ 55 - // where: inArray(monitor.id, monitors), 56 - // }); 57 - // const values = allMonitors.map((monitor) => ({ 58 - // monitorId: monitor.id, 59 - // incidentId: newIncident.id, 60 - // })); 61 - // await opts.ctx.db.insert(monitorsToIncidents).values(values).run(); 62 - // } 63 - 64 - if (monitors.length > 0) { 35 + if (Boolean(monitors.length)) { 65 36 await opts.ctx.db 66 37 .insert(monitorsToIncidents) 67 38 .values( ··· 74 45 .get(); 75 46 } 76 47 77 - if (pages.length > 0) { 48 + if (Boolean(pages.length)) { 78 49 await opts.ctx.db 79 50 .insert(pagesToIncidents) 80 51 .values( ··· 93 64 createIncidentUpdate: protectedProcedure 94 65 .input(insertIncidentUpdateSchema) 95 66 .mutation(async (opts) => { 96 - // Check if user has access to workspace 97 - const data = await hasUserAccessToWorkspace({ 98 - workspaceSlug: opts.input.workspaceSlug, 99 - ctx: opts.ctx, 100 - }); 101 - if (!data) return; 102 - 103 67 // update parent incident with latest status 104 68 await opts.ctx.db 105 69 .update(incident) 106 70 .set({ status: opts.input.status, updatedAt: new Date() }) 107 - .where(eq(incident.id, opts.input.incidentId)) 71 + .where( 72 + and( 73 + eq(incident.id, opts.input.incidentId), 74 + eq(incident.workspaceId, opts.ctx.workspace.id), 75 + ), 76 + ) 108 77 .returning() 109 78 .get(); 110 79 111 - const { workspaceSlug, id, ...incidentUpdateInput } = opts.input; 80 + const { id, ...incidentUpdateInput } = opts.input; 112 81 return await opts.ctx.db 113 82 .insert(incidentUpdate) 114 83 .values(incidentUpdateInput) ··· 119 88 updateIncident: protectedProcedure 120 89 .input(insertIncidentSchema) 121 90 .mutation(async (opts) => { 122 - const data = await hasUserAccessToWorkspace({ 123 - workspaceSlug: opts.input.workspaceSlug, 124 - ctx: opts.ctx, 125 - }); 126 - if (!data) return; 127 - 128 - const { monitors, pages, workspaceSlug, ...incidentInput } = opts.input; 91 + const { monitors, pages, ...incidentInput } = opts.input; 129 92 130 93 if (!incidentInput.id) return; 131 94 ··· 134 97 const currentIncident = await opts.ctx.db 135 98 .update(incident) 136 99 .set({ title, status, updatedAt: new Date() }) 137 - .where(eq(incident.id, incidentInput.id)) 100 + .where( 101 + and( 102 + eq(incident.id, incidentInput.id), 103 + eq(incident.workspaceId, opts.ctx.workspace.id), 104 + ), 105 + ) 138 106 .returning() 139 107 .get(); 140 108 ··· 144 112 .where(eq(monitorsToIncidents.incidentId, currentIncident.id)) 145 113 .all(); 146 114 147 - const currentMonitorToIncidentsIds = currentMonitorToIncidents.map( 148 - ({ monitorId }) => monitorId, 149 - ); 150 - 151 - const removedMonitors = currentMonitorToIncidentsIds.filter( 152 - (x) => !monitors?.includes(x), 115 + const addedMonitors = monitors.filter( 116 + (x) => 117 + !currentMonitorToIncidents 118 + .map(({ monitorId }) => monitorId) 119 + .includes(x), 153 120 ); 154 121 155 - const addedMonitors = monitors?.filter( 156 - (x) => !currentMonitorToIncidentsIds?.includes(x), 157 - ); 158 - 159 - if (addedMonitors && addedMonitors.length > 0) { 122 + if (Boolean(addedMonitors.length)) { 160 123 const values = addedMonitors.map((monitorId) => ({ 161 124 monitorId: monitorId, 162 125 incidentId: currentIncident.id, ··· 165 128 await opts.ctx.db.insert(monitorsToIncidents).values(values).run(); 166 129 } 167 130 168 - if (removedMonitors && removedMonitors.length > 0) { 131 + const removedMonitors = currentMonitorToIncidents 132 + .map(({ monitorId }) => monitorId) 133 + .filter((x) => !monitors?.includes(x)); 134 + 135 + if (Boolean(removedMonitors.length)) { 169 136 await opts.ctx.db 170 137 .delete(monitorsToIncidents) 171 138 .where( ··· 183 150 .where(eq(pagesToIncidents.incidentId, currentIncident.id)) 184 151 .all(); 185 152 186 - const currentPagesToIncidentsIds = currentPagesToIncidents.map( 187 - ({ pageId }) => pageId, 188 - ); 189 - 190 - const removedPages = currentPagesToIncidentsIds.filter( 191 - (x) => !pages?.includes(x), 192 - ); 193 - 194 153 const addedPages = pages?.filter( 195 - (x) => !currentPagesToIncidentsIds?.includes(x), 154 + (x) => 155 + !currentPagesToIncidents.map(({ pageId }) => pageId)?.includes(x), 196 156 ); 197 157 198 - if (addedPages && addedPages.length > 0) { 158 + if (Boolean(addedPages.length)) { 199 159 const values = addedPages.map((pageId) => ({ 200 160 pageId, 201 161 incidentId: currentIncident.id, ··· 204 164 await opts.ctx.db.insert(pagesToIncidents).values(values).run(); 205 165 } 206 166 207 - if (removedPages && removedPages.length > 0) { 167 + const removedPages = currentPagesToIncidents 168 + .map(({ pageId }) => pageId) 169 + .filter((x) => !pages?.includes(x)); 170 + 171 + if (Boolean(removedPages.length)) { 208 172 await opts.ctx.db 209 173 .delete(pagesToIncidents) 210 174 .where( ··· 222 186 updateIncidentUpdate: protectedProcedure 223 187 .input(insertIncidentUpdateSchema) 224 188 .mutation(async (opts) => { 225 - const currentWorkspace = await opts.ctx.db 226 - .select() 227 - .from(workspace) 228 - .where(eq(workspace.slug, opts.input.workspaceSlug)) 229 - .get(); 230 - if (!currentWorkspace) return; 231 - const currentUser = opts.ctx.db 232 - .select() 233 - .from(user) 234 - .where(eq(user.tenantId, opts.ctx.auth.userId)) 235 - .as("currentUser"); 236 - const result = await opts.ctx.db 237 - .select() 238 - .from(usersToWorkspaces) 239 - .where(eq(usersToWorkspaces.workspaceId, currentWorkspace.id)) 240 - .innerJoin(currentUser, eq(usersToWorkspaces.userId, currentUser.id)) 241 - .get(); 242 - 243 - if (!result) return; 244 - 245 - const { workspaceSlug, ...incidentUpdateInput } = opts.input; 189 + const incidentUpdateInput = opts.input; 246 190 247 191 if (!incidentUpdateInput.id) return; 248 192 ··· 259 203 deleteIncident: protectedProcedure 260 204 .input(z.object({ id: z.number() })) 261 205 .mutation(async (opts) => { 262 - // TODO: this looks not very affective 263 - const currentUser = await opts.ctx.db 264 - .select() 265 - .from(user) 266 - .where(eq(user.tenantId, opts.ctx.auth.userId)) 267 - .get(); 268 - if (!currentUser) return; 269 - const result = await opts.ctx.db 270 - .select() 271 - .from(usersToWorkspaces) 272 - .where(eq(usersToWorkspaces.userId, currentUser.id)) 273 - .all(); 274 - 275 - const workspaceIds = result.map((workspace) => workspace.workspaceId); 276 - 277 206 const incidentToDelete = await opts.ctx.db 278 207 .select() 279 208 .from(incident) 280 209 .where( 281 210 and( 282 211 eq(incident.id, opts.input.id), 283 - inArray(incident.workspaceId, workspaceIds), 212 + eq(incident.workspaceId, opts.ctx.workspace.id), 284 213 ), 285 214 ) 286 215 .get(); ··· 295 224 deleteIncidentUpdate: protectedProcedure 296 225 .input(z.object({ id: z.number() })) 297 226 .mutation(async (opts) => { 298 - const currentUser = await opts.ctx.db 299 - .select() 300 - .from(user) 301 - .where(eq(user.tenantId, opts.ctx.auth.userId)) 302 - .get(); 303 - if (!currentUser) return; 304 - const result = await opts.ctx.db 305 - .select() 306 - .from(usersToWorkspaces) 307 - .where(eq(usersToWorkspaces.userId, currentUser.id)) 308 - .all(); 309 - 310 - const workspaceIds = result.map((workspace) => workspace.workspaceId); 311 - 312 227 const incidentUpdateToDelete = await opts.ctx.db 313 228 .select() 314 229 .from(incidentUpdate) ··· 341 256 }); 342 257 343 258 const data = await opts.ctx.db.query.incident.findFirst({ 344 - where: eq(incident.id, opts.input.id), 259 + where: and( 260 + eq(incident.id, opts.input.id), 261 + eq(incident.workspaceId, opts.ctx.workspace.id), 262 + ), 345 263 with: { 346 264 monitorsToIncidents: true, 347 265 pagesToIncidents: true, ··· 360 278 .input(z.object({ id: z.number() })) 361 279 .query(async (opts) => { 362 280 const data = await opts.ctx.db.query.incidentUpdate.findFirst({ 363 - where: eq(incidentUpdate.id, opts.input.id), 281 + where: and(eq(incidentUpdate.id, opts.input.id)), 364 282 }); 365 283 return selectIncidentUpdateSchema.parse(data); 366 284 }), 367 285 368 - getIncidentByWorkspace: protectedProcedure 369 - .input(z.object({ workspaceSlug: z.string() })) 370 - .query(async (opts) => { 371 - const data = await hasUserAccessToWorkspace({ 372 - workspaceSlug: opts.input.workspaceSlug, 373 - ctx: opts.ctx, 374 - }); 375 - if (!data) return; 376 - 377 - const selectIncidentSchemaWithRelation = selectIncidentSchema.extend({ 378 - status: incidentStatusSchema.default("investigating"), // TODO: remove! 379 - monitorsToIncidents: z 380 - .array( 381 - z.object({ 382 - incidentId: z.number(), 383 - monitorId: z.number(), 384 - monitor: selectMonitorSchema, 385 - }), 386 - ) 387 - .default([]), 388 - incidentUpdates: z.array(selectIncidentUpdateSchema), 389 - }); 286 + getIncidentByWorkspace: protectedProcedure.query(async (opts) => { 287 + // FIXME: can we get rid of that? 288 + const selectIncidentSchemaWithRelation = selectIncidentSchema.extend({ 289 + status: incidentStatusSchema.default("investigating"), // TODO: remove! 290 + monitorsToIncidents: z 291 + .array( 292 + z.object({ 293 + incidentId: z.number(), 294 + monitorId: z.number(), 295 + monitor: selectMonitorSchema, 296 + }), 297 + ) 298 + .default([]), 299 + incidentUpdates: z.array(selectIncidentUpdateSchema), 300 + }); 390 301 391 - const result = await opts.ctx.db.query.incident.findMany({ 392 - where: eq(incident.workspaceId, data.workspace.id), 393 - with: { 394 - monitorsToIncidents: { with: { monitor: true } }, 395 - incidentUpdates: { 396 - orderBy: (incidentUpdate, { desc }) => [ 397 - desc(incidentUpdate.createdAt), 398 - ], 399 - }, 302 + const result = await opts.ctx.db.query.incident.findMany({ 303 + where: eq(incident.workspaceId, opts.ctx.workspace.id), 304 + with: { 305 + monitorsToIncidents: { with: { monitor: true } }, 306 + incidentUpdates: { 307 + orderBy: (incidentUpdate, { desc }) => [ 308 + desc(incidentUpdate.createdAt), 309 + ], 400 310 }, 401 - orderBy: (incident, { desc }) => [desc(incident.updatedAt)], 402 - }); 311 + }, 312 + orderBy: (incident, { desc }) => [desc(incident.updatedAt)], 313 + }); 403 314 404 - return z.array(selectIncidentSchemaWithRelation).parse(result); 405 - }), 315 + return z.array(selectIncidentSchemaWithRelation).parse(result); 316 + }), 406 317 });
+3 -10
packages/api/src/router/integration.ts
··· 1 1 import { z } from "zod"; 2 2 3 - import { and, eq, inArray } from "@openstatus/db"; 3 + import { and, eq } from "@openstatus/db"; 4 4 import { 5 5 insertIntegrationSchema, 6 6 integration, 7 7 } from "@openstatus/db/src/schema"; 8 8 9 9 import { createTRPCRouter, protectedProcedure } from "../trpc"; 10 - import { hasUserAccessToWorkspace } from "./utils"; 11 10 12 11 export const integrationRouter = createTRPCRouter({ 13 12 createIntegration: protectedProcedure ··· 15 14 z.object({ workspaceSlug: z.string(), input: insertIntegrationSchema }), 16 15 ) 17 16 .mutation(async (opts) => { 18 - const result = await hasUserAccessToWorkspace({ 19 - workspaceSlug: opts.input.workspaceSlug, 20 - ctx: opts.ctx, 21 - }); 22 - if (!result) return; 23 - 24 17 const exists = await opts.ctx.db 25 18 .select() 26 19 .from(integration) 27 20 .where( 28 21 and( 29 - eq(integration.workspaceId, result.workspace.id), 22 + eq(integration.workspaceId, opts.ctx.workspace.id), 30 23 eq(integration.externalId, opts.input.input.externalId), 31 24 ), 32 25 ) ··· 37 30 } 38 31 await opts.ctx.db 39 32 .insert(integration) 40 - .values({ ...opts.input.input, workspaceId: result.workspace.id }) 33 + .values({ ...opts.input.input, workspaceId: opts.ctx.workspace.id }) 41 34 .returning() 42 35 .get(); 43 36 }),
+89 -136
packages/api/src/router/monitor.ts
··· 1 1 import { TRPCError } from "@trpc/server"; 2 2 import { z } from "zod"; 3 3 4 - import { analytics, trackAnalytics } from "@openstatus/analytics"; 5 4 import { and, eq, inArray, sql } from "@openstatus/db"; 6 5 import { 7 6 insertMonitorSchema, 8 7 monitor, 9 - monitorMethodsSchema, 10 8 monitorPeriodicitySchema, 11 9 monitorsToPages, 12 10 notification, ··· 16 14 } from "@openstatus/db/src/schema"; 17 15 import { allPlans } from "@openstatus/plans"; 18 16 17 + import { trackNewMonitor } from "../analytics"; 19 18 import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; 20 - import { hasUserAccessToMonitor, hasUserAccessToWorkspace } from "./utils"; 21 19 22 20 export const monitorRouter = createTRPCRouter({ 23 - createMonitor: protectedProcedure 24 - .input(z.object({ data: insertMonitorSchema, workspaceSlug: z.string() })) 21 + create: protectedProcedure 22 + .input(insertMonitorSchema) 23 + .output(selectMonitorSchema) 25 24 .mutation(async (opts) => { 26 - const result = await hasUserAccessToWorkspace({ 27 - workspaceSlug: opts.input.workspaceSlug, 28 - ctx: opts.ctx, 29 - }); 30 - 31 - if (!result) return; 32 - 33 - const monitorLimit = result.plan.limits.monitors; 34 - const periodicityLimit = result.plan.limits.periodicity; 25 + const monitorLimit = allPlans[opts.ctx.workspace.plan].limits.monitors; 26 + const periodicityLimit = 27 + allPlans[opts.ctx.workspace.plan].limits.periodicity; 35 28 36 29 const monitorNumbers = ( 37 30 await opts.ctx.db.query.monitor.findMany({ 38 - where: eq(monitor.workspaceId, result.workspace.id), 31 + where: eq(monitor.workspaceId, opts.ctx.workspace.id), 39 32 }) 40 33 ).length; 41 34 ··· 49 42 50 43 // the user is not allowed to use the cron job 51 44 if ( 52 - opts.input.data?.periodicity && 53 - !periodicityLimit.includes(opts.input.data?.periodicity) 45 + opts.input.periodicity && 46 + !periodicityLimit.includes(opts.input.periodicity) 54 47 ) { 55 48 throw new TRPCError({ 56 49 code: "FORBIDDEN", 57 50 message: "You reached your cron job limits.", 58 51 }); 59 52 } 53 + 60 54 // FIXME: this is a hotfix 61 - const { regions, headers, notifications, ...data } = opts.input.data; 55 + const { regions, headers, notifications, id, ...data } = opts.input; 62 56 63 57 const newMonitor = await opts.ctx.db 64 58 .insert(monitor) 65 59 .values({ 60 + // REMINDER: We should explicitly pass the corresponding attributes 61 + // otherwise, unexpected attributes will be passed 66 62 ...data, 67 - workspaceId: result.workspace.id, 63 + workspaceId: opts.ctx.workspace.id, 68 64 regions: regions?.join(","), 69 65 headers: headers ? JSON.stringify(headers) : undefined, 70 66 }) 71 67 .returning() 72 68 .get(); 73 69 74 - if (notifications && notifications.length > 0) { 70 + if (Boolean(notifications.length)) { 71 + // We should make sure the user has access to the notifications 75 72 const allNotifications = await opts.ctx.db.query.notification.findMany({ 76 73 where: inArray(notification.id, notifications), 77 74 }); 75 + 78 76 const values = allNotifications.map((notification) => ({ 79 77 monitorId: newMonitor.id, 80 78 notificationId: notification.id, 81 79 })); 80 + 82 81 await opts.ctx.db.insert(notificationsToMonitors).values(values).run(); 83 82 } 84 83 85 - // TODO: check, do we have to await for the two calls? Will slow down user response for our analytics 86 - await analytics.identify(result.user.id, { 87 - userId: result.user.id, 88 - email: result.user.email, 89 - }); 90 - await trackAnalytics({ 91 - event: "Monitor Created", 84 + trackNewMonitor(opts.ctx.user, { 92 85 url: newMonitor.url, 93 86 periodicity: newMonitor.periodicity, 94 87 }); 95 - return newMonitor; 88 + 89 + return selectMonitorSchema.parse(newMonitor); 96 90 }), 97 91 98 - getMonitorByID: protectedProcedure 92 + getMonitorById: protectedProcedure 99 93 .input(z.object({ id: z.number() })) 100 - // .output(selectMonitorSchema) 94 + .output(selectMonitorSchema) // REMINDER: use more! 101 95 .query(async (opts) => { 102 - if (!opts.input.id) return; 103 - const result = await hasUserAccessToMonitor({ 104 - monitorId: opts.input.id, 105 - ctx: opts.ctx, 106 - }); 107 - if (!result) return; 108 - 109 - const _monitor = selectMonitorSchema.parse(result.monitor); 110 - return _monitor; 96 + const currentMonitor = await opts.ctx.db 97 + .select() 98 + .from(monitor) 99 + .where( 100 + and( 101 + eq(monitor.id, opts.input.id), 102 + eq(monitor.workspaceId, opts.ctx.workspace.id), 103 + ), 104 + ) 105 + .get(); 106 + return selectMonitorSchema.parse(currentMonitor); 111 107 }), 112 108 113 - updateMonitor: protectedProcedure 109 + update: protectedProcedure 114 110 .input(insertMonitorSchema) 115 111 .mutation(async (opts) => { 116 112 if (!opts.input.id) return; 117 - const result = await hasUserAccessToMonitor({ 118 - monitorId: opts.input.id, 119 - ctx: opts.ctx, 120 - }); 121 - if (!result) return; 122 113 123 - const plan = (result.workspace?.plan || "free") as "free" | "pro"; 124 - 125 - const periodicityLimit = allPlans[plan].limits.periodicity; 114 + const periodicityLimit = 115 + allPlans[opts.ctx.workspace.plan].limits.periodicity; 126 116 127 117 // the user is not allowed to use the cron job 128 118 if ( ··· 134 124 message: "You reached your cron job limits.", 135 125 }); 136 126 } 127 + 137 128 const { regions, headers, notifications, ...data } = opts.input; 129 + 138 130 const currentMonitor = await opts.ctx.db 139 131 .update(monitor) 140 132 .set({ ··· 143 135 updatedAt: new Date(), 144 136 headers: headers ? JSON.stringify(headers) : undefined, 145 137 }) 146 - .where(eq(monitor.id, opts.input.id)) 138 + .where( 139 + and( 140 + eq(monitor.id, opts.input.id), 141 + eq(monitor.workspaceId, opts.ctx.workspace.id), 142 + ), 143 + ) 147 144 .returning() 148 145 .get(); 149 146 150 - // TODO: optimize! 151 147 const currentMonitorNotifications = await opts.ctx.db 152 148 .select() 153 149 .from(notificationsToMonitors) 154 150 .where(eq(notificationsToMonitors.monitorId, currentMonitor.id)) 155 151 .all(); 156 152 157 - const currentMonitorNotificationsIds = currentMonitorNotifications.map( 158 - ({ notificationId }) => notificationId, 159 - ); 160 - 161 - const removedNotifications = currentMonitorNotificationsIds.filter( 162 - (x) => !notifications?.includes(x), 163 - ); 164 - 165 - const addedNotifications = notifications?.filter( 166 - (x) => !currentMonitorNotificationsIds?.includes(x), 153 + const addedNotifications = notifications.filter( 154 + (x) => 155 + !currentMonitorNotifications 156 + .map(({ notificationId }) => notificationId) 157 + ?.includes(x), 167 158 ); 168 159 169 - if (addedNotifications && addedNotifications.length > 0) { 160 + if (Boolean(addedNotifications.length)) { 170 161 const values = addedNotifications.map((notificationId) => ({ 171 162 monitorId: currentMonitor.id, 172 163 notificationId, ··· 175 166 await opts.ctx.db.insert(notificationsToMonitors).values(values).run(); 176 167 } 177 168 178 - if (removedNotifications && removedNotifications.length > 0) { 169 + const removedNotifications = currentMonitorNotifications 170 + .map(({ notificationId }) => notificationId) 171 + .filter((x) => !notifications?.includes(x)); 172 + 173 + if (Boolean(removedNotifications.length)) { 179 174 await opts.ctx.db 180 175 .delete(notificationsToMonitors) 181 176 .where( ··· 191 186 } 192 187 }), 193 188 194 - deleteMonitor: protectedProcedure 189 + delete: protectedProcedure 195 190 .input(z.object({ id: z.number() })) 196 191 .mutation(async (opts) => { 197 - const result = await hasUserAccessToMonitor({ 198 - monitorId: opts.input.id, 199 - ctx: opts.ctx, 200 - }); 201 - if (!result) return; 192 + const monitorToDelete = await opts.ctx.db 193 + .select() 194 + .from(monitor) 195 + .where( 196 + and( 197 + eq(monitor.id, opts.input.id), 198 + eq(monitor.workspaceId, opts.ctx.workspace.id), 199 + ), 200 + ) 201 + .get(); 202 + if (!monitorToDelete) return; 203 + 202 204 await opts.ctx.db 203 205 .delete(monitor) 204 - .where(eq(monitor.id, result.monitor.id)) 206 + .where(eq(monitor.id, monitorToDelete.id)) 205 207 .run(); 206 208 }), 207 209 208 210 getMonitorsByWorkspace: protectedProcedure 209 - .input(z.object({ workspaceSlug: z.string() })) 211 + .output(z.array(selectMonitorSchema)) 210 212 .query(async (opts) => { 211 - // Check if user has access to workspace 212 - const data = await hasUserAccessToWorkspace({ 213 - workspaceSlug: opts.input.workspaceSlug, 214 - ctx: opts.ctx, 215 - }); 216 - 217 - if (!data) return; 218 - 219 213 const monitors = await opts.ctx.db 220 214 .select() 221 215 .from(monitor) 222 - .where(eq(monitor.workspaceId, data.workspace.id)) 216 + .where(eq(monitor.workspaceId, opts.ctx.workspace.id)) 223 217 .all(); 224 - // const selectMonitorsArray = selectMonitorSchema.array(); 225 218 226 - try { 227 - return z.array(selectMonitorSchema).parse(monitors); 228 - } catch (e) { 229 - console.log(e); 230 - } 231 - return; 232 - }), 233 - 234 - updateMonitorAdvanced: protectedProcedure 235 - .input( 236 - z.object({ 237 - id: z.number(), 238 - method: monitorMethodsSchema.default("GET"), 239 - body: z.string().default("").optional(), 240 - headers: z 241 - .array(z.object({ key: z.string(), value: z.string() })) 242 - .transform((val) => JSON.stringify(val)) 243 - .default([]) 244 - .optional(), 245 - }), 246 - ) 247 - .mutation(async (opts) => { 248 - const result = await hasUserAccessToMonitor({ 249 - monitorId: opts.input.id, 250 - ctx: opts.ctx, 251 - }); 252 - if (!result) return; 253 - await opts.ctx.db 254 - .update(monitor) 255 - .set({ 256 - method: opts.input.method, 257 - body: opts.input.body, 258 - headers: opts.input.headers, 259 - }) 260 - .where(eq(monitor.id, opts.input.id)) 261 - .run(); 219 + return z.array(selectMonitorSchema).parse(monitors); 262 220 }), 263 221 264 222 getMonitorsForPeriodicity: protectedProcedure ··· 288 246 return allPages; 289 247 }), 290 248 291 - getTotalActiveMonitors: publicProcedure 292 - .input(z.object({})) 293 - .query(async (opts) => { 294 - const monitors = await opts.ctx.db 295 - .select({ count: sql<number>`count(*)` }) 296 - .from(monitor) 297 - .where(eq(monitor.active, true)) 298 - .all(); 299 - if (monitors.length === 0) return 0; 300 - return monitors[0].count; 301 - }), 249 + // rename to getActiveMonitorsCount 250 + getTotalActiveMonitors: publicProcedure.query(async (opts) => { 251 + const monitors = await opts.ctx.db 252 + .select({ count: sql<number>`count(*)` }) 253 + .from(monitor) 254 + .where(eq(monitor.active, true)) 255 + .all(); 256 + if (monitors.length === 0) return 0; 257 + return monitors[0].count; 258 + }), 302 259 303 - // TODO: return the notifications inside of the `getMonitorByID` like we do for the monitors on a status page 260 + // TODO: return the notifications inside of the `getMonitorById` like we do for the monitors on a status page 304 261 getAllNotificationsForMonitor: protectedProcedure 305 262 .input(z.object({ id: z.number() })) 306 263 // .output(selectMonitorSchema) 307 264 .query(async (opts) => { 308 - if (!opts.input.id) return; 309 - const result = await hasUserAccessToMonitor({ 310 - monitorId: opts.input.id, 311 - ctx: opts.ctx, 312 - }); 313 - if (!result) return null; 314 - 315 265 const data = await opts.ctx.db 316 266 .select() 317 267 .from(notificationsToMonitors) 318 268 .innerJoin( 319 269 notification, 320 - eq(notificationsToMonitors.notificationId, notification.id), 270 + and( 271 + eq(notificationsToMonitors.notificationId, notification.id), 272 + eq(notification.workspaceId, opts.ctx.workspace.id), 273 + ), 321 274 ) 322 275 .where(eq(notificationsToMonitors.monitorId, opts.input.id)) 323 276 .all();
+34 -71
packages/api/src/router/notification.ts
··· 1 1 import { z } from "zod"; 2 2 3 - import { analytics, trackAnalytics } from "@openstatus/analytics"; 4 - import { eq } from "@openstatus/db"; 3 + import { and, eq } from "@openstatus/db"; 5 4 import { 6 5 insertNotificationSchema, 7 6 notification, 8 7 selectNotificationSchema, 9 8 } from "@openstatus/db/src/schema"; 10 9 10 + import { trackNewNotification } from "../analytics"; 11 11 import { createTRPCRouter, protectedProcedure } from "../trpc"; 12 - import { hasUserAccessToNotification, hasUserAccessToWorkspace } from "./utils"; 13 12 14 13 export const notificationRouter = createTRPCRouter({ 15 - createNotification: protectedProcedure 16 - .input( 17 - insertNotificationSchema.extend({ 18 - workspaceSlug: z.string(), 19 - }), 20 - ) 14 + create: protectedProcedure 15 + .input(insertNotificationSchema) 21 16 .mutation(async (opts) => { 22 - const { workspaceSlug, ...data } = opts.input; 23 - 24 - const result = await hasUserAccessToWorkspace({ 25 - workspaceSlug, 26 - ctx: opts.ctx, 27 - }); 28 - if (!result) return; 17 + const { ...data } = opts.input; 29 18 30 19 const _notification = await opts.ctx.db 31 20 .insert(notification) 32 - .values({ ...data, workspaceId: result.workspace.id }) 21 + .values({ ...data, workspaceId: opts.ctx.workspace.id }) 33 22 .returning() 34 23 .get(); 35 24 36 - await analytics.identify(result.user.id, { 37 - userId: result.user.id, 38 - email: result.user.email, 39 - }); 40 - await trackAnalytics({ 41 - event: "Notification Created", 42 - provider: _notification.provider, 43 - }); 25 + trackNewNotification(opts.ctx.user, { provider: _notification.provider }); 44 26 45 27 return _notification; 46 28 }), 47 29 48 - updateNotification: protectedProcedure 30 + update: protectedProcedure 49 31 .input(insertNotificationSchema) 50 32 .mutation(async (opts) => { 51 33 if (!opts.input.id) return; 52 - const result = await hasUserAccessToNotification({ 53 - notificationId: opts.input.id, 54 - ctx: opts.ctx, 55 - }); 56 - if (!result) return; 57 34 58 35 const { ...data } = opts.input; 59 36 return await opts.ctx.db 60 37 .update(notification) 61 38 .set({ ...data, updatedAt: new Date() }) 62 - .where(eq(notification.id, opts.input.id)) 39 + .where( 40 + and( 41 + eq(notification.id, opts.input.id), 42 + eq(notification.workspaceId, opts.ctx.workspace.id), 43 + ), 44 + ) 63 45 .returning() 64 46 .get(); 65 47 }), ··· 67 49 deleteNotification: protectedProcedure 68 50 .input(z.object({ id: z.number() })) 69 51 .mutation(async (opts) => { 70 - console.log({ id: opts.input.id }); 71 - const result = await hasUserAccessToNotification({ 72 - notificationId: opts.input.id, 73 - ctx: opts.ctx, 74 - }); 75 - if (!result) return; 76 - 77 52 await opts.ctx.db 78 53 .delete(notification) 79 - .where(eq(notification.id, result.notification.id)) 54 + .where( 55 + and( 56 + eq(notification.id, opts.input.id), 57 + eq(notification.id, opts.input.id), 58 + ), 59 + ) 80 60 .run(); 81 61 }), 82 62 83 63 getNotificationById: protectedProcedure 84 64 .input(z.object({ id: z.number() })) 85 65 .query(async (opts) => { 86 - // if (!opts.input.id) return; 87 - // const result = await hasUserAccessToMonitor({ 88 - // monitorId: opts.input.id, 89 - // ctx: opts.ctx, 90 - // }); 91 - // if (!result) return; 92 - 93 66 const _notification = await opts.ctx.db 94 67 .select() 95 68 .from(notification) 96 - .where(eq(notification.id, opts.input.id)) 69 + .where( 70 + and( 71 + eq(notification.id, opts.input.id), 72 + eq(notification.id, opts.input.id), 73 + ), 74 + ) 97 75 .get(); 98 76 99 77 return selectNotificationSchema.parse(_notification); 100 78 }), 101 79 102 - getNotificationsByWorkspace: protectedProcedure 103 - .input(z.object({ workspaceSlug: z.string() })) 104 - .query(async (opts) => { 105 - // Check if user has access to workspace 106 - const data = await hasUserAccessToWorkspace({ 107 - workspaceSlug: opts.input.workspaceSlug, 108 - ctx: opts.ctx, 109 - }); 110 - 111 - if (!data) return; 112 - 113 - const notifications = await opts.ctx.db 114 - .select() 115 - .from(notification) 116 - .where(eq(notification.workspaceId, data.workspace.id)) 117 - .all(); 80 + getNotificationsByWorkspace: protectedProcedure.query(async (opts) => { 81 + const notifications = await opts.ctx.db 82 + .select() 83 + .from(notification) 84 + .where(eq(notification.workspaceId, opts.ctx.workspace.id)) 85 + .all(); 118 86 119 - try { 120 - return z.array(selectNotificationSchema).parse(notifications); 121 - } catch (e) { 122 - console.log(e); 123 - } 124 - return; 125 - }), 87 + return z.array(selectNotificationSchema).parse(notifications); 88 + }), 126 89 });
+8 -12
packages/api/src/router/page.test.ts
··· 8 8 const ctx = createTRPCContext({ 9 9 // @ts-expect-error 10 10 req: {}, 11 + auth: { 12 + userId: "1", 13 + sessionId: "1", 14 + }, 11 15 }); 12 - 13 - // @ts-expect-error 14 - ctx.auth = { 15 - userId: "1", 16 - sessionId: "1", 17 - }; 18 16 19 17 const caller = edgeRouter.createCaller(ctx); 20 18 const result = await caller.page.getPageBySlug({ slug: "status" }); ··· 50 48 const ctx = createTRPCContext({ 51 49 // @ts-expect-error 52 50 req: {}, 51 + auth: { 52 + userId: "1", 53 + sessionId: "1", 54 + }, 53 55 }); 54 - 55 - // @ts-expect-error 56 - ctx.auth = { 57 - userId: "1", 58 - sessionId: "1", 59 - }; 60 56 61 57 const caller = edgeRouter.createCaller(ctx); 62 58 const result = await caller.page.getPageBySlug({ slug: "Dont Exist" });
+99 -201
packages/api/src/router/page.ts
··· 1 1 import { TRPCError } from "@trpc/server"; 2 2 import { z } from "zod"; 3 3 4 - import { analytics, trackAnalytics } from "@openstatus/analytics"; 5 4 import { and, eq, inArray, or, sql } from "@openstatus/db"; 6 5 import { 7 6 incident, ··· 12 11 page, 13 12 pagesToIncidents, 14 13 selectPublicPageSchemaWithRelation, 15 - user, 16 - usersToWorkspaces, 17 - workspace, 18 14 } from "@openstatus/db/src/schema"; 15 + import { allPlans } from "@openstatus/plans"; 19 16 17 + import { trackNewPage } from "../analytics"; 20 18 import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; 21 - import { hasUserAccessToWorkspace } from "./utils"; 22 19 23 - // TODO: deletePageById - updatePageById 24 20 export const pageRouter = createTRPCRouter({ 25 - createPage: protectedProcedure 26 - .input(insertPageSchema) 27 - .mutation(async (opts) => { 28 - if (!opts.input.workspaceSlug) return; 29 - const data = await hasUserAccessToWorkspace({ 30 - workspaceSlug: opts.input.workspaceSlug, 31 - ctx: opts.ctx, 32 - }); 33 - if (!data) return; 21 + create: protectedProcedure.input(insertPageSchema).mutation(async (opts) => { 22 + const { monitors, workspaceId, id, ...pageProps } = opts.input; 34 23 35 - const { monitors, workspaceId, workspaceSlug, id, ...pageInput } = 36 - opts.input; 24 + const pageNumbers = ( 25 + await opts.ctx.db.query.page.findMany({ 26 + where: eq(page.workspaceId, opts.ctx.workspace.id), 27 + }) 28 + ).length; 37 29 38 - const pageNumbers = ( 39 - await opts.ctx.db.query.page.findMany({ 40 - where: eq(page.workspaceId, data.workspace.id), 41 - }) 42 - ).length; 30 + const limit = allPlans[opts.ctx.workspace.plan].limits["status-pages"]; 43 31 44 - const limit = data.plan.limits["status-pages"]; 32 + // the user has reached the limits 33 + if (pageNumbers >= limit) { 34 + throw new TRPCError({ 35 + code: "FORBIDDEN", 36 + message: "You reached your status-page limits.", 37 + }); 38 + } 45 39 46 - // the user has reached the limits 47 - if (pageNumbers >= limit) { 48 - throw new TRPCError({ 49 - code: "FORBIDDEN", 50 - message: "You reached your status-page limits.", 51 - }); 52 - } 40 + const newPage = await opts.ctx.db 41 + .insert(page) 42 + .values({ workspaceId: opts.ctx.workspace.id, ...pageProps }) 43 + .returning() 44 + .get(); 53 45 54 - const newPage = await opts.ctx.db 55 - .insert(page) 56 - .values({ workspaceId: data.workspace.id, ...pageInput }) 57 - .returning() 58 - .get(); 46 + if (Boolean(monitors.length)) { 47 + // We should make sure the user has access to the monitors 48 + const allMonitors = await opts.ctx.db.query.monitor.findMany({ 49 + where: and( 50 + inArray(monitor.id, monitors), 51 + eq(monitor.workspaceId, opts.ctx.workspace.id), 52 + ), 53 + }); 59 54 60 - if (monitors && monitors.length > 0) { 61 - // We should make sure the user has access to the monitors 62 - const allMonitors = await opts.ctx.db.query.monitor.findMany({ 63 - where: inArray(monitor.id, monitors), 64 - }); 65 - const values = allMonitors.map((monitor) => ({ 66 - monitorId: monitor.id, 67 - pageId: newPage.id, 68 - })); 69 - await opts.ctx.db.insert(monitorsToPages).values(values).run(); 70 - } 55 + const values = allMonitors.map((monitor) => ({ 56 + monitorId: monitor.id, 57 + pageId: newPage.id, 58 + })); 71 59 72 - // TODO: check, do we have to await for the two calls? Will slow down user response for our analytics 73 - await analytics.identify(data.user.id, { 74 - userId: data.user.id, 75 - email: data.user.email, 76 - }); 77 - await trackAnalytics({ 78 - event: "Page Created", 79 - slug: newPage.slug, 80 - }); 81 - return newPage; 82 - }), 60 + await opts.ctx.db.insert(monitorsToPages).values(values).run(); 61 + } 83 62 84 - getPageByID: protectedProcedure 63 + trackNewPage(opts.ctx.user, { slug: newPage.slug }); 64 + 65 + return newPage; 66 + }), 67 + getPageById: protectedProcedure 85 68 .input(z.object({ id: z.number() })) 86 69 .query(async (opts) => { 87 - const currentUser = await opts.ctx.db 88 - .select() 89 - .from(user) 90 - .where(eq(user.tenantId, opts.ctx.auth.userId)) 91 - .get(); 92 - if (!currentUser) return; 93 - const result = await opts.ctx.db 94 - .select() 95 - .from(usersToWorkspaces) 96 - .where(eq(usersToWorkspaces.userId, currentUser.id)) 97 - .all(); 98 - const workspaceIds = result.map((workspace) => workspace.workspaceId); 99 - 100 70 return await opts.ctx.db.query.page.findFirst({ 101 71 where: and( 102 72 eq(page.id, opts.input.id), 103 - inArray(page.workspaceId, workspaceIds), 73 + eq(page.workspaceId, opts.ctx.workspace.id), 104 74 ), 105 75 with: { 106 76 monitorsToPages: { with: { monitor: true } }, ··· 108 78 }, 109 79 }); 110 80 }), 111 - updatePage: protectedProcedure 112 - .input(insertPageSchema) 113 - .mutation(async (opts) => { 114 - if (!opts.input.id) return; 115 81 116 - const currentUser = await opts.ctx.db 117 - .select() 118 - .from(user) 119 - .where(eq(user.tenantId, opts.ctx.auth.userId)) 120 - .get(); 121 - if (!currentUser) return; 122 - const result = await opts.ctx.db 123 - .select() 124 - .from(usersToWorkspaces) 125 - .where(eq(usersToWorkspaces.userId, currentUser.id)) 126 - .all(); 127 - const workspaceIds = result.map((workspace) => workspace.workspaceId); 128 - 129 - const pageToUpdate = await opts.ctx.db 130 - .select() 131 - .from(page) 132 - .where( 133 - and( 134 - eq(page.id, opts.input.id), 135 - inArray(page.workspaceId, workspaceIds), 136 - ), 137 - ) 138 - .get(); 139 - if (!pageToUpdate) return; 140 - 141 - const { monitors, workspaceSlug, ...pageInput } = opts.input; 142 - if (!pageInput.id) return; 143 - const currentPage = await opts.ctx.db 144 - .update(page) 145 - .set({ ...pageInput, updatedAt: new Date() }) 146 - .where(eq(page.id, pageInput.id)) 147 - .returning() 148 - .get(); 82 + update: protectedProcedure.input(insertPageSchema).mutation(async (opts) => { 83 + const { monitors, ...pageInput } = opts.input; 84 + if (!pageInput.id) return; 149 85 150 - // TODO: optimize! 151 - const currentMonitorsToPages = await opts.ctx.db 152 - .select() 153 - .from(monitorsToPages) 154 - .where(eq(monitorsToPages.pageId, currentPage.id)) 155 - .all(); 86 + const currentPage = await opts.ctx.db 87 + .update(page) 88 + .set({ ...pageInput, updatedAt: new Date() }) 89 + .where( 90 + and( 91 + eq(page.id, pageInput.id), 92 + eq(page.workspaceId, opts.ctx.workspace.id), 93 + ), 94 + ) 95 + .returning() 96 + .get(); 156 97 157 - const currentMonitorsToPagesIds = currentMonitorsToPages.map( 158 - ({ monitorId }) => monitorId, 159 - ); 98 + const currentMonitorsToPages = await opts.ctx.db 99 + .select() 100 + .from(monitorsToPages) 101 + .where(eq(monitorsToPages.pageId, currentPage.id)) 102 + .all(); 160 103 161 - const removedMonitors = currentMonitorsToPagesIds.filter( 162 - (x) => !monitors?.includes(x), 163 - ); 104 + const removedMonitors = currentMonitorsToPages 105 + .map(({ monitorId }) => monitorId) 106 + .filter((x) => !monitors?.includes(x)); 164 107 165 - const addedMonitors = monitors?.filter( 166 - (x) => !currentMonitorsToPagesIds?.includes(x), 167 - ); 108 + if (Boolean(removedMonitors.length)) { 109 + await opts.ctx.db 110 + .delete(monitorsToPages) 111 + .where( 112 + and( 113 + inArray(monitorsToPages.monitorId, removedMonitors), 114 + eq(monitorsToPages.pageId, currentPage.id), 115 + ), 116 + ); 117 + } 168 118 169 - if (addedMonitors && addedMonitors.length > 0) { 170 - const values = addedMonitors.map((monitorId) => ({ 171 - monitorId: monitorId, 172 - pageId: currentPage.id, 173 - })); 119 + const addedMonitors = monitors.filter( 120 + (x) => 121 + !currentMonitorsToPages.map(({ monitorId }) => monitorId)?.includes(x), 122 + ); 174 123 175 - await opts.ctx.db.insert(monitorsToPages).values(values).run(); 176 - } 124 + if (Boolean(addedMonitors.length)) { 125 + const values = addedMonitors.map((monitorId) => ({ 126 + pageId: currentPage.id, 127 + monitorId, 128 + })); 177 129 178 - if (removedMonitors && removedMonitors.length > 0) { 179 - await opts.ctx.db 180 - .delete(monitorsToPages) 181 - .where( 182 - and( 183 - eq(monitorsToPages.pageId, currentPage.id), 184 - inArray(monitorsToPages.monitorId, removedMonitors), 185 - ), 186 - ) 187 - .run(); 188 - } 189 - }), 190 - deletePage: protectedProcedure 130 + await opts.ctx.db.insert(monitorsToPages).values(values).run(); 131 + } 132 + }), 133 + delete: protectedProcedure 191 134 .input(z.object({ id: z.number() })) 192 135 .mutation(async (opts) => { 193 - // TODO: this looks not very affective 194 - const currentUser = await opts.ctx.db 195 - .select() 196 - .from(user) 197 - .where(eq(user.tenantId, opts.ctx.auth.userId)) 198 - .get(); 199 - if (!currentUser) return; 200 - const result = await opts.ctx.db 201 - .select() 202 - .from(usersToWorkspaces) 203 - .where(eq(usersToWorkspaces.userId, currentUser.id)) 204 - .all(); 205 - const workspaceIds = result.map((workspace) => workspace.workspaceId); 206 - // two queries - can we reduce it? 207 - 208 - const pageToDelete = await opts.ctx.db 209 - .select() 210 - .from(page) 136 + await opts.ctx.db 137 + .delete(page) 211 138 .where( 212 139 and( 213 140 eq(page.id, opts.input.id), 214 - inArray(page.workspaceId, workspaceIds), 141 + eq(page.workspaceId, opts.ctx.workspace.id), 215 142 ), 216 143 ) 217 - .get(); 218 - if (!pageToDelete) return; 219 - 220 - await opts.ctx.db.delete(page).where(eq(page.id, pageToDelete.id)).run(); 144 + .run(); 221 145 }), 222 - getPagesByWorkspace: protectedProcedure 223 - .input(z.object({ workspaceSlug: z.string() })) 224 - .query(async (opts) => { 225 - const currentUser = await opts.ctx.db 226 - .select() 227 - .from(user) 228 - .where(eq(user.tenantId, opts.ctx.auth.userId)) 229 - .get(); 230 - if (!currentUser) return; 231 - const currentWorkspace = await opts.ctx.db 232 - .select() 233 - .from(workspace) 234 - .where(eq(workspace.slug, opts.input.workspaceSlug)) 235 - .get(); 236 - if (!currentWorkspace) return; 237 - const result = await opts.ctx.db 238 - .select() 239 - .from(usersToWorkspaces) 240 - .where( 241 - and( 242 - eq(usersToWorkspaces.userId, currentUser.id), 243 - eq(usersToWorkspaces.workspaceId, currentWorkspace.id), 244 - ), 245 - ) 246 - .all(); 247 - if (!result) return; 248 - 249 - return opts.ctx.db.query.page.findMany({ 250 - where: and(eq(page.workspaceId, currentWorkspace.id)), 251 - with: { 252 - monitorsToPages: { with: { monitor: true } }, 253 - }, 254 - }); 255 - }), 146 + getPagesByWorkspace: protectedProcedure.query(async (opts) => { 147 + return opts.ctx.db.query.page.findMany({ 148 + where: and(eq(page.workspaceId, opts.ctx.workspace.id)), 149 + with: { 150 + monitorsToPages: { with: { monitor: true } }, 151 + }, 152 + }); 153 + }), 256 154 257 155 // public if we use trpc hooks to get the page from the url 258 156 getPageBySlug: publicProcedure
-144
packages/api/src/router/utils.ts
··· 1 - import { eq } from "@openstatus/db"; 2 - import { 3 - monitor, 4 - notification, 5 - user, 6 - usersToWorkspaces, 7 - workspace, 8 - } from "@openstatus/db/src/schema"; 9 - import { allPlans } from "@openstatus/plans"; 10 - 11 - import { Context } from "../trpc"; 12 - 13 - /** 14 - * Check if the user has access to the workspace, and return the workspace and user otherwise undefined 15 - * @param workspaceSlug 16 - * @param tenantId 17 - * @param ctx - trpc context 18 - * @returns {Promise<{ workspaceId: string; userId: string }> | undefined} - workspaceId and userId 19 - */ 20 - export const hasUserAccessToWorkspace = async ({ 21 - workspaceSlug, 22 - ctx, 23 - }: { 24 - workspaceSlug: string; 25 - ctx: Context; 26 - }) => { 27 - if (!ctx.auth?.userId) return; 28 - const currentUser = ctx.db 29 - .select() 30 - .from(user) 31 - .where(eq(user.tenantId, ctx.auth?.userId)) 32 - .as("currentUser"); 33 - 34 - const currentWorkspace = await ctx.db 35 - .select() 36 - .from(workspace) 37 - .where(eq(workspace.slug, workspaceSlug)) 38 - .get(); 39 - if (!currentWorkspace) return; 40 - const result = await ctx.db 41 - .select() 42 - .from(usersToWorkspaces) 43 - .where(eq(usersToWorkspaces.workspaceId, currentWorkspace.id)) 44 - .innerJoin(currentUser, eq(usersToWorkspaces.userId, currentUser.id)) 45 - .get(); 46 - // the user doesn't have access to this workspace 47 - if (!result || !result.users_to_workspaces) return; 48 - 49 - const plan = (currentWorkspace.plan || "free") as "free" | "pro"; // FIXME: that is a hotfix 50 - 51 - return { 52 - workspace: currentWorkspace, 53 - user: result.currentUser, 54 - plan: allPlans[plan], 55 - }; 56 - }; 57 - 58 - export const hasUserAccessToMonitor = async ({ 59 - monitorId, 60 - ctx, 61 - }: { 62 - monitorId: number; 63 - ctx: Context; 64 - }) => { 65 - if (!ctx.auth?.userId) return; 66 - 67 - const currentMonitor = await ctx.db 68 - .select() 69 - .from(monitor) 70 - .where(eq(monitor.id, monitorId)) 71 - .get(); 72 - if (!currentMonitor || !currentMonitor.workspaceId) return; 73 - 74 - // TODO: we should use hasUserAccess and pass `workspaceId` instead of `workspaceSlug` 75 - const currentUser = ctx.db 76 - .select() 77 - .from(user) 78 - .where(eq(user.tenantId, ctx.auth.userId)) 79 - .as("currentUser"); 80 - 81 - const result = await ctx.db 82 - .select() 83 - .from(usersToWorkspaces) 84 - .where(eq(usersToWorkspaces.workspaceId, currentMonitor.workspaceId)) 85 - .innerJoin(currentUser, eq(usersToWorkspaces.userId, currentUser.id)) 86 - .get(); 87 - 88 - if (!result || !result.users_to_workspaces) return; 89 - 90 - const currentWorkspace = await ctx.db.query.workspace.findFirst({ 91 - where: eq(workspace.id, result.users_to_workspaces.workspaceId), 92 - }); 93 - 94 - if (!currentWorkspace) return; 95 - return { 96 - workspace: currentWorkspace, 97 - user: result.currentUser, 98 - monitor: currentMonitor, 99 - }; 100 - }; 101 - 102 - export const hasUserAccessToNotification = async ({ 103 - notificationId, 104 - ctx, 105 - }: { 106 - notificationId: number; 107 - ctx: Context; 108 - }) => { 109 - if (!ctx.auth?.userId) return; 110 - 111 - const currentNotification = await ctx.db 112 - .select() 113 - .from(notification) 114 - .where(eq(notification.id, notificationId)) 115 - .get(); 116 - if (!currentNotification || !currentNotification.workspaceId) return; 117 - 118 - // TODO: we should use hasUserAccess and pass `workspaceId` instead of `workspaceSlug` 119 - const currentUser = ctx.db 120 - .select() 121 - .from(user) 122 - .where(eq(user.tenantId, ctx.auth.userId)) 123 - .as("currentUser"); 124 - 125 - const result = await ctx.db 126 - .select() 127 - .from(usersToWorkspaces) 128 - .where(eq(usersToWorkspaces.workspaceId, currentNotification.workspaceId)) 129 - .innerJoin(currentUser, eq(usersToWorkspaces.userId, currentUser.id)) 130 - .get(); 131 - 132 - if (!result || !result.users_to_workspaces) return; 133 - 134 - const currentWorkspace = await ctx.db.query.workspace.findFirst({ 135 - where: eq(workspace.id, result.users_to_workspaces.workspaceId), 136 - }); 137 - 138 - if (!currentWorkspace) return; 139 - return { 140 - workspace: currentWorkspace, 141 - user: result.currentUser, 142 - notification: currentNotification, 143 - }; 144 - };
+23 -21
packages/api/src/router/workspace.test.ts
··· 5 5 6 6 vi.mock("@clerk/nextjs/server"); 7 7 test("Get Test Workspace", async () => { 8 - const ctx = await createTRPCContext({ 8 + const ctx = createTRPCContext({ 9 + // @ts-expect-error 9 10 req: {}, 11 + auth: { 12 + userId: "1", 13 + sessionId: "1", 14 + }, 15 + workspace: { 16 + id: 1, 17 + }, 10 18 }); 11 19 12 - // @ts-expect-error 13 - ctx.auth = { 14 - userId: "1", 15 - sessionId: "1", 16 - }; 17 - 18 20 const caller = edgeRouter.createCaller(ctx); 19 - const result = await caller.workspace.getWorkspace({ slug: "test" }); 21 + const result = await caller.workspace.getWorkspace(); 22 + 20 23 expect(result).toEqual({ 21 24 id: 1, 22 25 slug: "test", ··· 33 36 34 37 test("No workspace", async () => { 35 38 const ctx = createTRPCContext({ 39 + // @ts-expect-error 36 40 req: {}, 41 + auth: { 42 + userId: "1", 43 + sessionId: "1", 44 + }, 45 + workspace: undefined, 37 46 }); 38 47 39 - // @ts-expect-error 40 - ctx.auth = { 41 - userId: "1", 42 - sessionId: "1", 43 - }; 44 - 45 48 const caller = edgeRouter.createCaller(ctx); 46 - const result = await caller.workspace.getWorkspace({ slug: "Dont Exist" }); 49 + const result = await caller.workspace.getWorkspace(); 47 50 expect(result).toBeUndefined(); 48 51 }); 49 52 50 53 test("All workspaces", async () => { 51 54 const ctx = createTRPCContext({ 55 + // @ts-expect-error 52 56 req: {}, 57 + auth: { 58 + userId: "1", 59 + sessionId: "1", 60 + }, 53 61 }); 54 - 55 - // @ts-expect-error 56 - ctx.auth = { 57 - userId: "1", 58 - sessionId: "1", 59 - }; 60 62 61 63 const caller = edgeRouter.createCaller(ctx); 62 64 const result = await caller.workspace.getUserWithWorkspace();
+6 -15
packages/api/src/router/workspace.ts
··· 9 9 } from "@openstatus/db/src/schema"; 10 10 11 11 import { createTRPCRouter, protectedProcedure } from "../trpc"; 12 - import { hasUserAccessToWorkspace } from "./utils"; 13 12 14 13 export const workspaceRouter = createTRPCRouter({ 15 14 getUserWithWorkspace: protectedProcedure.query(async (opts) => { ··· 25 24 }); 26 25 }), 27 26 28 - getWorkspace: protectedProcedure 29 - .input(z.object({ slug: z.string() })) 30 - .query(async (opts) => { 31 - const data = await hasUserAccessToWorkspace({ 32 - workspaceSlug: opts.input.slug, 33 - ctx: opts.ctx, 34 - }); 35 - if (!data) return; 27 + getWorkspace: protectedProcedure.query(async (opts) => { 28 + const result = await opts.ctx.db.query.workspace.findFirst({ 29 + where: eq(workspace.id, opts.ctx.workspace.id), 30 + }); 36 31 37 - const result = await opts.ctx.db.query.workspace.findFirst({ 38 - where: eq(workspace.id, data.workspace.id), 39 - }); 40 - 41 - return selectWorkspaceSchema.parse(result); 42 - }), 32 + return selectWorkspaceSchema.parse(result); 33 + }), 43 34 44 35 createWorkspace: protectedProcedure 45 36 .input(z.object({ name: z.string() }))
+37 -2
packages/api/src/trpc.ts
··· 8 8 import superjson from "superjson"; 9 9 import { ZodError } from "zod"; 10 10 11 - import { db } from "@openstatus/db"; 11 + import { db, eq, schema } from "@openstatus/db"; 12 + import type { User, Workspace } from "@openstatus/db/src/schema"; 12 13 13 14 /** 14 15 * 1. CONTEXT ··· 21 22 */ 22 23 type CreateContextOptions = { 23 24 auth: SignedInAuthObject | SignedOutAuthObject | null; 25 + workspace?: Workspace | null; 26 + user?: User | null; 24 27 req?: NextRequest; 25 28 }; 26 29 ··· 50 53 serverSideCall?: boolean; 51 54 }) => { 52 55 const auth = !opts.serverSideCall ? getAuth(opts.req) : null; 56 + const workspace = null; 57 + const user = null; 53 58 54 59 return createInnerTRPCContext({ 55 60 auth, 61 + workspace, 62 + user, 56 63 req: opts.req, 57 64 }); 58 65 }; ··· 106 113 * Reusable middleware that enforces users are logged in before running the 107 114 * procedure 108 115 */ 109 - const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { 116 + const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => { 110 117 if (!ctx.auth?.userId) { 111 118 throw new TRPCError({ code: "UNAUTHORIZED" }); 112 119 } 120 + 121 + /** 122 + * Attach `user` and `workspace` infos to context by 123 + * comparing the `user.tenantId` to clerk's `auth.userId` 124 + */ 125 + const query = await db 126 + .select() 127 + .from(schema.user) 128 + .innerJoin( 129 + schema.usersToWorkspaces, 130 + eq(schema.usersToWorkspaces.userId, schema.user.id), 131 + ) 132 + .innerJoin( 133 + schema.workspace, 134 + eq(schema.workspace.id, schema.usersToWorkspaces.workspaceId), 135 + ) 136 + .where(eq(schema.user.tenantId, ctx.auth.userId)) 137 + .get(); 138 + 139 + const workspace = schema.selectWorkspaceSchema.parse(query?.workspace); 140 + const user = schema.selectUserSchema.parse(query?.user); 141 + 142 + if (!workspace && !user) { 143 + throw new TRPCError({ code: "UNAUTHORIZED" }); 144 + } 145 + 113 146 return next({ 114 147 ctx: { 115 148 auth: { 116 149 ...ctx.auth, 117 150 userId: ctx.auth.userId, 118 151 }, 152 + user, 153 + workspace, 119 154 }, 120 155 }); 121 156 });
packages/api/src/utils.ts

This is a binary file and will not be displayed.

-3
packages/db/src/schema/incidents/validation.ts
··· 7 7 8 8 export const insertIncidentUpdateSchema = createInsertSchema(incidentUpdate, { 9 9 status: incidentStatusSchema, 10 - }).extend({ 11 - workspaceSlug: z.string(), // FIXME: we should do it differently! 12 10 }); 13 11 14 12 export const insertIncidentSchema = createInsertSchema(incident, { ··· 16 14 }) 17 15 .extend({ 18 16 date: z.date().optional().default(new Date()), 19 - workspaceSlug: z.string(), 20 17 /** 21 18 * relationship to monitors and pages 22 19 */
+1 -1
packages/db/src/schema/monitors/validation.ts
··· 69 69 headers: headersSchema.default([]), 70 70 }).extend({ 71 71 method: monitorMethodsSchema.default("GET"), 72 - notifications: z.array(z.number()).optional(), 72 + notifications: z.array(z.number()).optional().default([]), 73 73 body: z.string().default("").optional(), 74 74 }); 75 75
-1
packages/db/src/schema/pages/validation.ts
··· 26 26 slug: slugSchema, 27 27 }).extend({ 28 28 monitors: z.array(z.number()).optional().default([]), 29 - workspaceSlug: z.string(), 30 29 }); 31 30 32 31 export const selectPageSchema = createSelectSchema(page);
+1
packages/db/src/schema/users/index.ts
··· 1 1 export * from "./user"; 2 + export * from "./validation";
+11
packages/db/src/schema/users/validation.ts
··· 1 + import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 2 + import type { z } from "zod"; 3 + 4 + import { user } from "./user"; 5 + 6 + export const insertUserSchema = createInsertSchema(user); 7 + 8 + export const selectUserSchema = createSelectSchema(user); 9 + 10 + export type InsertUser = z.infer<typeof insertUserSchema>; 11 + export type User = z.infer<typeof selectUserSchema>;
+1 -1
packages/ui/package.json
··· 20 20 "dependencies": { 21 21 "@hookform/resolvers": "3.3.1", 22 22 "@radix-ui/react-accordion": "1.1.2", 23 - "@radix-ui/react-alert-dialog": "1.0.4", 23 + "@radix-ui/react-alert-dialog": "1.0.5", 24 24 "@radix-ui/react-avatar": "1.0.4", 25 25 "@radix-ui/react-checkbox": "1.0.4", 26 26 "@radix-ui/react-context-menu": "2.1.5",
+1 -2
packages/ui/src/components/alert-dialog.tsx
··· 11 11 const AlertDialogTrigger = AlertDialogPrimitive.Trigger; 12 12 13 13 const AlertDialogPortal = ({ 14 - className, 15 14 ...props 16 15 }: AlertDialogPrimitive.AlertDialogPortalProps) => ( 17 - <AlertDialogPrimitive.Portal className={cn(className)} {...props} /> 16 + <AlertDialogPrimitive.Portal {...props} /> 18 17 ); 19 18 AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName; 20 19
+39 -5
pnpm-lock.yaml
··· 726 726 specifier: 1.1.2 727 727 version: 1.1.2(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 728 728 '@radix-ui/react-alert-dialog': 729 - specifier: 1.0.4 730 - version: 1.0.4(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 729 + specifier: 1.0.5 730 + version: 1.0.5(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 731 731 '@radix-ui/react-avatar': 732 732 specifier: 1.0.4 733 733 version: 1.0.4(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) ··· 2994 2994 react-dom: 18.2.0(react@18.2.0) 2995 2995 dev: false 2996 2996 2997 - /@radix-ui/react-alert-dialog@1.0.4(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0): 2998 - resolution: {integrity: sha512-jbfBCRlKYlhbitueOAv7z74PXYeIQmWpKwm3jllsdkw7fGWNkxqP3v0nY9WmOzcPqpQuoorNtvViBgL46n5gVg==} 2997 + /@radix-ui/react-alert-dialog@1.0.5(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0): 2998 + resolution: {integrity: sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA==} 2999 2999 peerDependencies: 3000 3000 '@types/react': '*' 3001 3001 '@types/react-dom': '*' ··· 3011 3011 '@radix-ui/primitive': 1.0.1 3012 3012 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.24)(react@18.2.0) 3013 3013 '@radix-ui/react-context': 1.0.1(@types/react@18.2.24)(react@18.2.0) 3014 - '@radix-ui/react-dialog': 1.0.4(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3014 + '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3015 3015 '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3016 3016 '@radix-ui/react-slot': 1.0.2(@types/react@18.2.24)(react@18.2.0) 3017 3017 '@types/react': 18.2.24 ··· 3266 3266 '@radix-ui/react-focus-scope': 1.0.3(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3267 3267 '@radix-ui/react-id': 1.0.1(@types/react@18.2.24)(react@18.2.0) 3268 3268 '@radix-ui/react-portal': 1.0.3(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3269 + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3270 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3271 + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.24)(react@18.2.0) 3272 + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.24)(react@18.2.0) 3273 + '@types/react': 18.2.24 3274 + '@types/react-dom': 18.2.8 3275 + aria-hidden: 1.2.3 3276 + react: 18.2.0 3277 + react-dom: 18.2.0(react@18.2.0) 3278 + react-remove-scroll: 2.5.5(@types/react@18.2.24)(react@18.2.0) 3279 + dev: false 3280 + 3281 + /@radix-ui/react-dialog@1.0.5(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0): 3282 + resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==} 3283 + peerDependencies: 3284 + '@types/react': '*' 3285 + '@types/react-dom': '*' 3286 + react: ^16.8 || ^17.0 || ^18.0 3287 + react-dom: ^16.8 || ^17.0 || ^18.0 3288 + peerDependenciesMeta: 3289 + '@types/react': 3290 + optional: true 3291 + '@types/react-dom': 3292 + optional: true 3293 + dependencies: 3294 + '@babel/runtime': 7.23.2 3295 + '@radix-ui/primitive': 1.0.1 3296 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.24)(react@18.2.0) 3297 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.24)(react@18.2.0) 3298 + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3299 + '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.24)(react@18.2.0) 3300 + '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3301 + '@radix-ui/react-id': 1.0.1(@types/react@18.2.24)(react@18.2.0) 3302 + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3269 3303 '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3270 3304 '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3271 3305 '@radix-ui/react-slot': 1.0.2(@types/react@18.2.24)(react@18.2.0)