Openstatus www.openstatus.dev

feat: invite members to workspace (#503)

* feat: invite members to workspace

* chore: improve selector

* fix: client side cookie for trpc mutation

* chore: avoid prop drilling and use client fetch instead

authored by

Maximilian Kaske and committed by
GitHub
0e12f6e6 d56f329b

+2064 -84
apps/web/src/app/app/(dashboard)/[workspaceSlug]/integrations/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/integrations/loading.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/integrations/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/integrations/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/integrations/vercel/configure/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/integrations/vercel/configure/page.tsx
+8 -2
apps/web/src/app/app/(dashboard)/[workspaceSlug]/layout.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/layout.tsx
··· 6 6 import { AppMenu } from "@/components/layout/app-menu"; 7 7 import { AppSidebar } from "@/components/layout/app-sidebar"; 8 8 import { api } from "@/trpc/server"; 9 + import { WorkspaceClientCookie } from "../worskpace-client-cookie"; 9 10 10 11 // TODO: make the container min-h-screen and the footer below! 11 12 export default async function AppLayout({ ··· 15 16 children: React.ReactNode; 16 17 params: { workspaceSlug: string }; 17 18 }) { 18 - const workspace = await api.workspace.getWorkspace.query(); 19 - if (!workspace) return notFound(); // TODO: discuss if we should move to middleware 19 + const { workspaceSlug } = params; 20 + const workspaces = await api.workspace.getUserWorkspaces.query(); 21 + 22 + if (workspaces.length === 0) return notFound(); 23 + if (workspaces.find((w) => w.slug === workspaceSlug) === undefined) 24 + return notFound(); 20 25 21 26 return ( 22 27 <div className="container relative mx-auto flex min-h-screen w-full flex-col items-center justify-center gap-6 p-4 lg:p-8"> ··· 36 41 </Shell> 37 42 </main> 38 43 </div> 44 + <WorkspaceClientCookie {...{ workspaceSlug }} /> 39 45 </div> 40 46 ); 41 47 }
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/[id]/data/_components/chart-wrapper.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/_components/chart-wrapper.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/[id]/data/_components/chart.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/_components/chart.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/[id]/data/_components/date-picker-preset.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/_components/date-picker-preset.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/[id]/data/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/loading.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/[id]/data/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/[id]/data/utils.ts apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/utils.ts
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/_components/action-button.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/_components/action-button.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/_components/empty-state.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/_components/empty-state.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/edit/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/edit/loading.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/edit/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/edit/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/loading.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/_components/empty-state.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/_components/empty-state.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/edit/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/edit/loading.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/edit/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/edit/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/loading.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/api-token/_components/actions.ts apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/actions.ts
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/api-token/_components/card.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/card.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/api-token/_components/create-form.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/create-form.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/api-token/_components/revoke-button.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/revoke-button.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/api-token/_components/submit-button.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/submit-button.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/api-token/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/api-token/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/billing/_components/customer-portal-button.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/_components/customer-portal-button.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/billing/_components/plan.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/_components/plan.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/billing/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/general/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/general/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/layout.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/layout.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/loading.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/page.tsx
-27
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/team/page.tsx
··· 1 - import { AlertTriangle } from "lucide-react"; 2 - 3 - import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 4 - 5 - import { columns } from "@/components/data-table/user/columns"; 6 - import { DataTable } from "@/components/data-table/user/data-table"; 7 - import { api } from "@/trpc/server"; 8 - 9 - export default async function TeamPage() { 10 - const workspace = await api.workspace.getWorkspace.query(); 11 - const users = await api.workspace.getWorkspaceUsers.query(); 12 - 13 - return ( 14 - <div className="flex flex-col gap-4"> 15 - <Alert> 16 - <AlertTriangle className="h-4 w-4" /> 17 - <AlertTitle>Team</AlertTitle> 18 - <AlertDescription> 19 - {workspace.plan === "free" 20 - ? "Please upgrade to invite more team members." 21 - : "Please contact us to invite more team members. We are working on a self-service solution."} 22 - </AlertDescription> 23 - </Alert> 24 - <DataTable data={users} columns={columns} /> 25 - </div> 26 - ); 27 - }
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/_components/action-button.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/_components/action-button.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/_components/empty-state.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/_components/empty-state.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/edit/_components/pro-feature-alert.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/edit/_components/pro-feature-alert.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/edit/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/edit/loading.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/edit/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/edit/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/loading.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-reports/_components/action-button.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/_components/action-button.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-reports/_components/delete-status-update.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/_components/delete-status-update.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-reports/_components/empty-state.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/_components/empty-state.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-reports/edit/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/edit/loading.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-reports/edit/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/edit/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-reports/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/loading.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-reports/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/page.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-reports/update/edit/loading.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/update/edit/loading.tsx
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-reports/update/edit/page.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/update/edit/page.tsx
apps/web/src/app/app/(dashboard)/onboarding/_components/description.tsx apps/web/src/app/app/[workspaceSlug]/onboarding/_components/description.tsx
-1
apps/web/src/app/app/(dashboard)/onboarding/layout.tsx apps/web/src/app/app/invite/layout.tsx
··· 3 3 import { Shell } from "@/components/dashboard/shell"; 4 4 import { AppHeader } from "@/components/layout/app-header"; 5 5 6 - // TODO: make the container min-h-screen and the footer below! 7 6 export default async function AppLayout({ 8 7 children, 9 8 }: {
+4 -15
apps/web/src/app/app/(dashboard)/onboarding/page.tsx apps/web/src/app/app/[workspaceSlug]/onboarding/page.tsx
··· 15 15 */ 16 16 const searchParamsSchema = z.object({ 17 17 id: z.coerce.number().optional(), // monitorId 18 - workspaceSlug: z.string().optional(), 19 18 }); 20 19 21 20 export default async function Onboarding({ 21 + params, 22 22 searchParams, 23 23 }: { 24 + params: { workspaceSlug: string }; 24 25 searchParams: { [key: string]: string | string[] | undefined }; 25 26 }) { 26 27 const search = searchParamsSchema.safeParse(searchParams); 28 + const { workspaceSlug } = params; 27 29 28 30 if (!search.success) { 29 31 return notFound(); 30 32 } 31 33 32 34 // Instead of having the workspaceSlug in the search params, we can get it from the auth user 33 - const { workspaceSlug, id: monitorId } = search.data; 34 - 35 - if (!workspaceSlug) { 36 - return ( 37 - <div className="flex flex-col gap-3"> 38 - <p className="text-lg">Waiting for Slug </p> 39 - <div> 40 - <Button variant="outline" asChild> 41 - <Link href="/app">Retry</Link> 42 - </Button> 43 - </div> 44 - </div> 45 - ); 46 - } 35 + const { id: monitorId } = search.data; 47 36 48 37 const allMonitors = await api.monitor.getMonitorsByWorkspace.query(); 49 38
+110
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/team/_components/invite-button.tsx
··· 1 + "use client"; 2 + 3 + import { useState, useTransition } from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import { zodResolver } from "@hookform/resolvers/zod"; 6 + import { useForm } from "react-hook-form"; 7 + import type * as z from "zod"; 8 + 9 + import { insertInvitationSchema } from "@openstatus/db/src/schema"; 10 + import { 11 + Button, 12 + Dialog, 13 + DialogContent, 14 + DialogDescription, 15 + DialogHeader, 16 + DialogTitle, 17 + DialogTrigger, 18 + Form, 19 + FormControl, 20 + FormDescription, 21 + FormField, 22 + FormItem, 23 + FormLabel, 24 + FormMessage, 25 + Input, 26 + } from "@openstatus/ui"; 27 + 28 + import { LoadingAnimation } from "@/components/loading-animation"; 29 + import { useToastAction } from "@/hooks/use-toast-action"; 30 + import { wait } from "@/lib/utils"; 31 + import { api } from "@/trpc/client"; 32 + 33 + const schema = insertInvitationSchema.pick({ email: true }); 34 + type Schema = z.infer<typeof schema>; 35 + 36 + export function InviteButton({ 37 + defaultValues, 38 + disabled, 39 + }: { 40 + defaultValues?: Schema; 41 + disabled?: boolean; 42 + }) { 43 + const [open, setOpen] = useState(false); 44 + const form = useForm<Schema>({ 45 + resolver: zodResolver(schema), 46 + defaultValues, 47 + }); 48 + const router = useRouter(); 49 + const [isPending, startTransition] = useTransition(); 50 + const { toast } = useToastAction(); 51 + 52 + async function onSubmit(data: Schema) { 53 + startTransition(async () => { 54 + try { 55 + wait(2000); 56 + api.invitation.create.mutate(data); 57 + toast("saved"); 58 + router.refresh(); 59 + } catch { 60 + toast("error"); 61 + } finally { 62 + setOpen(false); 63 + } 64 + }); 65 + } 66 + 67 + return ( 68 + <Dialog open={open} onOpenChange={setOpen}> 69 + <DialogTrigger asChild> 70 + <Button onClick={() => setOpen((v) => !v)} disabled={disabled}>Invite Member</Button> 71 + </DialogTrigger> 72 + <DialogContent> 73 + <DialogHeader> 74 + <DialogTitle>Invite your team members!</DialogTitle> 75 + <DialogDescription> 76 + They will receive an email invite to join your workspace. 77 + </DialogDescription> 78 + </DialogHeader> 79 + <Form {...form}> 80 + <form 81 + onSubmit={form.handleSubmit(onSubmit)} 82 + className="grid w-full grid-cols-1 items-center gap-6 sm:grid-cols-6" 83 + > 84 + <FormField 85 + control={form.control} 86 + name="email" 87 + render={({ field }) => ( 88 + <FormItem className="sm:col-span-5"> 89 + <FormLabel>Email</FormLabel> 90 + <FormControl> 91 + <Input {...field} /> 92 + </FormControl> 93 + <FormDescription> 94 + We will send an invite to this email address. 95 + </FormDescription> 96 + <FormMessage /> 97 + </FormItem> 98 + )} 99 + /> 100 + <div className="sm:col-span-full"> 101 + <Button className="w-full sm:w-auto" size="lg"> 102 + {!isPending ? "Confirm" : <LoadingAnimation />} 103 + </Button> 104 + </div> 105 + </form> 106 + </Form> 107 + </DialogContent> 108 + </Dialog> 109 + ); 110 + }
+53
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/team/page.tsx
··· 1 + import { AlertTriangle } from "lucide-react"; 2 + 3 + import { allPlans } from "@openstatus/plans"; 4 + import { 5 + Alert, 6 + AlertDescription, 7 + AlertTitle, 8 + ButtonWithDisableTooltip, 9 + } from "@openstatus/ui"; 10 + 11 + import { columns as invitationColumns } from "@/components/data-table/invitation/columns"; 12 + import { DataTable as InvitationDataTable } from "@/components/data-table/invitation/data-table"; 13 + import { columns as userColumns } from "@/components/data-table/user/columns"; 14 + import { DataTable as UserDataTable } from "@/components/data-table/user/data-table"; 15 + import { api } from "@/trpc/server"; 16 + import { InviteButton } from "./_components/invite-button"; 17 + 18 + export default async function TeamPage() { 19 + const workspace = await api.workspace.getWorkspace.query(); 20 + const invitations = await api.invitation.getWorkspaceOpenInvitations.query(); 21 + const users = await api.workspace.getWorkspaceUsers.query(); 22 + 23 + const isLimit = 24 + invitations.length + users.length >= 25 + allPlans[workspace.plan || "free"].limits.members; 26 + 27 + const isFreePlan = workspace.plan === "free"; 28 + 29 + return ( 30 + <div className="flex flex-col gap-4"> 31 + {isFreePlan ? ( 32 + <Alert> 33 + <AlertTriangle className="h-4 w-4" /> 34 + <AlertTitle>Team</AlertTitle> 35 + <AlertDescription> 36 + Please upgrade to invite more team members. 37 + </AlertDescription> 38 + </Alert> 39 + ) : null} 40 + {/* TODO: only show if isAdmin */} 41 + <div className="flex justify-end"> 42 + <InviteButton disabled={isLimit} /> 43 + </div> 44 + <UserDataTable 45 + data={users.map(({ role, user }) => ({ role, ...user }))} 46 + columns={userColumns} 47 + /> 48 + {invitations.length > 0 ? ( 49 + <InvitationDataTable data={invitations} columns={invitationColumns} /> 50 + ) : null} 51 + </div> 52 + ); 53 + }
+27
apps/web/src/app/app/[workspaceSlug]/onboarding/layout.tsx
··· 1 + import * as React from "react"; 2 + 3 + import { Shell } from "@/components/dashboard/shell"; 4 + import { AppHeader } from "@/components/layout/app-header"; 5 + import { WorkspaceClientCookie } from "../worskpace-client-cookie"; 6 + 7 + // TODO: make the container min-h-screen and the footer below! 8 + export default async function AppLayout({ 9 + params, 10 + children, 11 + }: { 12 + params: { workspaceSlug: string }; 13 + children: React.ReactNode; 14 + }) { 15 + const { workspaceSlug } = params; 16 + return ( 17 + <div className="container relative mx-auto flex min-h-screen w-full flex-col items-center justify-center gap-6 p-4 lg:p-8"> 18 + <AppHeader /> 19 + <div className="flex w-full flex-1 gap-6 lg:gap-8"> 20 + <main className="z-10 flex w-full flex-1 flex-col items-start justify-center"> 21 + <Shell className="relative flex-1">{children}</Shell> 22 + </main> 23 + </div> 24 + <WorkspaceClientCookie {...{ workspaceSlug }} /> 25 + </div> 26 + ); 27 + }
+22
apps/web/src/app/app/[workspaceSlug]/worskpace-client-cookie.ts
··· 1 + "use client"; 2 + 3 + import { useEffect } from "react"; 4 + 5 + /** 6 + * ISSUE: using the `middleware` to add a server httpOnly cookie doesn't work 7 + * req.nextUrl.pathname.startsWith("/app") is not true on the server as we are using the /api/trpc endpoint 8 + * to mutate our database. For some reasons, querying the database works fine. 9 + */ 10 + 11 + export function WorkspaceClientCookie({ 12 + workspaceSlug, 13 + }: { 14 + workspaceSlug: string; 15 + }) { 16 + useEffect(() => { 17 + if (document) { 18 + document.cookie = `workspace-slug=${workspaceSlug}; path=/`; 19 + } 20 + }, [workspaceSlug]); 21 + return null; 22 + }
+34
apps/web/src/app/app/invite/page.tsx
··· 1 + import Link from "next/link"; 2 + import { z } from "zod"; 3 + 4 + import { Button } from "@openstatus/ui"; 5 + 6 + import { api } from "@/trpc/server"; 7 + 8 + /** 9 + * allowed URL search params 10 + */ 11 + const searchParamsSchema = z.object({ 12 + token: z.string(), 13 + }); 14 + 15 + export default async function InvitePage({ 16 + searchParams, 17 + }: { 18 + searchParams: { [key: string]: string | string[] | undefined }; 19 + }) { 20 + const search = searchParamsSchema.safeParse(searchParams); 21 + 22 + const message = search.success 23 + ? await api.invitation.acceptInvitation.mutate({ token: search.data.token }) 24 + : "Invalid token"; 25 + 26 + return ( 27 + <div className="flex h-full flex-1 flex-col items-center justify-center gap-4"> 28 + <p className="text-muted-foreground text-lg">{message}</p> 29 + <Button> 30 + <Link href="/app">Dashboard</Link> 31 + </Button> 32 + </div> 33 + ); 34 + }
+47
apps/web/src/components/data-table/invitation/columns.tsx
··· 1 + "use client"; 2 + 3 + import type { ColumnDef } from "@tanstack/react-table"; 4 + 5 + import type { Invitation, WorkspaceRole } from "@openstatus/db/src/schema"; 6 + import { Badge } from "@openstatus/ui"; 7 + 8 + import { formatDate } from "@/lib/utils"; 9 + import { DataTableRowActions } from "./data-table-row-actions"; 10 + 11 + // TODO: add total number of monitors 12 + 13 + export const columns: ColumnDef<Invitation>[] = [ 14 + { 15 + accessorKey: "email", 16 + header: "Email", 17 + }, 18 + { 19 + accessorKey: "role", 20 + header: "Role", 21 + cell: ({ row }) => { 22 + const role = row.getValue("role") as WorkspaceRole; 23 + return ( 24 + <Badge variant={role === "member" ? "outline" : "default"}> 25 + {row.getValue("role")} 26 + </Badge> 27 + ); 28 + }, 29 + }, 30 + { 31 + accessorKey: "expiresAt", 32 + header: "Expires at", 33 + cell: ({ row }) => { 34 + return <span>{formatDate(row.getValue("expiresAt"))}</span>; 35 + }, 36 + }, 37 + { 38 + id: "actions", 39 + cell: ({ row }) => { 40 + return ( 41 + <div className="text-right"> 42 + <DataTableRowActions row={row} /> 43 + </div> 44 + ); 45 + }, 46 + }, 47 + ];
+83
apps/web/src/components/data-table/invitation/data-table-row-actions.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import type { Row } from "@tanstack/react-table"; 6 + 7 + import { selectInvitationSchema } from "@openstatus/db/src/schema"; 8 + import { 9 + AlertDialog, 10 + AlertDialogAction, 11 + AlertDialogCancel, 12 + AlertDialogContent, 13 + AlertDialogDescription, 14 + AlertDialogFooter, 15 + AlertDialogHeader, 16 + AlertDialogTitle, 17 + AlertDialogTrigger, 18 + Button, 19 + } from "@openstatus/ui"; 20 + 21 + import { LoadingAnimation } from "@/components/loading-animation"; 22 + import { useToastAction } from "@/hooks/use-toast-action"; 23 + import { api } from "@/trpc/client"; 24 + 25 + interface DataTableRowActionsProps<TData> { 26 + row: Row<TData>; 27 + } 28 + 29 + export function DataTableRowActions<TData>({ 30 + row, 31 + }: DataTableRowActionsProps<TData>) { 32 + const invitation = selectInvitationSchema.parse(row.original); 33 + const router = useRouter(); 34 + const { toast } = useToastAction(); 35 + const [alertOpen, setAlertOpen] = React.useState(false); 36 + const [isPending, startTransition] = React.useTransition(); 37 + 38 + async function onRevoke() { 39 + startTransition(async () => { 40 + try { 41 + if (!invitation.id) return; 42 + await api.invitation.delete.mutate({ id: invitation.id }); 43 + toast("deleted"); 44 + router.refresh(); 45 + setAlertOpen(false); 46 + } catch { 47 + toast("error"); 48 + } 49 + }); 50 + } 51 + 52 + return ( 53 + <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 54 + <AlertDialogTrigger asChild> 55 + <Button variant="outline" size="sm"> 56 + Revoke 57 + </Button> 58 + </AlertDialogTrigger> 59 + <AlertDialogContent> 60 + <AlertDialogHeader> 61 + <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 62 + <AlertDialogDescription> 63 + The invitation will be revoked and the user will no longer be able 64 + to join the workspace. 65 + </AlertDialogDescription> 66 + </AlertDialogHeader> 67 + <AlertDialogFooter> 68 + <AlertDialogCancel>Cancel</AlertDialogCancel> 69 + <AlertDialogAction 70 + onClick={(e) => { 71 + e.preventDefault(); 72 + onRevoke(); 73 + }} 74 + disabled={isPending} 75 + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 76 + > 77 + {!isPending ? "Revoke" : <LoadingAnimation />} 78 + </AlertDialogAction> 79 + </AlertDialogFooter> 80 + </AlertDialogContent> 81 + </AlertDialog> 82 + ); 83 + }
+81
apps/web/src/components/data-table/invitation/data-table.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import type { ColumnDef } from "@tanstack/react-table"; 5 + import { 6 + flexRender, 7 + getCoreRowModel, 8 + useReactTable, 9 + } from "@tanstack/react-table"; 10 + 11 + import { 12 + Table, 13 + TableBody, 14 + TableCell, 15 + TableHead, 16 + TableHeader, 17 + TableRow, 18 + } from "@openstatus/ui"; 19 + 20 + interface DataTableProps<TData, TValue> { 21 + columns: ColumnDef<TData, TValue>[]; 22 + data: TData[]; 23 + } 24 + 25 + export function DataTable<TData, TValue>({ 26 + columns, 27 + data, 28 + }: DataTableProps<TData, TValue>) { 29 + const table = useReactTable({ 30 + data, 31 + columns, 32 + getCoreRowModel: getCoreRowModel(), 33 + }); 34 + 35 + return ( 36 + <div className="rounded-md border"> 37 + <Table> 38 + <TableHeader className="bg-muted/50"> 39 + {table.getHeaderGroups().map((headerGroup) => ( 40 + <TableRow key={headerGroup.id} className="hover:bg-transparent"> 41 + {headerGroup.headers.map((header) => { 42 + return ( 43 + <TableHead key={header.id}> 44 + {header.isPlaceholder 45 + ? null 46 + : flexRender( 47 + header.column.columnDef.header, 48 + header.getContext(), 49 + )} 50 + </TableHead> 51 + ); 52 + })} 53 + </TableRow> 54 + ))} 55 + </TableHeader> 56 + <TableBody> 57 + {table.getRowModel().rows?.length ? ( 58 + table.getRowModel().rows.map((row) => ( 59 + <TableRow 60 + key={row.id} 61 + data-state={row.getIsSelected() && "selected"} 62 + > 63 + {row.getVisibleCells().map((cell) => ( 64 + <TableCell key={cell.id}> 65 + {flexRender(cell.column.columnDef.cell, cell.getContext())} 66 + </TableCell> 67 + ))} 68 + </TableRow> 69 + )) 70 + ) : ( 71 + <TableRow> 72 + <TableCell colSpan={columns.length} className="h-24 text-center"> 73 + No results. 74 + </TableCell> 75 + </TableRow> 76 + )} 77 + </TableBody> 78 + </Table> 79 + </div> 80 + ); 81 + }
+15 -2
apps/web/src/components/data-table/user/columns.tsx
··· 2 2 3 3 import type { ColumnDef } from "@tanstack/react-table"; 4 4 5 - import type { User } from "@openstatus/db/src/schema"; 5 + import type { User, WorkspaceRole } from "@openstatus/db/src/schema"; 6 + import { Badge } from "@openstatus/ui"; 6 7 7 8 import { formatDate } from "@/lib/utils"; 8 9 import { DataTableRowActions } from "./data-table-row-actions"; 9 10 10 11 // TODO: add total number of monitors 11 12 12 - export const columns: ColumnDef<User>[] = [ 13 + export const columns: ColumnDef<User & { role: WorkspaceRole }>[] = [ 13 14 { 14 15 accessorKey: "email", 15 16 header: "Email", ··· 21 22 </p> 22 23 <p className="text-muted-foreground">{row.getValue("email")}</p> 23 24 </div> 25 + ); 26 + }, 27 + }, 28 + { 29 + accessorKey: "role", 30 + header: "Role", 31 + cell: ({ row }) => { 32 + const role = row.getValue("role") as WorkspaceRole; 33 + return ( 34 + <Badge variant={role === "member" ? "outline" : "default"}> 35 + {row.getValue("role")} 36 + </Badge> 24 37 ); 25 38 }, 26 39 },
+4 -11
apps/web/src/components/layout/app-sidebar.tsx
··· 3 3 import Link from "next/link"; 4 4 import { useParams, usePathname } from "next/navigation"; 5 5 6 + import type { Workspace } from "@openstatus/db/src/schema"; 7 + 6 8 import { pagesConfig } from "@/config/pages"; 7 9 import { cn } from "@/lib/utils"; 8 10 import { ProBanner } from "../billing/pro-banner"; 9 11 import { Icons } from "../icons"; 12 + import { SelectWorkspace } from "../workspace/select-workspace"; 10 13 11 14 export function AppSidebar() { 12 15 const pathname = usePathname(); ··· 41 44 <ProBanner /> 42 45 </li> 43 46 <li className="w-full"> 44 - <Link 45 - href={`/app/${params?.workspaceSlug}/settings`} 46 - className={cn( 47 - "hover:bg-muted/50 hover:text-foreground text-muted-foreground group flex w-full min-w-[200px] items-center rounded-md border border-transparent px-3 py-1", 48 - pathname?.startsWith(`/app/${params?.workspaceSlug}/settings`) && 49 - "bg-muted/50 border-border text-foreground", 50 - )} 51 - > 52 - <Icons.cog className={cn("mr-2 h-4 w-4")} /> 53 - Settings 54 - </Link> 47 + <SelectWorkspace /> 55 48 </li> 56 49 </ul> 57 50 </div>
+1 -1
apps/web/src/components/status-update/events.tsx
··· 14 14 TooltipTrigger, 15 15 } from "@openstatus/ui"; 16 16 17 - import { DeleteStatusReportUpdateButtonIcon } from "@/app/app/(dashboard)/[workspaceSlug]/status-reports/_components/delete-status-update"; 17 + import { DeleteStatusReportUpdateButtonIcon } from "@/app/app/[workspaceSlug]/(dashboard)/status-reports/_components/delete-status-update"; 18 18 import { Icons } from "@/components/icons"; 19 19 import { statusDict } from "@/data/incidents-dictionary"; 20 20 import { useProcessor } from "@/hooks/use-preprocessor";
+85
apps/web/src/components/workspace/select-workspace.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import Link from "next/link"; 5 + import { usePathname } from "next/navigation"; 6 + import { Check, ChevronsUpDown, Plus } from "lucide-react"; 7 + 8 + import type { Workspace } from "@openstatus/db/src/schema"; 9 + import { 10 + Button, 11 + DropdownMenu, 12 + DropdownMenuContent, 13 + DropdownMenuItem, 14 + DropdownMenuLabel, 15 + DropdownMenuSeparator, 16 + DropdownMenuTrigger, 17 + Skeleton, 18 + } from "@openstatus/ui"; 19 + 20 + import { api } from "@/trpc/client"; 21 + 22 + export function SelectWorkspace() { 23 + const [workspaces, setWorkspaces] = React.useState<Workspace[]>([]); 24 + const [active, setActive] = React.useState<string>(); 25 + const pathname = usePathname(); 26 + 27 + React.useEffect(() => { 28 + if (pathname?.split("/")?.[2] && workspaces.length > 0) { 29 + setActive(pathname?.split("/")?.[2]); 30 + } 31 + }, [pathname, workspaces]); 32 + 33 + React.useEffect(() => { 34 + // REMINDER: avoid prop drilling to get data from the layout.tsx component. instead use client trpc 35 + async function fetchWorkspaces() { 36 + const _workspaces = await api.workspace.getUserWorkspaces.query(); 37 + setWorkspaces(_workspaces); 38 + } 39 + fetchWorkspaces(); 40 + }, []); 41 + 42 + return ( 43 + <DropdownMenu> 44 + <DropdownMenuTrigger asChild> 45 + <Button 46 + variant="outline" 47 + className="flex w-full items-center justify-between" 48 + > 49 + {active ? <span>{active}</span> : <Skeleton className="h-5 w-full" />} 50 + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 51 + </Button> 52 + </DropdownMenuTrigger> 53 + <DropdownMenuContent className="w-[var(--radix-dropdown-menu-trigger-width)]"> 54 + <DropdownMenuLabel>Workspaces</DropdownMenuLabel> 55 + <DropdownMenuSeparator /> 56 + {workspaces.map((workspace) => ( 57 + <DropdownMenuItem 58 + key={workspace.id} 59 + onClick={() => { 60 + if (workspace.slug !== active) { 61 + window.location.href = `/app/${workspace.slug}/monitors`; 62 + } 63 + }} 64 + className="justify-between" 65 + > 66 + {workspace.slug} 67 + {active === workspace.slug ? ( 68 + <Check className="ml-2 h-4 w-4" /> 69 + ) : null} 70 + </DropdownMenuItem> 71 + ))} 72 + <DropdownMenuSeparator /> 73 + <DropdownMenuItem asChild> 74 + <Link 75 + href={`/app/${active}/settings/team`} 76 + className="flex items-center justify-between" 77 + > 78 + Invite Members 79 + <Plus className="ml-2 h-4 w-4" /> 80 + </Link> 81 + </DropdownMenuItem> 82 + </DropdownMenuContent> 83 + </DropdownMenu> 84 + ); 85 + }
+6
apps/web/src/config/pages.ts
··· 34 34 href: "/notifications", 35 35 icon: "bell", 36 36 }, 37 + { 38 + title: "Settings", 39 + description: "Your workspace settings", 40 + href: "/settings", 41 + icon: "cog", 42 + }, 37 43 // { 38 44 // title: "Integrations", 39 45 // description: "Where you can see all the integrations.",
+10 -2
apps/web/src/middleware.ts
··· 83 83 "/status-page/(.*)", 84 84 "/incidents", // used when trying subdomain slug via status.documenso.com/incidents 85 85 ], 86 - ignoredRoutes: ["/api/og", "/discord", "github", "/status-page/(.*)"], // FIXME: we should check the `publicRoutes` 86 + ignoredRoutes: ["/api/og", "/discord", "/github", "/status-page/(.*)"], // FIXME: we should check the `publicRoutes` 87 87 beforeAuth: before, 88 88 debug: false, 89 89 async afterAuth(auth, req) { ··· 126 126 if (!firstMonitor) { 127 127 console.log(`>>> Redirecting to onboarding`); 128 128 const onboarding = new URL( 129 - `/app/onboarding?workspaceSlug=${currentWorkspace.slug}`, 129 + `/app/${currentWorkspace.slug}/onboarding`, 130 130 req.url, 131 131 ); 132 132 return NextResponse.redirect(onboarding); ··· 148 148 console.log("redirecting to onboarding"); 149 149 return; 150 150 } 151 + 152 + if (auth.userId && req.nextUrl.pathname.startsWith("/app")) { 153 + const workspaceSlug = req.nextUrl.pathname.split("/")?.[2]; 154 + // overrides the document.cookie set on the client 155 + if (workspaceSlug) req.cookies.set("workspace-slug", workspaceSlug); 156 + } 157 + 158 + // TODO: remove 151 159 if ( 152 160 auth.userId && 153 161 req.nextUrl.pathname === "/app/integrations/vercel/configure"
+2
packages/api/src/analytics.ts
··· 45 45 } 46 46 47 47 export async function trackNewStatusReport() {} 48 + 49 + export async function trackNewInvitation() {}
+2
packages/api/src/edge.ts
··· 1 1 import { domainRouter } from "./router/domain"; 2 2 import { integrationRouter } from "./router/integration"; 3 + import { invitationRouter } from "./router/invitation"; 3 4 import { monitorRouter } from "./router/monitor"; 4 5 import { notificationRouter } from "./router/notification"; 5 6 import { pageRouter } from "./router/page"; ··· 18 19 integration: integrationRouter, 19 20 user: userRouter, 20 21 notification: notificationRouter, 22 + invitation: invitationRouter 21 23 });
+1
packages/api/src/router/clerk/webhook.ts
··· 62 62 .values({ 63 63 userId: userResult.id, 64 64 workspaceId: workspaceResult.id, 65 + role: "owner", 65 66 }) 66 67 .returning() 67 68 .get();
+166
packages/api/src/router/invitation.ts
··· 1 + import { TRPCError } from "@trpc/server"; 2 + import { z } from "zod"; 3 + 4 + import { and, eq, gte, isNull } from "@openstatus/db"; 5 + import { 6 + insertInvitationSchema, 7 + invitation, 8 + user, 9 + usersToWorkspaces, 10 + } from "@openstatus/db/src/schema"; 11 + import { allPlans } from "@openstatus/plans"; 12 + 13 + import { trackNewInvitation } from "../analytics"; 14 + import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; 15 + 16 + export const invitationRouter = createTRPCRouter({ 17 + create: protectedProcedure 18 + .input(insertInvitationSchema.pick({ email: true })) 19 + .mutation(async (opts) => { 20 + const { email } = opts.input; 21 + 22 + const membersLimit = allPlans[opts.ctx.workspace.plan].limits.members; 23 + 24 + const usersToWorkspacesNumbers = ( 25 + await opts.ctx.db.query.usersToWorkspaces.findMany({ 26 + where: eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id), 27 + }) 28 + ).length; 29 + 30 + const openInvitationsNumbers = ( 31 + await opts.ctx.db.query.invitation.findMany({ 32 + where: and( 33 + eq(invitation.workspaceId, opts.ctx.workspace.id), 34 + gte(invitation.expiresAt, new Date()), 35 + isNull(invitation.acceptedAt), 36 + ), 37 + }) 38 + ).length; 39 + 40 + // the user has reached the limits 41 + if (usersToWorkspacesNumbers + openInvitationsNumbers >= membersLimit) { 42 + throw new TRPCError({ 43 + code: "FORBIDDEN", 44 + message: "You reached your member limits.", 45 + }); 46 + } 47 + 48 + const expiresAt = new Date(); 49 + expiresAt.setDate(expiresAt.getDate() + 7); 50 + 51 + const token = crypto.randomUUID(); 52 + 53 + await fetch("https://api.resend.com/emails", { 54 + method: "POST", 55 + headers: { 56 + "Content-Type": "application/json", 57 + Authorization: `Bearer ${process.env.RESEND_API_KEY}`, 58 + }, 59 + body: JSON.stringify({ 60 + to: email, 61 + from: "Maximilian Kaske <max@openstatus.dev>", 62 + subject: `You have been invited to join OpenStatus.dev`, 63 + html: `<p>Click here to join the workspace: <a href='https://openstatus.dev/app/invite?token=${token}'>accept invitation</a></p>`, 64 + }), 65 + }); 66 + 67 + const _invitation = await opts.ctx.db 68 + .insert(invitation) 69 + .values({ email, expiresAt, token, workspaceId: opts.ctx.workspace.id }) 70 + .returning() 71 + .get(); 72 + 73 + // TODO: 74 + await trackNewInvitation(); 75 + 76 + return _invitation; 77 + }), 78 + 79 + delete: protectedProcedure 80 + .input(z.object({ id: z.number() })) 81 + .mutation(async (opts) => { 82 + await opts.ctx.db 83 + .delete(invitation) 84 + .where( 85 + and( 86 + eq(invitation.id, opts.input.id), 87 + eq(invitation.workspaceId, opts.ctx.workspace.id), 88 + ), 89 + ) 90 + .run(); 91 + }), 92 + 93 + getWorkspaceOpenInvitations: protectedProcedure.query(async (opts) => { 94 + const _invitations = await opts.ctx.db.query.invitation.findMany({ 95 + where: and( 96 + eq(invitation.workspaceId, opts.ctx.workspace.id), 97 + gte(invitation.expiresAt, new Date()), 98 + isNull(invitation.acceptedAt), 99 + ), 100 + }); 101 + return _invitations; 102 + }), 103 + 104 + getInvitationByToken: protectedProcedure 105 + .input(z.object({ token: z.string() })) 106 + .query(async (opts) => { 107 + const _invitation = await opts.ctx.db.query.invitation.findFirst({ 108 + where: and(eq(invitation.token, opts.input.token)), 109 + with: { 110 + workspace: true, 111 + }, 112 + }); 113 + return _invitation; 114 + }), 115 + 116 + /** 117 + * REMINDER: we are not using a protected procedure here of the `/app/invite` url 118 + * instead of `/app/workspace-slug/invite` as the user is not allowed to it yet. 119 + * We validate the auth token in the `acceptInvitation` procedure 120 + */ 121 + acceptInvitation: publicProcedure 122 + .input(z.object({ token: z.string() })) 123 + .mutation(async (opts) => { 124 + const _invitation = await opts.ctx.db.query.invitation.findFirst({ 125 + where: and( 126 + eq(invitation.token, opts.input.token), 127 + isNull(invitation.acceptedAt), 128 + ), 129 + with: { 130 + workspace: true, 131 + }, 132 + }); 133 + 134 + const _user = await opts.ctx.db.query.user.findFirst({ 135 + where: eq(user.tenantId, opts.ctx.auth?.userId || ""), 136 + }); 137 + 138 + if (!_user) return "Invalid user"; 139 + 140 + if (!_invitation) return "Invalid invitation token"; 141 + 142 + if (_invitation.email !== _user.email) 143 + return "You are not invited to this workspace"; 144 + 145 + if (_invitation.expiresAt.getTime() < new Date().getTime()) { 146 + return "Invitation expired"; 147 + } 148 + 149 + await opts.ctx.db 150 + .update(invitation) 151 + .set({ acceptedAt: new Date() }) 152 + .where(eq(invitation.id, _invitation.id)) 153 + .run(); 154 + 155 + await opts.ctx.db 156 + .insert(usersToWorkspaces) 157 + .values({ 158 + userId: _user.id, 159 + workspaceId: _invitation.workspaceId, 160 + role: _invitation.role, 161 + }) 162 + .run(); 163 + 164 + return "Invitation accepted"; 165 + }), 166 + });
+2
packages/api/src/router/user.ts
··· 1 + import { z } from "zod"; 2 + 1 3 import { eq } from "@openstatus/db"; 2 4 import { user } from "@openstatus/db/src/schema"; 3 5
+14 -1
packages/api/src/router/workspace.ts
··· 33 33 return selectWorkspaceSchema.parse(result); 34 34 }), 35 35 36 + getUserWorkspaces: protectedProcedure.query(async (opts) => { 37 + const result = await opts.ctx.db.query.usersToWorkspaces.findMany({ 38 + where: eq(usersToWorkspaces.userId, opts.ctx.user.id), 39 + with: { 40 + workspace: true, 41 + }, 42 + }); 43 + 44 + return selectWorkspaceSchema 45 + .array() 46 + .parse(result.map(({ workspace }) => workspace)); 47 + }), 48 + 36 49 getWorkspaceUsers: protectedProcedure.query(async (opts) => { 37 50 // const result = await opts.ctx.db 38 51 // .select() ··· 48 61 }, 49 62 where: eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id), 50 63 }); 51 - return result.map(({ user }) => user); 64 + return result; 52 65 }), 53 66 54 67 createWorkspace: protectedProcedure
+49 -19
packages/api/src/trpc.ts
··· 118 118 throw new TRPCError({ code: "UNAUTHORIZED" }); 119 119 } 120 120 121 + // /** 122 + // * Attach `user` and `workspace` | `activeWorkspace` infos to context by 123 + // * comparing the `user.tenantId` to clerk's `auth.userId` 124 + // */ 125 + const userAndWorkspace = await db.query.user.findFirst({ 126 + where: eq(schema.user.tenantId, ctx.auth.userId), 127 + with: { 128 + usersToWorkspaces: { 129 + with: { 130 + workspace: true, 131 + }, 132 + }, 133 + }, 134 + }); 135 + 136 + const { usersToWorkspaces, ...userProps } = userAndWorkspace || {}; 137 + 121 138 /** 122 - * Attach `user` and `workspace` infos to context by 123 - * comparing the `user.tenantId` to clerk's `auth.userId` 139 + * We need to include the active "workspace-slug" cookie in the request found in the 140 + * `/app/[workspaceSlug]/.../`routes. We pass them either via middleware if it's a 141 + * server request or via the client cookie, set via `<WorspaceClientCookie />` 142 + * if it's a client request. 143 + * 144 + * REMINDER: We only need the client cookie because of client side mutations. 124 145 */ 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(); 146 + const workspaceSlug = ctx.req?.cookies.get("workspace-slug")?.value; 138 147 139 - const workspace = schema.selectWorkspaceSchema.parse(query?.workspace); 140 - const user = schema.selectUserSchema.parse(query?.user); 148 + // if (!workspaceSlug) { 149 + // throw new TRPCError({ 150 + // code: "UNAUTHORIZED", 151 + // message: "Workspace Slug Not Found", 152 + // }); 153 + // } 141 154 142 - if (!workspace && !user) { 143 - throw new TRPCError({ code: "UNAUTHORIZED" }); 155 + const activeWorkspace = usersToWorkspaces?.find(({ workspace }) => { 156 + // If there is a workspace slug in the cookie, use it to find the workspace 157 + if (workspaceSlug) return workspace.slug === workspaceSlug; 158 + return true; 159 + })?.workspace; 160 + 161 + if (!activeWorkspace) { 162 + throw new TRPCError({ 163 + code: "UNAUTHORIZED", 164 + message: "Workspace Not Found", 165 + }); 144 166 } 145 167 168 + if (!userProps) { 169 + throw new TRPCError({ code: "UNAUTHORIZED", message: "User Not Found" }); 170 + } 171 + 172 + const user = schema.selectUserSchema.parse(userProps); 173 + const workspace = schema.selectWorkspaceSchema.parse(activeWorkspace); 174 + 146 175 return next({ 147 176 ctx: { 177 + ...ctx, 148 178 auth: { 149 179 ...ctx.auth, 150 180 userId: ctx.auth.userId,
+12
packages/db/drizzle/0012_tan_magma.sql
··· 1 + CREATE TABLE `invitation` ( 2 + `id` integer PRIMARY KEY NOT NULL, 3 + `email` text NOT NULL, 4 + `role` text DEFAULT 'member' NOT NULL, 5 + `workspace_id` integer NOT NULL, 6 + `token` text NOT NULL, 7 + `expires_at` integer NOT NULL, 8 + `created_at` integer DEFAULT (strftime('%s', 'now')), 9 + `accepted_at` integer 10 + ); 11 + --> statement-breakpoint 12 + ALTER TABLE users_to_workspaces ADD `role` text DEFAULT 'owner' NOT NULL;
+1169
packages/db/drizzle/meta/0012_snapshot.json
··· 1 + { 2 + "version": "5", 3 + "dialect": "sqlite", 4 + "id": "b5c53ce9-981d-4a7c-a0da-c82e517fc0a3", 5 + "prevId": "21a5d3a2-6aab-4f67-8385-19d905e1a232", 6 + "tables": { 7 + "status_report_to_monitors": { 8 + "name": "status_report_to_monitors", 9 + "columns": { 10 + "monitor_id": { 11 + "name": "monitor_id", 12 + "type": "integer", 13 + "primaryKey": false, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "status_report_id": { 18 + "name": "status_report_id", 19 + "type": "integer", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + } 24 + }, 25 + "indexes": {}, 26 + "foreignKeys": { 27 + "status_report_to_monitors_monitor_id_monitor_id_fk": { 28 + "name": "status_report_to_monitors_monitor_id_monitor_id_fk", 29 + "tableFrom": "status_report_to_monitors", 30 + "tableTo": "monitor", 31 + "columnsFrom": [ 32 + "monitor_id" 33 + ], 34 + "columnsTo": [ 35 + "id" 36 + ], 37 + "onDelete": "cascade", 38 + "onUpdate": "no action" 39 + }, 40 + "status_report_to_monitors_status_report_id_status_report_id_fk": { 41 + "name": "status_report_to_monitors_status_report_id_status_report_id_fk", 42 + "tableFrom": "status_report_to_monitors", 43 + "tableTo": "status_report", 44 + "columnsFrom": [ 45 + "status_report_id" 46 + ], 47 + "columnsTo": [ 48 + "id" 49 + ], 50 + "onDelete": "cascade", 51 + "onUpdate": "no action" 52 + } 53 + }, 54 + "compositePrimaryKeys": { 55 + "status_report_to_monitors_monitor_id_status_report_id_pk": { 56 + "columns": [ 57 + "monitor_id", 58 + "status_report_id" 59 + ] 60 + } 61 + }, 62 + "uniqueConstraints": {} 63 + }, 64 + "status_reports_to_pages": { 65 + "name": "status_reports_to_pages", 66 + "columns": { 67 + "page_id": { 68 + "name": "page_id", 69 + "type": "integer", 70 + "primaryKey": false, 71 + "notNull": true, 72 + "autoincrement": false 73 + }, 74 + "status_report_id": { 75 + "name": "status_report_id", 76 + "type": "integer", 77 + "primaryKey": false, 78 + "notNull": true, 79 + "autoincrement": false 80 + } 81 + }, 82 + "indexes": {}, 83 + "foreignKeys": { 84 + "status_reports_to_pages_page_id_page_id_fk": { 85 + "name": "status_reports_to_pages_page_id_page_id_fk", 86 + "tableFrom": "status_reports_to_pages", 87 + "tableTo": "page", 88 + "columnsFrom": [ 89 + "page_id" 90 + ], 91 + "columnsTo": [ 92 + "id" 93 + ], 94 + "onDelete": "cascade", 95 + "onUpdate": "no action" 96 + }, 97 + "status_reports_to_pages_status_report_id_status_report_id_fk": { 98 + "name": "status_reports_to_pages_status_report_id_status_report_id_fk", 99 + "tableFrom": "status_reports_to_pages", 100 + "tableTo": "status_report", 101 + "columnsFrom": [ 102 + "status_report_id" 103 + ], 104 + "columnsTo": [ 105 + "id" 106 + ], 107 + "onDelete": "cascade", 108 + "onUpdate": "no action" 109 + } 110 + }, 111 + "compositePrimaryKeys": { 112 + "status_reports_to_pages_page_id_status_report_id_pk": { 113 + "columns": [ 114 + "page_id", 115 + "status_report_id" 116 + ] 117 + } 118 + }, 119 + "uniqueConstraints": {} 120 + }, 121 + "status_report": { 122 + "name": "status_report", 123 + "columns": { 124 + "id": { 125 + "name": "id", 126 + "type": "integer", 127 + "primaryKey": true, 128 + "notNull": true, 129 + "autoincrement": false 130 + }, 131 + "status": { 132 + "name": "status", 133 + "type": "text", 134 + "primaryKey": false, 135 + "notNull": true, 136 + "autoincrement": false 137 + }, 138 + "title": { 139 + "name": "title", 140 + "type": "text(256)", 141 + "primaryKey": false, 142 + "notNull": true, 143 + "autoincrement": false 144 + }, 145 + "workspace_id": { 146 + "name": "workspace_id", 147 + "type": "integer", 148 + "primaryKey": false, 149 + "notNull": false, 150 + "autoincrement": false 151 + }, 152 + "created_at": { 153 + "name": "created_at", 154 + "type": "integer", 155 + "primaryKey": false, 156 + "notNull": false, 157 + "autoincrement": false, 158 + "default": "(strftime('%s', 'now'))" 159 + }, 160 + "updated_at": { 161 + "name": "updated_at", 162 + "type": "integer", 163 + "primaryKey": false, 164 + "notNull": false, 165 + "autoincrement": false, 166 + "default": "(strftime('%s', 'now'))" 167 + } 168 + }, 169 + "indexes": {}, 170 + "foreignKeys": { 171 + "status_report_workspace_id_workspace_id_fk": { 172 + "name": "status_report_workspace_id_workspace_id_fk", 173 + "tableFrom": "status_report", 174 + "tableTo": "workspace", 175 + "columnsFrom": [ 176 + "workspace_id" 177 + ], 178 + "columnsTo": [ 179 + "id" 180 + ], 181 + "onDelete": "no action", 182 + "onUpdate": "no action" 183 + } 184 + }, 185 + "compositePrimaryKeys": {}, 186 + "uniqueConstraints": {} 187 + }, 188 + "status_report_update": { 189 + "name": "status_report_update", 190 + "columns": { 191 + "id": { 192 + "name": "id", 193 + "type": "integer", 194 + "primaryKey": true, 195 + "notNull": true, 196 + "autoincrement": false 197 + }, 198 + "status": { 199 + "name": "status", 200 + "type": "text(4)", 201 + "primaryKey": false, 202 + "notNull": true, 203 + "autoincrement": false 204 + }, 205 + "date": { 206 + "name": "date", 207 + "type": "integer", 208 + "primaryKey": false, 209 + "notNull": true, 210 + "autoincrement": false 211 + }, 212 + "message": { 213 + "name": "message", 214 + "type": "text", 215 + "primaryKey": false, 216 + "notNull": true, 217 + "autoincrement": false 218 + }, 219 + "status_report_id": { 220 + "name": "status_report_id", 221 + "type": "integer", 222 + "primaryKey": false, 223 + "notNull": true, 224 + "autoincrement": false 225 + }, 226 + "created_at": { 227 + "name": "created_at", 228 + "type": "integer", 229 + "primaryKey": false, 230 + "notNull": false, 231 + "autoincrement": false, 232 + "default": "(strftime('%s', 'now'))" 233 + }, 234 + "updated_at": { 235 + "name": "updated_at", 236 + "type": "integer", 237 + "primaryKey": false, 238 + "notNull": false, 239 + "autoincrement": false, 240 + "default": "(strftime('%s', 'now'))" 241 + } 242 + }, 243 + "indexes": {}, 244 + "foreignKeys": { 245 + "status_report_update_status_report_id_status_report_id_fk": { 246 + "name": "status_report_update_status_report_id_status_report_id_fk", 247 + "tableFrom": "status_report_update", 248 + "tableTo": "status_report", 249 + "columnsFrom": [ 250 + "status_report_id" 251 + ], 252 + "columnsTo": [ 253 + "id" 254 + ], 255 + "onDelete": "cascade", 256 + "onUpdate": "no action" 257 + } 258 + }, 259 + "compositePrimaryKeys": {}, 260 + "uniqueConstraints": {} 261 + }, 262 + "integration": { 263 + "name": "integration", 264 + "columns": { 265 + "id": { 266 + "name": "id", 267 + "type": "integer", 268 + "primaryKey": true, 269 + "notNull": true, 270 + "autoincrement": false 271 + }, 272 + "name": { 273 + "name": "name", 274 + "type": "text(256)", 275 + "primaryKey": false, 276 + "notNull": true, 277 + "autoincrement": false 278 + }, 279 + "workspace_id": { 280 + "name": "workspace_id", 281 + "type": "integer", 282 + "primaryKey": false, 283 + "notNull": false, 284 + "autoincrement": false 285 + }, 286 + "credential": { 287 + "name": "credential", 288 + "type": "text", 289 + "primaryKey": false, 290 + "notNull": false, 291 + "autoincrement": false 292 + }, 293 + "external_id": { 294 + "name": "external_id", 295 + "type": "text", 296 + "primaryKey": false, 297 + "notNull": true, 298 + "autoincrement": false 299 + }, 300 + "created_at": { 301 + "name": "created_at", 302 + "type": "integer", 303 + "primaryKey": false, 304 + "notNull": false, 305 + "autoincrement": false, 306 + "default": "(strftime('%s', 'now'))" 307 + }, 308 + "updated_at": { 309 + "name": "updated_at", 310 + "type": "integer", 311 + "primaryKey": false, 312 + "notNull": false, 313 + "autoincrement": false, 314 + "default": "(strftime('%s', 'now'))" 315 + }, 316 + "data": { 317 + "name": "data", 318 + "type": "text", 319 + "primaryKey": false, 320 + "notNull": true, 321 + "autoincrement": false 322 + } 323 + }, 324 + "indexes": {}, 325 + "foreignKeys": { 326 + "integration_workspace_id_workspace_id_fk": { 327 + "name": "integration_workspace_id_workspace_id_fk", 328 + "tableFrom": "integration", 329 + "tableTo": "workspace", 330 + "columnsFrom": [ 331 + "workspace_id" 332 + ], 333 + "columnsTo": [ 334 + "id" 335 + ], 336 + "onDelete": "no action", 337 + "onUpdate": "no action" 338 + } 339 + }, 340 + "compositePrimaryKeys": {}, 341 + "uniqueConstraints": {} 342 + }, 343 + "page": { 344 + "name": "page", 345 + "columns": { 346 + "id": { 347 + "name": "id", 348 + "type": "integer", 349 + "primaryKey": true, 350 + "notNull": true, 351 + "autoincrement": false 352 + }, 353 + "workspace_id": { 354 + "name": "workspace_id", 355 + "type": "integer", 356 + "primaryKey": false, 357 + "notNull": true, 358 + "autoincrement": false 359 + }, 360 + "title": { 361 + "name": "title", 362 + "type": "text", 363 + "primaryKey": false, 364 + "notNull": true, 365 + "autoincrement": false 366 + }, 367 + "description": { 368 + "name": "description", 369 + "type": "text", 370 + "primaryKey": false, 371 + "notNull": true, 372 + "autoincrement": false 373 + }, 374 + "icon": { 375 + "name": "icon", 376 + "type": "text(256)", 377 + "primaryKey": false, 378 + "notNull": false, 379 + "autoincrement": false, 380 + "default": "''" 381 + }, 382 + "slug": { 383 + "name": "slug", 384 + "type": "text(256)", 385 + "primaryKey": false, 386 + "notNull": true, 387 + "autoincrement": false 388 + }, 389 + "custom_domain": { 390 + "name": "custom_domain", 391 + "type": "text(256)", 392 + "primaryKey": false, 393 + "notNull": true, 394 + "autoincrement": false 395 + }, 396 + "published": { 397 + "name": "published", 398 + "type": "integer", 399 + "primaryKey": false, 400 + "notNull": false, 401 + "autoincrement": false, 402 + "default": false 403 + }, 404 + "created_at": { 405 + "name": "created_at", 406 + "type": "integer", 407 + "primaryKey": false, 408 + "notNull": false, 409 + "autoincrement": false, 410 + "default": "(strftime('%s', 'now'))" 411 + }, 412 + "updated_at": { 413 + "name": "updated_at", 414 + "type": "integer", 415 + "primaryKey": false, 416 + "notNull": false, 417 + "autoincrement": false, 418 + "default": "(strftime('%s', 'now'))" 419 + } 420 + }, 421 + "indexes": { 422 + "page_slug_unique": { 423 + "name": "page_slug_unique", 424 + "columns": [ 425 + "slug" 426 + ], 427 + "isUnique": true 428 + } 429 + }, 430 + "foreignKeys": { 431 + "page_workspace_id_workspace_id_fk": { 432 + "name": "page_workspace_id_workspace_id_fk", 433 + "tableFrom": "page", 434 + "tableTo": "workspace", 435 + "columnsFrom": [ 436 + "workspace_id" 437 + ], 438 + "columnsTo": [ 439 + "id" 440 + ], 441 + "onDelete": "cascade", 442 + "onUpdate": "no action" 443 + } 444 + }, 445 + "compositePrimaryKeys": {}, 446 + "uniqueConstraints": {} 447 + }, 448 + "monitor": { 449 + "name": "monitor", 450 + "columns": { 451 + "id": { 452 + "name": "id", 453 + "type": "integer", 454 + "primaryKey": true, 455 + "notNull": true, 456 + "autoincrement": false 457 + }, 458 + "job_type": { 459 + "name": "job_type", 460 + "type": "text", 461 + "primaryKey": false, 462 + "notNull": true, 463 + "autoincrement": false, 464 + "default": "'other'" 465 + }, 466 + "periodicity": { 467 + "name": "periodicity", 468 + "type": "text", 469 + "primaryKey": false, 470 + "notNull": true, 471 + "autoincrement": false, 472 + "default": "'other'" 473 + }, 474 + "status": { 475 + "name": "status", 476 + "type": "text", 477 + "primaryKey": false, 478 + "notNull": true, 479 + "autoincrement": false, 480 + "default": "'active'" 481 + }, 482 + "active": { 483 + "name": "active", 484 + "type": "integer", 485 + "primaryKey": false, 486 + "notNull": false, 487 + "autoincrement": false, 488 + "default": false 489 + }, 490 + "regions": { 491 + "name": "regions", 492 + "type": "text", 493 + "primaryKey": false, 494 + "notNull": true, 495 + "autoincrement": false, 496 + "default": "''" 497 + }, 498 + "url": { 499 + "name": "url", 500 + "type": "text(2048)", 501 + "primaryKey": false, 502 + "notNull": true, 503 + "autoincrement": false 504 + }, 505 + "name": { 506 + "name": "name", 507 + "type": "text(256)", 508 + "primaryKey": false, 509 + "notNull": true, 510 + "autoincrement": false, 511 + "default": "''" 512 + }, 513 + "description": { 514 + "name": "description", 515 + "type": "text", 516 + "primaryKey": false, 517 + "notNull": true, 518 + "autoincrement": false, 519 + "default": "''" 520 + }, 521 + "headers": { 522 + "name": "headers", 523 + "type": "text", 524 + "primaryKey": false, 525 + "notNull": false, 526 + "autoincrement": false, 527 + "default": "''" 528 + }, 529 + "body": { 530 + "name": "body", 531 + "type": "text", 532 + "primaryKey": false, 533 + "notNull": false, 534 + "autoincrement": false, 535 + "default": "''" 536 + }, 537 + "method": { 538 + "name": "method", 539 + "type": "text", 540 + "primaryKey": false, 541 + "notNull": false, 542 + "autoincrement": false, 543 + "default": "'GET'" 544 + }, 545 + "workspace_id": { 546 + "name": "workspace_id", 547 + "type": "integer", 548 + "primaryKey": false, 549 + "notNull": false, 550 + "autoincrement": false 551 + }, 552 + "created_at": { 553 + "name": "created_at", 554 + "type": "integer", 555 + "primaryKey": false, 556 + "notNull": false, 557 + "autoincrement": false, 558 + "default": "(strftime('%s', 'now'))" 559 + }, 560 + "updated_at": { 561 + "name": "updated_at", 562 + "type": "integer", 563 + "primaryKey": false, 564 + "notNull": false, 565 + "autoincrement": false, 566 + "default": "(strftime('%s', 'now'))" 567 + } 568 + }, 569 + "indexes": {}, 570 + "foreignKeys": { 571 + "monitor_workspace_id_workspace_id_fk": { 572 + "name": "monitor_workspace_id_workspace_id_fk", 573 + "tableFrom": "monitor", 574 + "tableTo": "workspace", 575 + "columnsFrom": [ 576 + "workspace_id" 577 + ], 578 + "columnsTo": [ 579 + "id" 580 + ], 581 + "onDelete": "no action", 582 + "onUpdate": "no action" 583 + } 584 + }, 585 + "compositePrimaryKeys": {}, 586 + "uniqueConstraints": {} 587 + }, 588 + "monitors_to_pages": { 589 + "name": "monitors_to_pages", 590 + "columns": { 591 + "monitor_id": { 592 + "name": "monitor_id", 593 + "type": "integer", 594 + "primaryKey": false, 595 + "notNull": true, 596 + "autoincrement": false 597 + }, 598 + "page_id": { 599 + "name": "page_id", 600 + "type": "integer", 601 + "primaryKey": false, 602 + "notNull": true, 603 + "autoincrement": false 604 + } 605 + }, 606 + "indexes": {}, 607 + "foreignKeys": { 608 + "monitors_to_pages_monitor_id_monitor_id_fk": { 609 + "name": "monitors_to_pages_monitor_id_monitor_id_fk", 610 + "tableFrom": "monitors_to_pages", 611 + "tableTo": "monitor", 612 + "columnsFrom": [ 613 + "monitor_id" 614 + ], 615 + "columnsTo": [ 616 + "id" 617 + ], 618 + "onDelete": "cascade", 619 + "onUpdate": "no action" 620 + }, 621 + "monitors_to_pages_page_id_page_id_fk": { 622 + "name": "monitors_to_pages_page_id_page_id_fk", 623 + "tableFrom": "monitors_to_pages", 624 + "tableTo": "page", 625 + "columnsFrom": [ 626 + "page_id" 627 + ], 628 + "columnsTo": [ 629 + "id" 630 + ], 631 + "onDelete": "cascade", 632 + "onUpdate": "no action" 633 + } 634 + }, 635 + "compositePrimaryKeys": { 636 + "monitors_to_pages_monitor_id_page_id_pk": { 637 + "columns": [ 638 + "monitor_id", 639 + "page_id" 640 + ] 641 + } 642 + }, 643 + "uniqueConstraints": {} 644 + }, 645 + "user": { 646 + "name": "user", 647 + "columns": { 648 + "id": { 649 + "name": "id", 650 + "type": "integer", 651 + "primaryKey": true, 652 + "notNull": true, 653 + "autoincrement": false 654 + }, 655 + "tenant_id": { 656 + "name": "tenant_id", 657 + "type": "text(256)", 658 + "primaryKey": false, 659 + "notNull": false, 660 + "autoincrement": false 661 + }, 662 + "first_name": { 663 + "name": "first_name", 664 + "type": "text", 665 + "primaryKey": false, 666 + "notNull": false, 667 + "autoincrement": false, 668 + "default": "''" 669 + }, 670 + "last_name": { 671 + "name": "last_name", 672 + "type": "text", 673 + "primaryKey": false, 674 + "notNull": false, 675 + "autoincrement": false, 676 + "default": "''" 677 + }, 678 + "email": { 679 + "name": "email", 680 + "type": "text", 681 + "primaryKey": false, 682 + "notNull": false, 683 + "autoincrement": false, 684 + "default": "''" 685 + }, 686 + "photo_url": { 687 + "name": "photo_url", 688 + "type": "text", 689 + "primaryKey": false, 690 + "notNull": false, 691 + "autoincrement": false, 692 + "default": "''" 693 + }, 694 + "created_at": { 695 + "name": "created_at", 696 + "type": "integer", 697 + "primaryKey": false, 698 + "notNull": false, 699 + "autoincrement": false, 700 + "default": "(strftime('%s', 'now'))" 701 + }, 702 + "updated_at": { 703 + "name": "updated_at", 704 + "type": "integer", 705 + "primaryKey": false, 706 + "notNull": false, 707 + "autoincrement": false, 708 + "default": "(strftime('%s', 'now'))" 709 + } 710 + }, 711 + "indexes": { 712 + "user_tenant_id_unique": { 713 + "name": "user_tenant_id_unique", 714 + "columns": [ 715 + "tenant_id" 716 + ], 717 + "isUnique": true 718 + } 719 + }, 720 + "foreignKeys": {}, 721 + "compositePrimaryKeys": {}, 722 + "uniqueConstraints": {} 723 + }, 724 + "users_to_workspaces": { 725 + "name": "users_to_workspaces", 726 + "columns": { 727 + "user_id": { 728 + "name": "user_id", 729 + "type": "integer", 730 + "primaryKey": false, 731 + "notNull": true, 732 + "autoincrement": false 733 + }, 734 + "workspace_id": { 735 + "name": "workspace_id", 736 + "type": "integer", 737 + "primaryKey": false, 738 + "notNull": true, 739 + "autoincrement": false 740 + }, 741 + "role": { 742 + "name": "role", 743 + "type": "text", 744 + "primaryKey": false, 745 + "notNull": true, 746 + "autoincrement": false, 747 + "default": "'member'" 748 + } 749 + }, 750 + "indexes": {}, 751 + "foreignKeys": { 752 + "users_to_workspaces_user_id_user_id_fk": { 753 + "name": "users_to_workspaces_user_id_user_id_fk", 754 + "tableFrom": "users_to_workspaces", 755 + "tableTo": "user", 756 + "columnsFrom": [ 757 + "user_id" 758 + ], 759 + "columnsTo": [ 760 + "id" 761 + ], 762 + "onDelete": "no action", 763 + "onUpdate": "no action" 764 + }, 765 + "users_to_workspaces_workspace_id_workspace_id_fk": { 766 + "name": "users_to_workspaces_workspace_id_workspace_id_fk", 767 + "tableFrom": "users_to_workspaces", 768 + "tableTo": "workspace", 769 + "columnsFrom": [ 770 + "workspace_id" 771 + ], 772 + "columnsTo": [ 773 + "id" 774 + ], 775 + "onDelete": "no action", 776 + "onUpdate": "no action" 777 + } 778 + }, 779 + "compositePrimaryKeys": { 780 + "users_to_workspaces_user_id_workspace_id_pk": { 781 + "columns": [ 782 + "user_id", 783 + "workspace_id" 784 + ] 785 + } 786 + }, 787 + "uniqueConstraints": {} 788 + }, 789 + "workspace": { 790 + "name": "workspace", 791 + "columns": { 792 + "id": { 793 + "name": "id", 794 + "type": "integer", 795 + "primaryKey": true, 796 + "notNull": true, 797 + "autoincrement": false 798 + }, 799 + "slug": { 800 + "name": "slug", 801 + "type": "text", 802 + "primaryKey": false, 803 + "notNull": true, 804 + "autoincrement": false 805 + }, 806 + "name": { 807 + "name": "name", 808 + "type": "text", 809 + "primaryKey": false, 810 + "notNull": false, 811 + "autoincrement": false 812 + }, 813 + "stripe_id": { 814 + "name": "stripe_id", 815 + "type": "text(256)", 816 + "primaryKey": false, 817 + "notNull": false, 818 + "autoincrement": false 819 + }, 820 + "subscription_id": { 821 + "name": "subscription_id", 822 + "type": "text", 823 + "primaryKey": false, 824 + "notNull": false, 825 + "autoincrement": false 826 + }, 827 + "plan": { 828 + "name": "plan", 829 + "type": "text", 830 + "primaryKey": false, 831 + "notNull": false, 832 + "autoincrement": false 833 + }, 834 + "ends_at": { 835 + "name": "ends_at", 836 + "type": "integer", 837 + "primaryKey": false, 838 + "notNull": false, 839 + "autoincrement": false 840 + }, 841 + "paid_until": { 842 + "name": "paid_until", 843 + "type": "integer", 844 + "primaryKey": false, 845 + "notNull": false, 846 + "autoincrement": false 847 + }, 848 + "created_at": { 849 + "name": "created_at", 850 + "type": "integer", 851 + "primaryKey": false, 852 + "notNull": false, 853 + "autoincrement": false, 854 + "default": "(strftime('%s', 'now'))" 855 + }, 856 + "updated_at": { 857 + "name": "updated_at", 858 + "type": "integer", 859 + "primaryKey": false, 860 + "notNull": false, 861 + "autoincrement": false, 862 + "default": "(strftime('%s', 'now'))" 863 + } 864 + }, 865 + "indexes": { 866 + "workspace_slug_unique": { 867 + "name": "workspace_slug_unique", 868 + "columns": [ 869 + "slug" 870 + ], 871 + "isUnique": true 872 + }, 873 + "workspace_stripe_id_unique": { 874 + "name": "workspace_stripe_id_unique", 875 + "columns": [ 876 + "stripe_id" 877 + ], 878 + "isUnique": true 879 + } 880 + }, 881 + "foreignKeys": {}, 882 + "compositePrimaryKeys": {}, 883 + "uniqueConstraints": {} 884 + }, 885 + "notification": { 886 + "name": "notification", 887 + "columns": { 888 + "id": { 889 + "name": "id", 890 + "type": "integer", 891 + "primaryKey": true, 892 + "notNull": true, 893 + "autoincrement": false 894 + }, 895 + "name": { 896 + "name": "name", 897 + "type": "text", 898 + "primaryKey": false, 899 + "notNull": true, 900 + "autoincrement": false 901 + }, 902 + "provider": { 903 + "name": "provider", 904 + "type": "text", 905 + "primaryKey": false, 906 + "notNull": true, 907 + "autoincrement": false 908 + }, 909 + "data": { 910 + "name": "data", 911 + "type": "text", 912 + "primaryKey": false, 913 + "notNull": false, 914 + "autoincrement": false, 915 + "default": "'{}'" 916 + }, 917 + "workspace_id": { 918 + "name": "workspace_id", 919 + "type": "integer", 920 + "primaryKey": false, 921 + "notNull": false, 922 + "autoincrement": false 923 + }, 924 + "created_at": { 925 + "name": "created_at", 926 + "type": "integer", 927 + "primaryKey": false, 928 + "notNull": false, 929 + "autoincrement": false, 930 + "default": "(strftime('%s', 'now'))" 931 + }, 932 + "updated_at": { 933 + "name": "updated_at", 934 + "type": "integer", 935 + "primaryKey": false, 936 + "notNull": false, 937 + "autoincrement": false, 938 + "default": "(strftime('%s', 'now'))" 939 + } 940 + }, 941 + "indexes": {}, 942 + "foreignKeys": { 943 + "notification_workspace_id_workspace_id_fk": { 944 + "name": "notification_workspace_id_workspace_id_fk", 945 + "tableFrom": "notification", 946 + "tableTo": "workspace", 947 + "columnsFrom": [ 948 + "workspace_id" 949 + ], 950 + "columnsTo": [ 951 + "id" 952 + ], 953 + "onDelete": "no action", 954 + "onUpdate": "no action" 955 + } 956 + }, 957 + "compositePrimaryKeys": {}, 958 + "uniqueConstraints": {} 959 + }, 960 + "notifications_to_monitors": { 961 + "name": "notifications_to_monitors", 962 + "columns": { 963 + "monitor_id": { 964 + "name": "monitor_id", 965 + "type": "integer", 966 + "primaryKey": false, 967 + "notNull": true, 968 + "autoincrement": false 969 + }, 970 + "notification_id": { 971 + "name": "notification_id", 972 + "type": "integer", 973 + "primaryKey": false, 974 + "notNull": true, 975 + "autoincrement": false 976 + } 977 + }, 978 + "indexes": {}, 979 + "foreignKeys": { 980 + "notifications_to_monitors_monitor_id_monitor_id_fk": { 981 + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", 982 + "tableFrom": "notifications_to_monitors", 983 + "tableTo": "monitor", 984 + "columnsFrom": [ 985 + "monitor_id" 986 + ], 987 + "columnsTo": [ 988 + "id" 989 + ], 990 + "onDelete": "cascade", 991 + "onUpdate": "no action" 992 + }, 993 + "notifications_to_monitors_notification_id_notification_id_fk": { 994 + "name": "notifications_to_monitors_notification_id_notification_id_fk", 995 + "tableFrom": "notifications_to_monitors", 996 + "tableTo": "notification", 997 + "columnsFrom": [ 998 + "notification_id" 999 + ], 1000 + "columnsTo": [ 1001 + "id" 1002 + ], 1003 + "onDelete": "cascade", 1004 + "onUpdate": "no action" 1005 + } 1006 + }, 1007 + "compositePrimaryKeys": { 1008 + "notifications_to_monitors_monitor_id_notification_id_pk": { 1009 + "columns": [ 1010 + "monitor_id", 1011 + "notification_id" 1012 + ] 1013 + } 1014 + }, 1015 + "uniqueConstraints": {} 1016 + }, 1017 + "monitor_status": { 1018 + "name": "monitor_status", 1019 + "columns": { 1020 + "monitor_id": { 1021 + "name": "monitor_id", 1022 + "type": "integer", 1023 + "primaryKey": false, 1024 + "notNull": true, 1025 + "autoincrement": false 1026 + }, 1027 + "region": { 1028 + "name": "region", 1029 + "type": "text", 1030 + "primaryKey": false, 1031 + "notNull": true, 1032 + "autoincrement": false, 1033 + "default": "''" 1034 + }, 1035 + "status": { 1036 + "name": "status", 1037 + "type": "text", 1038 + "primaryKey": false, 1039 + "notNull": true, 1040 + "autoincrement": false, 1041 + "default": "'active'" 1042 + }, 1043 + "created_at": { 1044 + "name": "created_at", 1045 + "type": "integer", 1046 + "primaryKey": false, 1047 + "notNull": false, 1048 + "autoincrement": false, 1049 + "default": "(strftime('%s', 'now'))" 1050 + }, 1051 + "updated_at": { 1052 + "name": "updated_at", 1053 + "type": "integer", 1054 + "primaryKey": false, 1055 + "notNull": false, 1056 + "autoincrement": false, 1057 + "default": "(strftime('%s', 'now'))" 1058 + } 1059 + }, 1060 + "indexes": { 1061 + "monitor_status_idx": { 1062 + "name": "monitor_status_idx", 1063 + "columns": [ 1064 + "monitor_id", 1065 + "region" 1066 + ], 1067 + "isUnique": false 1068 + } 1069 + }, 1070 + "foreignKeys": { 1071 + "monitor_status_monitor_id_monitor_id_fk": { 1072 + "name": "monitor_status_monitor_id_monitor_id_fk", 1073 + "tableFrom": "monitor_status", 1074 + "tableTo": "monitor", 1075 + "columnsFrom": [ 1076 + "monitor_id" 1077 + ], 1078 + "columnsTo": [ 1079 + "id" 1080 + ], 1081 + "onDelete": "cascade", 1082 + "onUpdate": "no action" 1083 + } 1084 + }, 1085 + "compositePrimaryKeys": { 1086 + "monitor_status_monitor_id_region_pk": { 1087 + "columns": [ 1088 + "monitor_id", 1089 + "region" 1090 + ] 1091 + } 1092 + }, 1093 + "uniqueConstraints": {} 1094 + }, 1095 + "invitation": { 1096 + "name": "invitation", 1097 + "columns": { 1098 + "id": { 1099 + "name": "id", 1100 + "type": "integer", 1101 + "primaryKey": true, 1102 + "notNull": true, 1103 + "autoincrement": false 1104 + }, 1105 + "email": { 1106 + "name": "email", 1107 + "type": "text", 1108 + "primaryKey": false, 1109 + "notNull": true, 1110 + "autoincrement": false 1111 + }, 1112 + "role": { 1113 + "name": "role", 1114 + "type": "text", 1115 + "primaryKey": false, 1116 + "notNull": true, 1117 + "autoincrement": false, 1118 + "default": "'member'" 1119 + }, 1120 + "workspace_id": { 1121 + "name": "workspace_id", 1122 + "type": "integer", 1123 + "primaryKey": false, 1124 + "notNull": true, 1125 + "autoincrement": false 1126 + }, 1127 + "token": { 1128 + "name": "token", 1129 + "type": "text", 1130 + "primaryKey": false, 1131 + "notNull": true, 1132 + "autoincrement": false 1133 + }, 1134 + "expires_at": { 1135 + "name": "expires_at", 1136 + "type": "integer", 1137 + "primaryKey": false, 1138 + "notNull": true, 1139 + "autoincrement": false 1140 + }, 1141 + "created_at": { 1142 + "name": "created_at", 1143 + "type": "integer", 1144 + "primaryKey": false, 1145 + "notNull": false, 1146 + "autoincrement": false, 1147 + "default": "(strftime('%s', 'now'))" 1148 + }, 1149 + "accepted_at": { 1150 + "name": "accepted_at", 1151 + "type": "integer", 1152 + "primaryKey": false, 1153 + "notNull": false, 1154 + "autoincrement": false 1155 + } 1156 + }, 1157 + "indexes": {}, 1158 + "foreignKeys": {}, 1159 + "compositePrimaryKeys": {}, 1160 + "uniqueConstraints": {} 1161 + } 1162 + }, 1163 + "enums": {}, 1164 + "_meta": { 1165 + "schemas": {}, 1166 + "tables": {}, 1167 + "columns": {} 1168 + } 1169 + }
+8 -1
packages/db/drizzle/meta/_journal.json
··· 85 85 "when": 1701100570578, 86 86 "tag": "0011_bright_jazinda", 87 87 "breakpoints": true 88 + }, 89 + { 90 + "idx": 12, 91 + "version": "5", 92 + "when": 1701713135829, 93 + "tag": "0012_tan_magma", 94 + "breakpoints": true 88 95 } 89 96 ] 90 - } 97 + }
+1
packages/db/src/schema/index.ts
··· 7 7 export * from "./shared"; 8 8 export * from "./notifications"; 9 9 export * from "./monitor_status"; 10 + export * from "./invitations";
+2
packages/db/src/schema/invitations/index.ts
··· 1 + export * from "./invitation"; 2 + export * from "./validation";
+24
packages/db/src/schema/invitations/invitation.ts
··· 1 + import { relations, sql } from "drizzle-orm"; 2 + import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 + 4 + import { workspace, workspaceRole } from "../workspaces"; 5 + 6 + export const invitation = sqliteTable("invitation", { 7 + id: integer("id").primaryKey(), 8 + email: text("email").notNull(), 9 + role: text("role", { enum: workspaceRole }).notNull().default("member"), 10 + workspaceId: integer("workspace_id").notNull(), 11 + token: text("token").notNull(), 12 + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), 13 + createdAt: integer("created_at", { mode: "timestamp" }).default( 14 + sql`(strftime('%s', 'now'))`, 15 + ), 16 + acceptedAt: integer("accepted_at", { mode: "timestamp" }), 17 + }); 18 + 19 + export const invitationRelations = relations(invitation, ({ one }) => ({ 20 + workspace: one(workspace, { 21 + fields: [invitation.workspaceId], 22 + references: [workspace.id], 23 + }), 24 + }));
+13
packages/db/src/schema/invitations/validation.ts
··· 1 + import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 2 + import { z } from "zod"; 3 + 4 + import { invitation } from "./invitation"; 5 + 6 + export const insertInvitationSchema = createInsertSchema(invitation, { 7 + email: z.string().email(), 8 + }); 9 + 10 + export const selectInvitationSchema = createSelectSchema(invitation); 11 + 12 + export type InsertInvitation = z.infer<typeof insertInvitationSchema>; 13 + export type Invitation = z.infer<typeof selectInvitationSchema>;
+2 -1
packages/db/src/schema/users/user.ts
··· 6 6 text, 7 7 } from "drizzle-orm/sqlite-core"; 8 8 9 - import { workspace } from "../workspaces"; 9 + import { workspace, workspaceRole } from "../workspaces"; 10 10 11 11 export const user = sqliteTable("user", { 12 12 id: integer("id").primaryKey(), ··· 38 38 workspaceId: integer("workspace_id") 39 39 .notNull() 40 40 .references(() => workspace.id), 41 + role: text("role", { enum: workspaceRole }).notNull().default("member"), 41 42 }, 42 43 (t) => ({ 43 44 pk: primaryKey(t.userId, t.workspaceId),
+1
packages/db/src/schema/workspaces/constants.ts
··· 1 1 export const workspacePlans = ["free", "pro"] as const; 2 + export const workspaceRole = ["owner", "admin", "member"] as const;
+3 -1
packages/db/src/schema/workspaces/validation.ts
··· 1 1 import { createSelectSchema } from "drizzle-zod"; 2 2 import { z } from "zod"; 3 3 4 - import { workspacePlans } from "./constants"; 4 + import { workspacePlans, workspaceRole } from "./constants"; 5 5 import { workspace } from "./workspace"; 6 6 7 7 export const workspacePlanSchema = z.enum(workspacePlans); 8 + export const workspaceRoleSchema = z.enum(workspaceRole); 8 9 9 10 export const selectWorkspaceSchema = createSelectSchema(workspace).extend({ 10 11 plan: z ··· 18 19 19 20 export type Workspace = z.infer<typeof selectWorkspaceSchema>; 20 21 export type WorkspacePlan = z.infer<typeof workspacePlanSchema>; 22 + export type WorkspaceRole = z.infer<typeof workspaceRoleSchema>;
+3
packages/plans/index.ts
··· 5 5 monitors: number; 6 6 "status-pages": number; 7 7 periodicity: Partial<MonitorPeriodicity>[]; 8 + members: number; 8 9 }; 9 10 }; 10 11 ··· 14 15 monitors: 5, 15 16 "status-pages": 1, 16 17 periodicity: ["10m", "30m", "1h"], 18 + members: 1, 17 19 }, 18 20 }, 19 21 pro: { ··· 21 23 monitors: 20, 22 24 "status-pages": 5, 23 25 periodicity: ["1m", "5m", "10m", "30m", "1h"], 26 + members: 5, 24 27 }, 25 28 }, 26 29 };