Openstatus www.openstatus.dev

wip: statuspage magic link (#1669)

* wip: statuspage magic link

* fix: build

* fix: comments

* fix: package.json

* wip: user without workspace

* wip: dashboard form

* wip: magic link

* chore: access type email-domain

* wip: review

* chore: pricing table and addon button

* ci: apply automated fixes

* fix: zod 4 schema

* Add stripe product

* ci: apply automated fixes

* refactor: auth and migration

* fix: web build

* chore: add success check if addon exists

* Add stripe product

improve subscription

* fix

* ci: apply automated fixes

* update

* ci: apply automated fixes

* fix: addon pricing

* refactor: duplication

* fix: number format

* chore: enforce plan limit addons to be false

* wip: addon

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Thibault Le Ouay Ducasse <thibaultleouay@gmail.com>

+5039 -873
+1 -1
apps/dashboard/next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 - import "./.next/types/routes.d.ts"; 3 + import "./.next/dev/types/routes.d.ts"; 4 4 5 5 // NOTE: This file should not be edited 6 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6 -14
apps/dashboard/src/app/(dashboard)/layout.tsx
··· 1 1 import { AppSidebar } from "@/components/nav/app-sidebar"; 2 2 import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; 3 - import { auth } from "@/lib/auth"; 4 - import { TRPCReactProvider } from "@/lib/trpc/client"; 5 3 import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; 6 - import { SessionProvider } from "next-auth/react"; 7 4 import { cookies } from "next/headers"; 8 5 9 6 export default async function Layout({ ··· 11 8 }: { 12 9 children: React.ReactNode; 13 10 }) { 14 - const session = await auth(); 15 11 const cookieStore = await cookies(); 16 12 const hasState = cookieStore.has("sidebar_state"); 17 13 const defaultOpen = hasState ··· 19 15 : true; 20 16 21 17 return ( 22 - <SessionProvider session={session}> 23 - <TRPCReactProvider> 24 - <HydrateSidebar> 25 - <SidebarProvider defaultOpen={defaultOpen}> 26 - <AppSidebar /> 27 - <SidebarInset>{children}</SidebarInset> 28 - </SidebarProvider> 29 - </HydrateSidebar> 30 - </TRPCReactProvider> 31 - </SessionProvider> 18 + <HydrateSidebar> 19 + <SidebarProvider defaultOpen={defaultOpen}> 20 + <AppSidebar /> 21 + <SidebarInset>{children}</SidebarInset> 22 + </SidebarProvider> 23 + </HydrateSidebar> 32 24 ); 33 25 } 34 26
+33 -8
apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx
··· 1 1 "use client"; 2 2 3 + import { BillingAddons } from "@/components/content/billing-addons"; 3 4 import { BillingProgress } from "@/components/content/billing-progress"; 5 + import { 6 + EmptyStateContainer, 7 + EmptyStateTitle, 8 + } from "@/components/content/empty-state"; 4 9 import { 5 10 Section, 6 11 SectionDescription, ··· 22 27 } from "@/components/forms/form-card"; 23 28 import { Button } from "@/components/ui/button"; 24 29 import { useTRPC } from "@/lib/trpc/client"; 30 + import { allPlans } from "@openstatus/db/src/schema/plan/config"; 25 31 import type { Limits } from "@openstatus/db/src/schema/plan/schema"; 26 32 import { useMutation, useQuery } from "@tanstack/react-query"; 27 33 import { useRouter } from "next/navigation"; ··· 133 139 <FormCardGroup> 134 140 <FormCard> 135 141 <FormCardHeader> 136 - <FormCardTitle>Limits</FormCardTitle> 142 + <FormCardTitle>Usage</FormCardTitle> 137 143 <FormCardDescription> 138 - Overview of your current limits. 144 + Overview of your current usage, limits and addons. 139 145 </FormCardDescription> 140 146 </FormCardHeader> 141 147 <FormCardContent> ··· 154 160 label="Notifications" 155 161 value={workspace.usage?.notifications ?? 0} 156 162 max={workspace.limits["notification-channels"]} 163 + /> 164 + <BillingProgress 165 + label="Total requests in the last 30 days" 166 + value={totalRequests} 167 + max={calculateTotalRequests(workspace.limits)} 157 168 /> 158 169 </div> 159 170 </FormCardContent> 160 171 <FormCardSeparator /> 161 172 <FormCardContent> 162 - <div className="flex flex-col gap-2"> 163 - <BillingProgress 164 - label="Requests in the last 30 days" 165 - value={totalRequests} 166 - max={calculateTotalRequests(workspace.limits)} 167 - /> 173 + <FormCardHeader className="col-span-full px-0 pt-0 pb-0"> 174 + <FormCardTitle>Add-ons</FormCardTitle> 175 + <FormCardDescription> 176 + Extend your limits with additional features. 177 + </FormCardDescription> 178 + </FormCardHeader> 179 + <div className="flex flex-col gap-2 pt-4"> 180 + {/* TODO: redirect to stripe product */} 181 + {allPlans[workspace.plan].addons["email-domain-protection"] ? ( 182 + <BillingAddons 183 + label="Magic Link (Auth)" 184 + description="Only allow user with a given email domain to access the status page." 185 + addon="email-domain-protection" 186 + workspace={workspace} 187 + /> 188 + ) : ( 189 + <EmptyStateContainer> 190 + <EmptyStateTitle>No add-ons available</EmptyStateTitle> 191 + </EmptyStateContainer> 192 + )} 168 193 </div> 169 194 </FormCardContent> 170 195 <FormCardFooter>
+16 -32
apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx
··· 1 1 "use client"; 2 2 3 3 import { Link } from "@/components/common/link"; 4 - import { TableCellBoolean } from "@/components/data-table/table-cell-boolean"; 5 4 import { TableCellLink } from "@/components/data-table/table-cell-link"; 6 5 import { SidebarRight } from "@/components/nav/sidebar-right"; 7 6 import { ··· 49 48 </Link> 50 49 ), 51 50 }, 51 + { 52 + label: "Access Type", 53 + value: statusPage.accessType, 54 + }, 52 55 { label: "Domain", value: statusPage.customDomain || "-" }, 53 56 { 54 57 label: "Favicon", ··· 89 92 label: "Configuration", 90 93 items: [ 91 94 { 92 - label: "Legacy", 93 - value: <TableCellBoolean value={statusPage.legacyPage} />, 95 + label: "Theme", 96 + value: statusPage.configuration?.theme ?? "-", 94 97 }, 95 98 { 96 - label: "Protected", 97 - value: <TableCellBoolean value={statusPage.passwordProtected} />, 99 + label: "Bar Value", 100 + value: statusPage.configuration?.type ?? "-", 98 101 }, 99 - ...(statusPage.legacyPage 100 - ? [ 101 - { 102 - label: "Show values", 103 - value: ( 104 - <TableCellBoolean value={statusPage.showMonitorValues} /> 105 - ), 106 - }, 107 - ] 108 - : [ 109 - { 110 - label: "Theme", 111 - value: statusPage.configuration?.theme ?? "-", 112 - }, 113 - { 114 - label: "Bar Value", 115 - value: statusPage.configuration?.type ?? "-", 116 - }, 117 - { 118 - label: "Card Value", 119 - value: statusPage.configuration?.value ?? "-", 120 - }, 121 - { 122 - label: "Show Uptime", 123 - value: statusPage.configuration?.uptime ? "Yes" : "No", 124 - }, 125 - ]), 102 + { 103 + label: "Card Value", 104 + value: statusPage.configuration?.value ?? "-", 105 + }, 106 + { 107 + label: "Show Uptime", 108 + value: statusPage.configuration?.uptime ? "Yes" : "No", 109 + }, 126 110 ], 127 111 }, 128 112 {
apps/dashboard/src/app/(public)/login/_components/actions.ts apps/dashboard/src/app/login/_components/actions.ts
+1 -11
apps/dashboard/src/app/(public)/login/layout.tsx apps/dashboard/src/components/layout/auth-layout.tsx
··· 1 1 import Image from "next/image"; 2 - import { redirect } from "next/navigation"; 3 2 4 - import { auth } from "@/lib/auth"; 5 - 6 - export default async function AuthLayout({ 7 - children, 8 - }: { 9 - children: React.ReactNode; 10 - }) { 11 - const session = await auth(); 12 - if (session) redirect("/"); 13 - 3 + export function AuthLayout({ children }: { children: React.ReactNode }) { 14 4 return ( 15 5 <div className="grid min-h-screen grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5"> 16 6 <aside className="col-span-1 flex w-full flex-col gap-4 border border-border bg-sidebar p-4 backdrop-blur-[2px] md:p-8 xl:col-span-2">
apps/dashboard/src/app/(public)/login/page.tsx apps/dashboard/src/app/login/page.tsx
apps/dashboard/src/app/(public)/login/search-params.ts apps/dashboard/src/app/login/search-params.ts
+2 -1
apps/dashboard/src/app/api/trpc/edge/[trpc]/route.ts
··· 1 1 import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 2 import type { NextRequest } from "next/server"; 3 3 4 + import { auth } from "@/lib/auth"; 4 5 import { createTRPCContext } from "@openstatus/api"; 5 6 import { edgeRouter } from "@openstatus/api/src/edge"; 6 7 ··· 11 12 endpoint: "/api/trpc/edge", 12 13 router: edgeRouter, 13 14 req: req, 14 - createContext: () => createTRPCContext({ req }), 15 + createContext: () => createTRPCContext({ req, auth }), 15 16 onError: ({ error }) => { 16 17 console.log("Error in tRPC handler (edge)"); 17 18 console.error(error);
+2 -1
apps/dashboard/src/app/api/trpc/lambda/[trpc]/route.ts
··· 1 1 import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 2 import type { NextRequest } from "next/server"; 3 3 4 + import { auth } from "@/lib/auth"; 4 5 import { createTRPCContext } from "@openstatus/api"; 5 6 import { lambdaRouter } from "@openstatus/api/src/lambda"; 6 7 ··· 12 13 endpoint: "/api/trpc/lambda", 13 14 router: lambdaRouter, 14 15 req: req, 15 - createContext: () => createTRPCContext({ req }), 16 + createContext: () => createTRPCContext({ req, auth }), 16 17 onError: ({ error }) => { 17 18 console.log("Error in tRPC handler (lambda)"); 18 19 console.error(error);
+22 -13
apps/dashboard/src/app/layout.tsx
··· 4 4 import { TailwindIndicator } from "@/components/tailwind-indicator"; 5 5 import { ThemeProvider } from "@/components/theme-provider"; 6 6 import { Toaster } from "@/components/ui/sonner"; 7 + import { auth } from "@/lib/auth"; 8 + import { TRPCReactProvider } from "@/lib/trpc/client"; 7 9 import { cn } from "@/lib/utils"; 10 + import { SessionProvider } from "next-auth/react"; 8 11 import LocalFont from "next/font/local"; 9 12 import { NuqsAdapter } from "nuqs/adapters/next/app"; 10 13 import { ogMetadata, twitterMetadata } from "./metadata"; ··· 68 71 69 72 // export const dynamic = "error"; 70 73 71 - export default function RootLayout({ 74 + export default async function RootLayout({ 72 75 children, 73 76 }: Readonly<{ 74 77 children: React.ReactNode; 75 78 }>) { 79 + const session = await auth(); 80 + 76 81 return ( 77 82 <html lang="en" suppressHydrationWarning> 78 83 <body ··· 85 90 "font-sans antialiased ", 86 91 )} 87 92 > 88 - <NuqsAdapter> 89 - <ThemeProvider 90 - attribute="class" 91 - defaultTheme="system" 92 - enableSystem 93 - disableTransitionOnChange 94 - > 95 - {children} 96 - <TailwindIndicator /> 97 - <Toaster richColors expand /> 98 - </ThemeProvider> 99 - </NuqsAdapter> 93 + <SessionProvider session={session}> 94 + <TRPCReactProvider> 95 + <NuqsAdapter> 96 + <ThemeProvider 97 + attribute="class" 98 + defaultTheme="system" 99 + enableSystem 100 + disableTransitionOnChange 101 + > 102 + {children} 103 + <TailwindIndicator /> 104 + <Toaster richColors expand /> 105 + </ThemeProvider> 106 + </NuqsAdapter> 107 + </TRPCReactProvider> 108 + </SessionProvider> 100 109 </body> 101 110 </html> 102 111 );
+15
apps/dashboard/src/app/login/layout.tsx
··· 1 + import { redirect } from "next/navigation"; 2 + 3 + import { AuthLayout } from "@/components/layout/auth-layout"; 4 + import { auth } from "@/lib/auth"; 5 + 6 + export default async function Layout({ 7 + children, 8 + }: { 9 + children: React.ReactNode; 10 + }) { 11 + const session = await auth(); 12 + if (session) redirect("/"); 13 + 14 + return <AuthLayout>{children}</AuthLayout>; 15 + }
-48
apps/dashboard/src/app/status-page/layout.tsx
··· 1 - /** 2 - * TODO: 3 - * - add different header 4 - * - add different chart/tracker 5 - * - add subscription popover (choose which one you'd like to allow) 6 - * - use the '@/components/status-page` for the components 7 - */ 8 - 9 - import { Link } from "@/components/common/link"; 10 - import { Button } from "@/components/ui/button"; 11 - import NextLink from "next/link"; 12 - 13 - const nav = [ 14 - { label: "Status", href: "#" }, 15 - { label: "Events", href: "#" }, 16 - { label: "Monitors", href: "#" }, 17 - ]; 18 - 19 - export default function Layout({ children }: { children: React.ReactNode }) { 20 - return ( 21 - <div className="flex min-h-screen flex-col gap-4"> 22 - <header className="w-full border border-b px-3 py-2"> 23 - <nav className="mx-auto flex max-w-xl items-center justify-between"> 24 - <ul className="flex flex-row gap-2"> 25 - {nav.map((item) => ( 26 - <li key={item.label}> 27 - <Button variant="ghost" asChild> 28 - <NextLink href={item.href}>{item.label}</NextLink> 29 - </Button> 30 - </li> 31 - ))} 32 - </ul> 33 - <div>{/* TODO: Popover */}</div> 34 - </nav> 35 - </header> 36 - <main className="mx-auto w-full max-w-xl flex-1 px-3 py-2"> 37 - {children} 38 - </main> 39 - <footer className="w-full border border-t px-3 py-2"> 40 - <div className="mx-auto max-w-xl"> 41 - <p className="text-center text-muted-foreground"> 42 - Powered by <Link href="#">OpenStatus</Link> 43 - </p> 44 - </div> 45 - </footer> 46 - </div> 47 - ); 48 - }
-46
apps/dashboard/src/app/status-page/page.tsx
··· 1 - "use client"; 2 - 3 - import { 4 - FloatingButton, 5 - StatusPageProvider, 6 - useStatusPage, 7 - } from "@/components/status-page/floating-button"; 8 - import { 9 - Status, 10 - StatusBanner, 11 - StatusContent, 12 - StatusDescription, 13 - StatusHeader, 14 - StatusTitle, 15 - } from "@/components/status-page/status"; 16 - import { StatusMonitor } from "@/components/status-page/status-monitor"; 17 - 18 - function StatusPageContent() { 19 - const { variant, cardType } = useStatusPage(); 20 - 21 - return ( 22 - <div className="grid gap-6"> 23 - <Status variant={variant}> 24 - <StatusHeader> 25 - <StatusTitle>Craft</StatusTitle> 26 - <StatusDescription> 27 - Stay informed about the stability 28 - </StatusDescription> 29 - </StatusHeader> 30 - <StatusBanner /> 31 - <StatusContent> 32 - <StatusMonitor variant={variant} type={cardType} /> 33 - </StatusContent> 34 - </Status> 35 - <FloatingButton /> 36 - </div> 37 - ); 38 - } 39 - 40 - export default function Page() { 41 - return ( 42 - <StatusPageProvider defaultVariant="success"> 43 - <StatusPageContent /> 44 - </StatusPageProvider> 45 - ); 46 - }
+168
apps/dashboard/src/components/content/billing-addons.tsx
··· 1 + import { 2 + AlertDialog, 3 + AlertDialogAction, 4 + AlertDialogCancel, 5 + AlertDialogContent, 6 + AlertDialogDescription, 7 + AlertDialogFooter, 8 + AlertDialogHeader, 9 + AlertDialogTitle, 10 + AlertDialogTrigger, 11 + } from "@/components/ui/alert-dialog"; 12 + import { Button } from "@/components/ui/button"; 13 + import { Label } from "@/components/ui/label"; 14 + import { useCookieState } from "@/hooks/use-cookie-state"; 15 + 16 + import { useTRPC } from "@/lib/trpc/client"; 17 + import type { RouterOutputs } from "@openstatus/api"; 18 + import type { Addons } from "@openstatus/db/src/schema/plan/schema"; 19 + import { getAddonPriceConfig } from "@openstatus/db/src/schema/plan/utils"; 20 + import { useMutation, useQueryClient } from "@tanstack/react-query"; 21 + import { isTRPCClientError } from "@trpc/client"; 22 + import { Check } from "lucide-react"; 23 + import { useState, useTransition } from "react"; 24 + import { toast } from "sonner"; 25 + 26 + const BASE_URL = 27 + process.env.NODE_ENV === "production" 28 + ? "https://app.openstatus.dev" 29 + : "http://localhost:3000"; 30 + 31 + type Workspace = RouterOutputs["workspace"]["get"]; 32 + 33 + interface BillingAddonsProps { 34 + label: string; 35 + description: React.ReactNode; 36 + addon: keyof Addons; 37 + workspace: Workspace; 38 + } 39 + 40 + export function BillingAddons({ 41 + label, 42 + description, 43 + addon, 44 + workspace, 45 + }: BillingAddonsProps) { 46 + const [open, setOpen] = useState(false); 47 + const [isPending, startTransition] = useTransition(); 48 + const [currency] = useCookieState("x-currency", "USD"); 49 + const trpc = useTRPC(); 50 + const queryClient = useQueryClient(); 51 + const checkoutSessionMutation = useMutation( 52 + trpc.stripeRouter.addAddon.mutationOptions({ 53 + onSuccess: () => { 54 + queryClient.invalidateQueries({ 55 + queryKey: trpc.workspace.get.queryKey(), 56 + }); 57 + }, 58 + }), 59 + ); 60 + 61 + const plan = workspace.plan; 62 + const value = workspace.limits[addon]; 63 + const price = getAddonPriceConfig(plan, addon, currency); 64 + 65 + function submitAction() { 66 + startTransition(async () => { 67 + try { 68 + const promise = checkoutSessionMutation.mutateAsync({ 69 + workspaceSlug: workspace.slug, 70 + successUrl: `${BASE_URL}/settings/billing?success=true`, 71 + cancelUrl: `${BASE_URL}/settings/billing`, 72 + feature: addon, 73 + remove: value, 74 + }); 75 + toast.promise(promise, { 76 + loading: "Updating...", 77 + success: () => { 78 + setOpen(false); 79 + return value ? "Removed" : "Added"; 80 + }, 81 + error: (error) => { 82 + if (isTRPCClientError(error)) { 83 + return error.message; 84 + } 85 + return "Failed to update"; 86 + }, 87 + }); 88 + await promise; 89 + } catch (error) { 90 + console.error(error); 91 + } 92 + }); 93 + } 94 + 95 + return ( 96 + <div className="flex flex-col gap-2"> 97 + <div className="flex flex-col justify-between gap-1.5 sm:flex-row"> 98 + <div className="space-y-0.5 text-sm"> 99 + <Label>{label}</Label> 100 + <div className="truncate text-muted-foreground">{description}</div> 101 + </div> 102 + <div className="flex items-center gap-2"> 103 + {value ? <Check className="size-4 text-success" /> : null} 104 + <span className="font-mono text-foreground text-sm"> 105 + {price 106 + ? new Intl.NumberFormat(price.locale, { 107 + style: "currency", 108 + currency: price.currency, 109 + }).format(price.value) 110 + : "N/A"} 111 + /mo. 112 + </span> 113 + <AlertDialog open={open} onOpenChange={setOpen}> 114 + <AlertDialogTrigger asChild> 115 + <Button size="sm" variant="secondary"> 116 + {value ? "Remove" : "Add"} 117 + </Button> 118 + </AlertDialogTrigger> 119 + <AlertDialogContent> 120 + <AlertDialogHeader> 121 + <AlertDialogTitle>{label}</AlertDialogTitle> 122 + <AlertDialogDescription> 123 + {value ? ( 124 + <> 125 + {label} will be removed from your subscription. You will 126 + save{" "} 127 + {price 128 + ? new Intl.NumberFormat(price.locale, { 129 + style: "currency", 130 + currency: price.currency, 131 + }).format(price.value) 132 + : "N/A"} 133 + /mo. on your next billing cycle. 134 + </> 135 + ) : ( 136 + <> 137 + {label} will be added to your subscription. You will be 138 + charged an additional{" "} 139 + {price 140 + ? new Intl.NumberFormat(price.locale, { 141 + style: "currency", 142 + currency: price.currency, 143 + }).format(price.value) 144 + : "N/A"} 145 + /mo. on your next billing cycle. 146 + </> 147 + )} 148 + </AlertDialogDescription> 149 + </AlertDialogHeader> 150 + <AlertDialogFooter> 151 + <AlertDialogCancel>Cancel</AlertDialogCancel> 152 + <AlertDialogAction 153 + onClick={(e) => { 154 + e.preventDefault(); 155 + submitAction(); 156 + }} 157 + disabled={isPending} 158 + > 159 + {isPending ? "Updating..." : value ? "Remove" : "Add"} 160 + </AlertDialogAction> 161 + </AlertDialogFooter> 162 + </AlertDialogContent> 163 + </AlertDialog> 164 + </div> 165 + </div> 166 + </div> 167 + ); 168 + }
+4 -1
apps/dashboard/src/components/content/billing-progress.tsx
··· 13 13 <div className="flex justify-between text-muted-foreground text-sm"> 14 14 <div className="font-medium">{label}</div> 15 15 <div className="font-mono"> 16 - <span className="text-foreground">{value}</span>/{max} 16 + <span className="text-foreground"> 17 + {new Intl.NumberFormat("de-DE").format(value)} 18 + </span> 19 + /{new Intl.NumberFormat("de-DE").format(max)} 17 20 </div> 18 21 </div> 19 22 <Progress value={(value / max) * 100} />
+86 -51
apps/dashboard/src/components/data-table/billing/data-table.tsx
··· 14 14 TableRow, 15 15 } from "@/components/ui/table"; 16 16 17 + import { Badge } from "@/components/ui/badge"; 17 18 import { config as featureGroups, plans } from "@/data/plans"; 18 19 import { useCookieState } from "@/hooks/use-cookie-state"; 19 20 import { getStripe } from "@/lib/stripe"; 20 21 import { useTRPC } from "@/lib/trpc/client"; 21 22 import { cn } from "@/lib/utils"; 22 23 import type { WorkspacePlan } from "@openstatus/db/src/schema"; 23 - import { getPriceConfig } from "@openstatus/db/src/schema/plan/utils"; 24 + import { 25 + getAddonPriceConfig, 26 + getPriceConfig, 27 + } from "@openstatus/db/src/schema/plan/utils"; 24 28 import { useMutation, useQuery } from "@tanstack/react-query"; 25 29 26 30 const BASE_URL = ··· 80 84 </p> 81 85 </div> 82 86 <p className="text-right"> 83 - <span className="font-cal text-lg"> 87 + <span className="font-mono text-lg"> 84 88 {new Intl.NumberFormat(price.locale, { 85 89 style: "currency", 86 90 currency: price.currency, 87 91 }).format(price.value)} 88 92 </span> 89 - <span className="font-light text-muted-foreground text-sm"> 93 + <span className="text-muted-foreground text-sm"> 90 94 /month 91 95 </span> 92 96 </p> ··· 131 135 {label} 132 136 </TableCell> 133 137 </TableRow> 134 - {features.map(({ value, label: featureLabel, monthly }) => ( 135 - <TableRow key={groupKey + value}> 136 - <TableCell> 137 - <div className="flex items-center gap-2 text-wrap"> 138 - {featureLabel} 139 - </div> 140 - </TableCell> 141 - {filteredPlans.map((plan) => { 142 - const limitValue = 143 - plan.limits[value as keyof typeof plan.limits]; 138 + {features.map( 139 + ({ value, label: featureLabel, monthly, badge }) => ( 140 + <TableRow key={groupKey + value}> 141 + <TableCell> 142 + <div className="flex items-center gap-2 text-wrap"> 143 + {featureLabel}{" "} 144 + {badge ? ( 145 + <Badge variant="outline">{badge}</Badge> 146 + ) : null} 147 + </div> 148 + </TableCell> 149 + {filteredPlans.map((plan) => { 150 + const limitValue = 151 + plan.limits[value as keyof typeof plan.limits]; 152 + const isAddon = value in plan.addons; 144 153 145 - function renderContent() { 146 - if (typeof limitValue === "boolean") { 147 - return limitValue ? ( 148 - <Check className="h-4 w-4 text-foreground" /> 149 - ) : ( 150 - <span className="text-muted-foreground/50"> 151 - &#8208; 152 - </span> 153 - ); 154 - } 155 - if (typeof limitValue === "number") { 156 - return new Intl.NumberFormat("us") 157 - .format(limitValue) 158 - .toString(); 159 - } 154 + function renderContent() { 155 + if (isAddon) { 156 + const price = getAddonPriceConfig( 157 + plan.id, 158 + value as keyof typeof plan.addons, 159 + currency, 160 + ); 161 + if (!price) return null; 162 + return ( 163 + <div> 164 + <span className="text-muted-foreground"> 165 + add-on{" "} 166 + </span> 167 + <span> 168 + {new Intl.NumberFormat(price.locale, { 169 + style: "currency", 170 + currency: price.currency, 171 + }).format(price.value)} 172 + /mo. 173 + </span> 174 + </div> 175 + ); 176 + } 177 + if (typeof limitValue === "boolean") { 178 + return limitValue ? ( 179 + <Check className="h-4 w-4 text-foreground" /> 180 + ) : ( 181 + <span className="text-muted-foreground/50"> 182 + &#8208; 183 + </span> 184 + ); 185 + } 186 + if (typeof limitValue === "number") { 187 + return new Intl.NumberFormat("us") 188 + .format(limitValue) 189 + .toString(); 190 + } 160 191 161 - // TODO: create a format function for this in @data/plans 162 - if (value === "regions" && Array.isArray(limitValue)) { 163 - return limitValue?.length ?? 0; 164 - } 192 + // TODO: create a format function for this in @data/plans 193 + if (value === "regions" && Array.isArray(limitValue)) { 194 + return limitValue?.length ?? 0; 195 + } 165 196 166 - if (Array.isArray(limitValue) && limitValue.length > 0) { 167 - return limitValue[0]; 197 + if ( 198 + Array.isArray(limitValue) && 199 + limitValue.length > 0 200 + ) { 201 + return limitValue[0]; 202 + } 203 + return limitValue; 168 204 } 169 - return limitValue; 170 - } 171 205 172 - return ( 173 - <TableCell 174 - key={plan.id + value} 175 - className={cn( 176 - "font-mono", 177 - plan.id === "starter" && "bg-muted/30", 178 - )} 179 - > 180 - {renderContent()} 181 - {monthly ? "/mo" : ""} 182 - </TableCell> 183 - ); 184 - })} 185 - </TableRow> 186 - ))} 206 + return ( 207 + <TableCell 208 + key={plan.id + value} 209 + className={cn( 210 + "font-mono", 211 + plan.id === "starter" && "bg-muted/30", 212 + )} 213 + > 214 + {renderContent()} 215 + {monthly ? "/mo." : ""} 216 + </TableCell> 217 + ); 218 + })} 219 + </TableRow> 220 + ), 221 + )} 187 222 </Fragment> 188 223 ), 189 224 )}
+19
apps/dashboard/src/components/forms/form-card.tsx
··· 82 82 <CardContent 83 83 className={cn( 84 84 "px-4 group-has-data-[slot=card-upgrade]:pointer-events-none group-has-data-[slot=card-upgrade]:opacity-50", 85 + "has-data-[slot=card-content-upgrade]:pointer-events-none has-data-[slot=card-content-upgrade]:opacity-50", 85 86 className, 86 87 )} 87 88 {...props} ··· 169 170 return ( 170 171 <div 171 172 data-slot="card-upgrade" 173 + className={cn("hidden", className)} 174 + {...props} 175 + > 176 + {children} 177 + </div> 178 + ); 179 + } 180 + 181 + // NOTE; this is for a very specific case where we don't want to disable the whole content 182 + // and instead disable specpfic card content (e.g. for add-ons) 183 + export function FormCardContentUpgrade({ 184 + children, 185 + className, 186 + ...props 187 + }: React.ComponentProps<"div">) { 188 + return ( 189 + <div 190 + data-slot="card-content-upgrade" 172 191 className={cn("hidden", className)} 173 192 {...props} 174 193 >
+246
apps/dashboard/src/components/forms/status-page/form-page-access.tsx
··· 1 + "use client"; 2 + 3 + import { Link } from "@/components/common/link"; 4 + import { 5 + FormCard, 6 + FormCardContent, 7 + FormCardContentUpgrade, 8 + FormCardDescription, 9 + FormCardFooter, 10 + FormCardFooterInfo, 11 + FormCardHeader, 12 + FormCardSeparator, 13 + FormCardTitle, 14 + } from "@/components/forms/form-card"; 15 + import { Button } from "@/components/ui/button"; 16 + import { 17 + Form, 18 + FormControl, 19 + FormDescription, 20 + FormField, 21 + FormItem, 22 + FormLabel, 23 + FormMessage, 24 + } from "@/components/ui/form"; 25 + import { Input } from "@/components/ui/input"; 26 + import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 27 + import { cn } from "@/lib/utils"; 28 + import { zodResolver } from "@hookform/resolvers/zod"; 29 + import { isTRPCClientError } from "@trpc/client"; 30 + import { Key, Lock, LockOpen, ShieldUser } from "lucide-react"; 31 + import { useTransition } from "react"; 32 + import { useForm } from "react-hook-form"; 33 + import { toast } from "sonner"; 34 + import { z } from "zod"; 35 + 36 + const accessTypeSchema = z.enum(["public", "password", "email-domain"]); 37 + 38 + const schema = z.object({ 39 + accessType: accessTypeSchema, 40 + password: z.string().optional(), 41 + authEmailDomains: z 42 + .preprocess( 43 + (val: string[] | undefined) => 44 + val 45 + ? String(val) 46 + .split(",") 47 + .map((domain) => domain.trim()) 48 + .filter((domain) => domain.length > 0) 49 + : [], 50 + z.array(z.string()).optional(), 51 + ) 52 + .optional(), 53 + }); 54 + 55 + type FormValues = z.infer<typeof schema>; 56 + 57 + export function FormPageAccess({ 58 + lockedMap, 59 + defaultValues, 60 + onSubmit, 61 + ...props 62 + }: Omit<React.ComponentProps<"form">, "onSubmit"> & { 63 + lockedMap?: Map<z.infer<typeof accessTypeSchema>, boolean>; 64 + defaultValues?: FormValues; 65 + onSubmit: (values: FormValues) => Promise<void>; 66 + }) { 67 + const [isPending, startTransition] = useTransition(); 68 + const form = useForm<FormValues>({ 69 + resolver: zodResolver(schema), 70 + defaultValues: defaultValues ?? { 71 + accessType: "public", 72 + password: "", 73 + authEmailDomains: [], 74 + }, 75 + }); 76 + const watchAccessType = form.watch("accessType"); 77 + const locked = lockedMap?.get(watchAccessType); 78 + 79 + function submitAction(values: FormValues) { 80 + if (isPending) return; 81 + 82 + startTransition(async () => { 83 + try { 84 + console.log(values); 85 + const promise = onSubmit(values); 86 + toast.promise(promise, { 87 + loading: "Saving...", 88 + success: "Saved", 89 + error: (error) => { 90 + if (isTRPCClientError(error)) { 91 + return error.message; 92 + } 93 + return "Failed to save"; 94 + }, 95 + }); 96 + await promise; 97 + } catch (error) { 98 + console.error(error); 99 + } 100 + }); 101 + } 102 + 103 + return ( 104 + <Form {...form}> 105 + <form onSubmit={form.handleSubmit(submitAction)} {...props}> 106 + <FormCard> 107 + <FormCardHeader> 108 + <FormCardTitle>Page Access</FormCardTitle> 109 + <FormCardDescription> 110 + Enable protection for your status page. Choose between simple 111 + password or email domain authentication via magic link. 112 + </FormCardDescription> 113 + </FormCardHeader> 114 + <FormCardContent> 115 + <FormField 116 + control={form.control} 117 + name="accessType" 118 + render={({ field }) => ( 119 + <FormItem> 120 + <FormLabel>Protection Type</FormLabel> 121 + <FormControl> 122 + <RadioGroup 123 + onValueChange={field.onChange} 124 + defaultValue={field.value} 125 + className="grid grid-cols-2 gap-4 sm:grid-cols-4" 126 + > 127 + {[ 128 + { value: "public", icon: LockOpen, label: "Public" }, 129 + { value: "password", icon: Key, label: "Password" }, 130 + { 131 + value: "email-domain", 132 + icon: ShieldUser, 133 + label: "Magic Link (Auth)", 134 + }, 135 + ].map((type) => { 136 + return ( 137 + <FormItem 138 + key={type.value} 139 + className={cn( 140 + "relative flex cursor-pointer flex-row items-center gap-3 rounded-md border border-input px-2 py-3 text-center shadow-xs outline-none transition-[color,box-shadow] has-aria-[invalid=true]:border-destructive has-data-[state=checked]:border-primary/50 has-focus-visible:border-ring has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50", 141 + )} 142 + > 143 + <FormControl> 144 + <RadioGroupItem 145 + value={type.value} 146 + className="sr-only" 147 + /> 148 + </FormControl> 149 + <type.icon 150 + className="shrink-0 text-muted-foreground" 151 + size={16} 152 + aria-hidden="true" 153 + /> 154 + <FormLabel className="cursor-pointer font-medium text-foreground text-xs leading-none after:absolute after:inset-0"> 155 + {type.label} 156 + </FormLabel> 157 + </FormItem> 158 + ); 159 + })} 160 + </RadioGroup> 161 + </FormControl> 162 + <FormMessage /> 163 + </FormItem> 164 + )} 165 + /> 166 + </FormCardContent> 167 + {watchAccessType && watchAccessType !== "public" ? ( 168 + <FormCardSeparator /> 169 + ) : null} 170 + {watchAccessType === "password" ? ( 171 + <FormCardContent className="grid gap-4"> 172 + {locked ? <FormCardContentUpgrade /> : null} 173 + <FormField 174 + control={form.control} 175 + name="password" 176 + disabled={locked} 177 + render={({ field }) => ( 178 + <FormItem> 179 + <FormLabel>Password</FormLabel> 180 + <FormControl> 181 + <Input {...field} /> 182 + </FormControl> 183 + <FormMessage /> 184 + <FormDescription> 185 + Set a password to your status page to have a very basic 186 + protection. 187 + </FormDescription> 188 + </FormItem> 189 + )} 190 + /> 191 + </FormCardContent> 192 + ) : null} 193 + {watchAccessType === "email-domain" ? ( 194 + <FormCardContent className="grid gap-4"> 195 + {locked ? <FormCardContentUpgrade /> : null} 196 + <FormField 197 + control={form.control} 198 + name="authEmailDomains" 199 + disabled={locked} 200 + render={({ field }) => ( 201 + <FormItem> 202 + <FormLabel>Email Domains</FormLabel> 203 + <FormControl> 204 + <Input {...field} /> 205 + </FormControl> 206 + <FormMessage /> 207 + <FormDescription> 208 + Comma-separated list of email domains. Only emails from 209 + these domains will be authenticated to access the status 210 + page. 211 + </FormDescription> 212 + </FormItem> 213 + )} 214 + /> 215 + </FormCardContent> 216 + ) : null} 217 + <FormCardFooter> 218 + <FormCardFooterInfo> 219 + Learn more about{" "} 220 + <Link 221 + href="https://docs.openstatus.dev/reference/status-page/#password" 222 + rel="noreferrer" 223 + target="_blank" 224 + > 225 + Protection 226 + </Link> 227 + . 228 + </FormCardFooterInfo> 229 + {locked ? ( 230 + <Button type="button" asChild> 231 + <Link href="/settings/billing"> 232 + <Lock /> 233 + Upgrade 234 + </Link> 235 + </Button> 236 + ) : ( 237 + <Button type="submit" disabled={isPending}> 238 + {isPending ? "Submitting..." : "Submit"} 239 + </Button> 240 + )} 241 + </FormCardFooter> 242 + </FormCard> 243 + </form> 244 + </Form> 245 + ); 246 + }
-160
apps/dashboard/src/components/forms/status-page/form-password-protection.tsx
··· 1 - "use client"; 2 - 3 - import { Link } from "@/components/common/link"; 4 - import { 5 - FormCard, 6 - FormCardContent, 7 - FormCardDescription, 8 - FormCardFooter, 9 - FormCardFooterInfo, 10 - FormCardHeader, 11 - FormCardTitle, 12 - FormCardUpgrade, 13 - } from "@/components/forms/form-card"; 14 - import { Button } from "@/components/ui/button"; 15 - import { Checkbox } from "@/components/ui/checkbox"; 16 - import { 17 - Form, 18 - FormControl, 19 - FormDescription, 20 - FormField, 21 - FormItem, 22 - FormLabel, 23 - FormMessage, 24 - } from "@/components/ui/form"; 25 - import { Input } from "@/components/ui/input"; 26 - import { zodResolver } from "@hookform/resolvers/zod"; 27 - import { isTRPCClientError } from "@trpc/client"; 28 - import { Lock } from "lucide-react"; 29 - import { useTransition } from "react"; 30 - import { useForm } from "react-hook-form"; 31 - import { toast } from "sonner"; 32 - import { z } from "zod"; 33 - 34 - const schema = z.object({ 35 - passwordProtected: z.boolean().optional(), 36 - password: z.string().optional(), 37 - }); 38 - 39 - type FormValues = z.infer<typeof schema>; 40 - 41 - export function FormPasswordProtection({ 42 - locked, 43 - defaultValues, 44 - onSubmit, 45 - ...props 46 - }: Omit<React.ComponentProps<"form">, "onSubmit"> & { 47 - locked?: boolean; 48 - defaultValues?: FormValues; 49 - onSubmit: (values: FormValues) => Promise<void>; 50 - }) { 51 - const [isPending, startTransition] = useTransition(); 52 - const form = useForm<FormValues>({ 53 - resolver: zodResolver(schema), 54 - defaultValues: defaultValues ?? { 55 - passwordProtected: false, 56 - password: "", 57 - }, 58 - }); 59 - 60 - function submitAction(values: FormValues) { 61 - if (isPending) return; 62 - 63 - startTransition(async () => { 64 - try { 65 - const promise = onSubmit(values); 66 - toast.promise(promise, { 67 - loading: "Saving...", 68 - success: "Saved", 69 - error: (error) => { 70 - if (isTRPCClientError(error)) { 71 - return error.message; 72 - } 73 - return "Failed to save"; 74 - }, 75 - }); 76 - await promise; 77 - } catch (error) { 78 - console.error(error); 79 - } 80 - }); 81 - } 82 - 83 - return ( 84 - <Form {...form}> 85 - <form onSubmit={form.handleSubmit(submitAction)} {...props}> 86 - <FormCard> 87 - {locked ? <FormCardUpgrade /> : null} 88 - <FormCardHeader> 89 - <FormCardTitle>Password Protection</FormCardTitle> 90 - <FormCardDescription> 91 - Protect your status page with a password. 92 - </FormCardDescription> 93 - </FormCardHeader> 94 - <FormCardContent className="grid gap-4"> 95 - <FormField 96 - control={form.control} 97 - disabled={locked} 98 - name="passwordProtected" 99 - render={({ field }) => ( 100 - <FormItem className="flex flex-row items-start"> 101 - <FormControl> 102 - <Checkbox 103 - checked={field.value} 104 - onCheckedChange={field.onChange} 105 - /> 106 - </FormControl> 107 - <div className="space-y-1"> 108 - <FormLabel>Enable Password Protection</FormLabel> 109 - <FormDescription> 110 - Hide the page from the public 111 - </FormDescription> 112 - </div> 113 - </FormItem> 114 - )} 115 - /> 116 - <FormField 117 - control={form.control} 118 - name="password" 119 - disabled={locked} 120 - render={({ field }) => ( 121 - <FormItem> 122 - <FormLabel>Password</FormLabel> 123 - <FormControl> 124 - <Input {...field} /> 125 - </FormControl> 126 - <FormMessage /> 127 - </FormItem> 128 - )} 129 - /> 130 - </FormCardContent> 131 - <FormCardFooter> 132 - <FormCardFooterInfo> 133 - Learn more about{" "} 134 - <Link 135 - href="https://docs.openstatus.dev/reference/status-page/#password" 136 - rel="noreferrer" 137 - target="_blank" 138 - > 139 - Password Protection 140 - </Link> 141 - . 142 - </FormCardFooterInfo> 143 - {locked ? ( 144 - <Button type="button" asChild> 145 - <Link href="/settings/billing"> 146 - <Lock /> 147 - Upgrade 148 - </Link> 149 - </Button> 150 - ) : ( 151 - <Button type="submit" disabled={isPending}> 152 - {isPending ? "Submitting..." : "Submit"} 153 - </Button> 154 - )} 155 - </FormCardFooter> 156 - </FormCard> 157 - </form> 158 - </Form> 159 - ); 160 - }
+16 -5
apps/dashboard/src/components/forms/status-page/update.tsx
··· 12 12 import { FormGeneral } from "./form-general"; 13 13 import { FormLinks } from "./form-links"; 14 14 import { FormMonitors } from "./form-monitors"; 15 - import { FormPasswordProtection } from "./form-password-protection"; 15 + import { FormPageAccess } from "./form-page-access"; 16 16 17 17 export function FormStatusPageUpdate() { 18 18 const { id } = useParams<{ id: string }>(); ··· 224 224 }} 225 225 configLink={configLink} 226 226 /> 227 - <FormPasswordProtection 228 - locked={workspace.limits["password-protection"] === false} 227 + <FormPageAccess 228 + lockedMap={ 229 + new Map([ 230 + ["public", false], 231 + ["password", workspace.limits["password-protection"] === false], 232 + [ 233 + "email-domain", 234 + workspace.limits["email-domain-protection"] === false, 235 + ], 236 + ]) 237 + } 229 238 defaultValues={{ 230 - passwordProtected: statusPage.passwordProtected ?? false, 239 + accessType: statusPage.accessType, 231 240 password: statusPage.password ?? undefined, 241 + authEmailDomains: statusPage.authEmailDomains ?? [], 232 242 }} 233 243 onSubmit={async (values) => { 234 244 await updatePasswordProtectionMutation.mutateAsync({ 235 245 id: Number.parseInt(id), 236 - passwordProtected: values.passwordProtected ?? false, 246 + accessType: values.accessType, 237 247 password: values.password, 248 + authEmailDomains: values.authEmailDomains, 238 249 }); 239 250 }} 240 251 />
+10 -1
apps/dashboard/src/data/plans.ts
··· 67 67 value: "custom-domain", 68 68 label: "Custom domain", 69 69 }, 70 + ], 71 + }, 72 + "status-page-audience": { 73 + label: "Status Page Audience", 74 + features: [ 70 75 { 71 76 value: "password-protection", 72 - label: "Password-protected", 77 + label: "Password Protection (Basic)", 78 + }, 79 + { 80 + value: "email-domain-protection", 81 + label: "Magic Link (Auth)", 73 82 }, 74 83 ], 75 84 },
+7 -4
apps/docs/src/content/docs/reference/status-page.mdx
··· 14 14 15 15 You can configure a custom domain for your status page. e.g `status.example.com` => `https://status.example.com` 16 16 17 - ### Password 17 + ### Password (basic auth) 18 18 19 - You can add a password for your status page to protect a status page. If you are not authenticated, you will get redirected to the `/protected`. 19 + You can add a password for your status page to protect a status page. If you are not authenticated, you will get redirected to the `/login`. 20 + 21 + The password is stored in plain text in a `cookie`, so keep the password easy and fun. You can share any page by appending the password via the `pw` search param like: `https://[slug].openstatus.dev/?pw=my-secret-password` and we will automatically authenticate the user. This can be useful to authenticate to private RSS feeds. 20 22 21 - The password is stored in plain text in a `cookie`, so keep the password easy and fun. You can share any page by appending the password via the `pw` search param like: `https://custom.stpg.dev/?pw=my-secret-password` and we will automatically authenticate the user. This can be useful to authenticate to private RSS feeds. 23 + ### Magic Link (session auth) 22 24 23 - > If you need an email confirmed status page, e.g. by whitelisting domains for specific partners who would access the page with a magic link, please contact ping@openstatus.dev - we have that on our backlog and are happy to prioritze it. 25 + You can secure your status page by restricting access to users with approved email domains. Users will receive a magic link sent to their email and, upon clicking it, will be authenticated via a session token. 24 26 27 + This feature is available as an add-on for paid plans and is not included by default. 25 28 26 29 ### Favicon 27 30
+1 -1
apps/server/src/routes/public/status.ts
··· 45 45 return c.json({ status: Status.Unknown }); 46 46 } 47 47 48 - if (currentPage.passwordProtected) { 48 + if (currentPage.accessType !== "public") { 49 49 return c.json({ status: Status.Unknown }); 50 50 } 51 51
+1
apps/server/src/routes/v1/monitors/post.ts
··· 104 104 ...rest, 105 105 workspaceId: workspaceId, 106 106 regions: regions ? regions.join(",") : undefined, 107 + description: input.description ?? undefined, 107 108 headers: input.headers ? JSON.stringify(input.headers) : undefined, 108 109 assertions: assert.length > 0 ? serialize(assert) : undefined, 109 110 timeout: input.timeout || 45000,
+1
apps/server/src/routes/v1/monitors/put.ts
··· 109 109 .set({ 110 110 ...rest, 111 111 regions: regions ? regions.join(",") : undefined, 112 + description: input.description ?? undefined, 112 113 headers: input.headers ? JSON.stringify(input.headers) : undefined, 113 114 assertions: assert.length > 0 ? serialize(assert) : undefined, 114 115 timeout: input.timeout || 45000,
+2 -2
apps/server/src/routes/v1/pages/get.ts
··· 5 5 import { db } from "@openstatus/db/src/db"; 6 6 import { page } from "@openstatus/db/src/schema"; 7 7 import type { pagesApi } from "./index"; 8 - import { PageSchema, ParamsSchema } from "./schema"; 8 + import { PageSchema, ParamsSchema, transformPageData } from "./schema"; 9 9 10 10 const getRoute = createRoute({ 11 11 method: "get", ··· 46 46 }); 47 47 } 48 48 49 - const data = PageSchema.parse(_page); 49 + const data = transformPageData(PageSchema.parse(_page)); 50 50 51 51 return c.json(data, 200); 52 52 });
+4 -2
apps/server/src/routes/v1/pages/get_all.ts
··· 4 4 import { db, eq } from "@openstatus/db"; 5 5 import { page } from "@openstatus/db/src/schema"; 6 6 import type { pagesApi } from "./index"; 7 - import { PageSchema } from "./schema"; 7 + import { PageSchema, transformPageData } from "./schema"; 8 8 9 9 const getAllRoute = createRoute({ 10 10 method: "get", ··· 33 33 .from(page) 34 34 .where(eq(page.workspaceId, workspaceId)); 35 35 36 - const data = PageSchema.array().parse(_pages); 36 + const data = PageSchema.array() 37 + .parse(_pages) 38 + .map((page) => transformPageData(page)); 37 39 38 40 return c.json(data, 200); 39 41 });
+25 -2
apps/server/src/routes/v1/pages/post.ts
··· 14 14 import { Events } from "@openstatus/analytics"; 15 15 import { isNumberArray } from "../utils"; 16 16 import type { pagesApi } from "./index"; 17 - import { PageSchema } from "./schema"; 17 + import { PageSchema, transformPageData } from "./schema"; 18 18 19 19 const postRoute = createRoute({ 20 20 method: "post", ··· 90 90 }); 91 91 } 92 92 93 + if ( 94 + !limits["password-protection"] && 95 + (input?.accessType === "password" || input?.password) 96 + ) { 97 + throw new OpenStatusApiError({ 98 + code: "PAYMENT_REQUIRED", 99 + message: "Upgrade for password protection", 100 + }); 101 + } 102 + 103 + if ( 104 + !limits["email-domain-protection"] && 105 + (input?.accessType === "email-domain" || input?.authEmailDomains?.length) 106 + ) { 107 + throw new OpenStatusApiError({ 108 + code: "PAYMENT_REQUIRED", 109 + message: "Upgrade for email domain protection", 110 + }); 111 + } 112 + 93 113 if (subdomainSafeList.includes(input.slug)) { 94 114 throw new OpenStatusApiError({ 95 115 code: "BAD_REQUEST", ··· 145 165 ...rest, 146 166 workspaceId: workspaceId, 147 167 customDomain: rest.customDomain ?? "", // TODO: make database migration to allow null 168 + accessType: 169 + rest.accessType ?? (rest.passwordProtected ? "password" : "public"), 170 + authEmailDomains: rest.authEmailDomains?.join(","), 148 171 }) 149 172 .returning() 150 173 .get(); ··· 161 184 .run(); 162 185 } 163 186 } 164 - const data = PageSchema.parse(_page); 187 + const data = transformPageData(PageSchema.parse(_page)); 165 188 return c.json(data, 200); 166 189 }); 167 190 }
+30 -5
apps/server/src/routes/v1/pages/put.ts
··· 13 13 } from "@openstatus/db/src/schema"; 14 14 import { isNumberArray } from "../utils"; 15 15 import type { pagesApi } from "./index"; 16 - import { PageSchema, ParamsSchema } from "./schema"; 16 + import { PageSchema, ParamsSchema, transformPageData } from "./schema"; 17 17 18 18 const putRoute = createRoute({ 19 19 method: "put", ··· 76 76 }); 77 77 } 78 78 79 + if ( 80 + limits["email-domain-protection"] === false && 81 + (input?.accessType === "email-domain" || input?.authEmailDomains?.length) 82 + ) { 83 + throw new OpenStatusApiError({ 84 + code: "PAYMENT_REQUIRED", 85 + message: "Upgrade for email domain protection", 86 + }); 87 + } 88 + 89 + if ( 90 + limits["password-protection"] === false && 91 + (input?.accessType === "password" || input?.password) 92 + ) { 93 + throw new OpenStatusApiError({ 94 + code: "PAYMENT_REQUIRED", 95 + message: "Upgrade for password protection", 96 + }); 97 + } 98 + 79 99 const _page = await db 80 100 .select() 81 101 .from(page) ··· 147 167 .set({ 148 168 ...rest, 149 169 customDomain: input.customDomain ?? "", 170 + accessType: 171 + rest.accessType ?? (rest.passwordProtected ? "password" : "public"), 172 + authEmailDomains: rest.authEmailDomains?.join(","), 150 173 updatedAt: new Date(), 151 174 }) 152 175 .where(eq(page.id, _page.id)) ··· 188 211 } 189 212 } 190 213 191 - const data = PageSchema.parse({ 192 - ...newPage, 193 - monitors: monitors || currentMonitorsToPages, 194 - }); 214 + const data = transformPageData( 215 + PageSchema.parse({ 216 + ...newPage, 217 + monitors: monitors || currentMonitorsToPages, 218 + }), 219 + ); 195 220 196 221 return c.json(data, 200); 197 222 });
+35 -2
apps/server/src/routes/v1/pages/schema.ts
··· 53 53 }), 54 54 passwordProtected: z.boolean().optional().prefault(false).openapi({ 55 55 description: 56 - "Make the page password protected. Used with the 'passwordProtected' property.", 56 + "Deprecated in favor of `accessType`. Used to set the password protection type. Returns true if `accessType` is set to 'password' and false otherwise.", 57 57 example: true, 58 + deprecated: true, 58 59 }), 60 + accessType: z 61 + .enum(["public", "password", "email-domain"]) 62 + .default("public") 63 + .openapi({ 64 + description: "The access type of the page", 65 + example: "public", 66 + }), 59 67 password: z.string().optional().nullish().openapi({ 60 68 description: "Your password to protect the page from the public", 61 69 example: "hidden-password", 62 70 }), 71 + authEmailDomains: z 72 + .array(z.string()) 73 + .optional() 74 + .nullish() 75 + .openapi({ 76 + description: "The email domains of the page", 77 + example: ["example.com", "example.org"], 78 + }), 63 79 showMonitorValues: z.boolean().optional().nullish().prefault(true).openapi({ 64 80 description: 65 - "Displays the total and failed request numbers for each monitor", 81 + "Displays the total and failed request numbers for each monitor. Deprecated and will be removed in the future in favor for `configuration` property.", 66 82 example: true, 83 + deprecated: true, 67 84 }), 68 85 monitors: z 69 86 .array(z.number()) ··· 89 106 .openapi("Page"); 90 107 91 108 export type PageSchema = z.infer<typeof PageSchema>; 109 + 110 + /** 111 + * Transforms page data to ensure passwordProtected reflects accessType 112 + * This should be used when parsing page data for responses 113 + * 114 + * NOTE: cannot be used in `PageSchema` because `.omit` is not supported otherwise 115 + */ 116 + export function transformPageData< 117 + T extends { accessType?: string; passwordProtected?: boolean }, 118 + >(data: T): T & { passwordProtected: boolean } { 119 + return { 120 + ...data, 121 + passwordProtected: 122 + data.accessType === "password" ? true : data.passwordProtected ?? false, 123 + }; 124 + }
+1 -1
apps/status-page/next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 - import "./.next/types/routes.d.ts"; 3 + import "./.next/dev/types/routes.d.ts"; 4 4 5 5 // NOTE: This file should not be edited 6 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2
apps/status-page/package.json
··· 11 11 "tsc": "tsc --noEmit" 12 12 }, 13 13 "dependencies": { 14 + "@auth/core": "0.40.0", 15 + "@auth/drizzle-adapter": "1.10.0", 14 16 "@date-fns/tz": "1.2.0", 15 17 "@date-fns/utc": "2.1.0", 16 18 "@dnd-kit/core": "6.3.1",
+28
apps/status-page/src/app/(status-page)/[domain]/(auth)/layout.tsx
··· 1 + import { Footer } from "@/components/nav/footer"; 2 + import { getQueryClient, trpc } from "@/lib/trpc/server"; 3 + import { Suspense } from "react"; 4 + 5 + export default async function Layout({ 6 + children, 7 + params, 8 + }: { 9 + children: React.ReactNode; 10 + params: Promise<{ domain: string }>; 11 + }) { 12 + const queryClient = getQueryClient(); 13 + const { domain } = await params; 14 + await queryClient.prefetchQuery( 15 + trpc.statusPage.get.queryOptions({ slug: domain }), 16 + ); 17 + 18 + return ( 19 + <Suspense> 20 + <div className="flex min-h-screen flex-col gap-4"> 21 + <main className="mx-auto flex w-full max-w-2xl flex-1 flex-col px-3 py-2"> 22 + {children} 23 + </main> 24 + <Footer className="w-full border-t" /> 25 + </div> 26 + </Suspense> 27 + ); 28 + }
+90
apps/status-page/src/app/(status-page)/[domain]/(auth)/login/_components/section-magic-link.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + EmptyStateContainer, 5 + EmptyStateDescription, 6 + EmptyStateTitle, 7 + } from "@/components/content/empty-state"; 8 + import { 9 + Section, 10 + SectionDescription, 11 + SectionHeader, 12 + SectionTitle, 13 + } from "@/components/content/section"; 14 + import { FormEmail, type FormValues } from "@/components/forms/form-email"; 15 + import { Button } from "@/components/ui/button"; 16 + import { Inbox } from "lucide-react"; 17 + import { useParams } from "next/navigation"; 18 + import { useState } from "react"; 19 + import { flushSync } from "react-dom"; 20 + import { signInWithResendAction } from "../actions"; 21 + 22 + export function SectionMagicLink() { 23 + const { domain } = useParams<{ domain: string }>(); 24 + const [state, setState] = useState<"idle" | "pending" | "success">("idle"); 25 + 26 + async function submitAction(values: FormValues) { 27 + // NOTE: we can improve a bit if we use pathname instead of subdomain/hostname 28 + // like http://localhost:3000/hello, the redirectTo should be http://localhost:3000/hello 29 + // this only affects local development if not using chrome and subdomain 30 + const redirectTo = 31 + process.env.NODE_ENV === "development" 32 + ? `http://${window.location.hostname}:${window.location.port}` 33 + : `https://${window.location.hostname}`; 34 + 35 + const formData = new FormData(); 36 + formData.append("redirectTo", redirectTo); 37 + formData.append("email", values.email); 38 + formData.append("domain", domain); 39 + 40 + // we need this because submitAction is called in a startTransition and we need to update the state immediately 41 + flushSync(() => setState("pending")); 42 + 43 + try { 44 + await new Promise((resolve) => setTimeout(resolve, 1000)); 45 + await signInWithResendAction(formData); 46 + setState("success"); 47 + } catch (error) { 48 + setState("idle"); 49 + throw error; 50 + } 51 + } 52 + 53 + return ( 54 + <Section className="m-auto w-full max-w-lg rounded-lg border bg-card p-4"> 55 + <SectionHeader> 56 + <SectionTitle>Authenticate</SectionTitle> 57 + <SectionDescription> 58 + Enter your email to receive a magic link for accessing the status 59 + page. Note: Only emails from approved domains are accepted. 60 + </SectionDescription> 61 + </SectionHeader> 62 + {state !== "success" ? ( 63 + <div className="flex flex-col gap-2"> 64 + <FormEmail id="email-form" onSubmit={submitAction} /> 65 + <Button 66 + type="submit" 67 + form="email-form" 68 + disabled={state === "pending"} 69 + > 70 + {state === "pending" ? "Submitting..." : "Submit"} 71 + </Button> 72 + </div> 73 + ) : ( 74 + <SuccessState /> 75 + )} 76 + </Section> 77 + ); 78 + } 79 + 80 + function SuccessState() { 81 + return ( 82 + <EmptyStateContainer> 83 + <Inbox className="size-4 shrink-0" /> 84 + <EmptyStateTitle>Check your inbox!</EmptyStateTitle> 85 + <EmptyStateDescription> 86 + Access the status page by clicking the link in the email. 87 + </EmptyStateDescription> 88 + </EmptyStateContainer> 89 + ); 90 + }
+37
apps/status-page/src/app/(status-page)/[domain]/(auth)/login/actions.ts
··· 1 + "use server"; 2 + 3 + import { signIn } from "@/lib/auth"; 4 + import { getQueryClient, trpc } from "@/lib/trpc/server"; 5 + import { AuthError } from "next-auth"; 6 + import { isRedirectError } from "next/dist/client/components/redirect-error"; 7 + 8 + export async function signInWithResendAction(formData: FormData) { 9 + try { 10 + const email = formData.get("email") as string; 11 + const redirectTo = formData.get("redirectTo") as string; 12 + const domain = formData.get("domain") as string; 13 + 14 + if (!email || !redirectTo) { 15 + throw new Error("Email and redirectTo are required"); 16 + } 17 + 18 + const queryClient = getQueryClient(); 19 + // NOTE: throws an error if the email domain is not allowed 20 + await queryClient.fetchQuery( 21 + trpc.statusPage.validateEmailDomain.queryOptions({ slug: domain, email }), 22 + ); 23 + 24 + await signIn("resend", { 25 + email, 26 + redirectTo, 27 + }); 28 + } catch (e) { 29 + // NOTE: https://github.com/nextauthjs/next-auth/discussions/9389 30 + if (isRedirectError(e)) return; 31 + console.error(e); 32 + if (e instanceof AuthError) { 33 + throw new Error(`Authentication error: ${e.type}`); 34 + } 35 + throw e; 36 + } 37 + }
+25
apps/status-page/src/app/(status-page)/[domain]/(auth)/login/page.tsx
··· 1 + "use client"; 2 + 3 + import { useTRPC } from "@/lib/trpc/client"; 4 + import { useQuery } from "@tanstack/react-query"; 5 + import { notFound, useParams } from "next/navigation"; 6 + import { SectionMagicLink } from "./_components/section-magic-link"; 7 + import { SectionPassword } from "./_components/section-password"; 8 + 9 + export default function LoginPage() { 10 + const { domain } = useParams<{ domain: string }>(); 11 + const trpc = useTRPC(); 12 + const { data: page } = useQuery( 13 + trpc.statusPage.get.queryOptions({ slug: domain }), 14 + ); 15 + 16 + if (page?.accessType === "password") { 17 + return <SectionPassword />; 18 + } 19 + 20 + if (page?.accessType === "email-domain") { 21 + return <SectionMagicLink />; 22 + } 23 + 24 + return notFound(); 25 + }
-15
apps/status-page/src/app/(status-page)/[domain]/(private)/layout.tsx
··· 1 - import { Footer } from "@/components/nav/footer"; 2 - import { Suspense } from "react"; 3 - 4 - export default function Layout({ children }: { children: React.ReactNode }) { 5 - return ( 6 - <Suspense> 7 - <div className="flex min-h-screen flex-col gap-4"> 8 - <main className="mx-auto flex w-full max-w-2xl flex-1 flex-col px-3 py-2"> 9 - {children} 10 - </main> 11 - <Footer className="w-full border-t" /> 12 - </div> 13 - </Suspense> 14 - ); 15 - }
+1 -1
apps/status-page/src/app/(status-page)/[domain]/(private)/protected/page.tsx apps/status-page/src/app/(status-page)/[domain]/(auth)/login/_components/section-password.tsx
··· 14 14 import { useMutation } from "@tanstack/react-query"; 15 15 import { useParams, useRouter, useSearchParams } from "next/navigation"; 16 16 17 - export default function PrivatePage() { 17 + export function SectionPassword() { 18 18 const { domain } = useParams<{ domain: string }>(); 19 19 const searchParams = useSearchParams(); 20 20 const trpc = useTRPC();
+12 -1
apps/status-page/src/app/(status-page)/[domain]/(public)/feed/[type]/route.ts
··· 1 + import { auth } from "@/lib/auth"; 1 2 import { getBaseUrl } from "@/lib/base-url"; 2 3 import { getQueryClient, trpc } from "@/lib/trpc/server"; 3 4 import { Feed } from "feed"; ··· 20 21 try { 21 22 const queryClient = getQueryClient(); 22 23 const { domain, type } = await props.params; 24 + 23 25 if (!["rss", "atom"].includes(type)) return notFound(); 24 26 25 27 const page = await queryClient.fetchQuery( ··· 27 29 ); 28 30 if (!page) return notFound(); 29 31 30 - if (page.passwordProtected) { 32 + if (page.accessType === "password") { 31 33 const url = new URL(_request.url); 32 34 const password = url.searchParams.get("pw"); 33 35 console.log({ url, page, password }); 34 36 if (password !== page.password) return unauthorized(); 37 + } 38 + 39 + if (page.accessType === "email-domain") { 40 + const session = await auth(); 41 + const user = session?.user; 42 + const allowedDomains = page.authEmailDomains ?? []; 43 + if (!user || !user.email) return unauthorized(); 44 + if (!allowedDomains.includes(user.email.split("@")[1])) 45 + return unauthorized(); 35 46 } 36 47 37 48 const baseUrl = getBaseUrl({
+11 -1
apps/status-page/src/app/(status-page)/[domain]/(public)/feed/json/route.ts
··· 1 + import { auth } from "@/lib/auth"; 1 2 import { getQueryClient, trpc } from "@/lib/trpc/server"; 2 3 import { notFound, unauthorized } from "next/navigation"; 3 4 ··· 25 26 26 27 if (!_page) return notFound(); 27 28 28 - if (_page.passwordProtected) { 29 + if (_page.accessType === "password") { 29 30 const url = new URL(_request.url); 30 31 const password = url.searchParams.get("pw"); 31 32 console.log({ url, _page, password }); 32 33 if (password !== _page.password) return unauthorized(); 34 + } 35 + 36 + if (_page.accessType === "email-domain") { 37 + const session = await auth(); 38 + const user = session?.user; 39 + const allowedDomains = _page.authEmailDomains ?? []; 40 + if (!user || !user.email) return unauthorized(); 41 + if (!allowedDomains.includes(user.email.split("@")[1])) 42 + return unauthorized(); 33 43 } 34 44 35 45 const page = await queryClient.fetchQuery(
+2 -6
apps/status-page/src/app/(status-page)/[domain]/layout.tsx
··· 114 114 }, 115 115 twitter: { 116 116 ...twitterMetadata, 117 - images: [ 118 - `/api/og/page?slug=${page?.slug}&passwordProtected=${page?.passwordProtected}`, 119 - ], 117 + images: [`/api/og/page?slug=${page?.slug}`], 120 118 title: page?.title, 121 119 description: page?.description, 122 120 }, 123 121 openGraph: { 124 122 ...ogMetadata, 125 - images: [ 126 - `/api/og/page?slug=${page?.slug}&passwordProtected=${page?.passwordProtected}`, 127 - ], 123 + images: [`/api/og/page?slug=${page?.slug}`], 128 124 title: page?.title, 129 125 description: page?.description, 130 126 },
+2 -1
apps/status-page/src/app/api/trpc/edge/[trpc]/route.ts
··· 1 1 import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 2 import type { NextRequest } from "next/server"; 3 3 4 + import { auth } from "@/lib/auth"; 4 5 import { createTRPCContext } from "@openstatus/api"; 5 6 import { edgeRouter } from "@openstatus/api/src/edge"; 6 7 ··· 11 12 endpoint: "/api/trpc/edge", 12 13 router: edgeRouter, 13 14 req: req, 14 - createContext: () => createTRPCContext({ req }), 15 + createContext: () => createTRPCContext({ req, auth }), 15 16 onError: ({ error }) => { 16 17 console.log("Error in tRPC handler (edge)"); 17 18 console.error(error);
+2 -1
apps/status-page/src/app/api/trpc/lambda/[trpc]/route.ts
··· 1 1 import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 2 import type { NextRequest } from "next/server"; 3 3 4 + import { auth } from "@/lib/auth"; 4 5 import { createTRPCContext } from "@openstatus/api"; 5 6 import { lambdaRouter } from "@openstatus/api/src/lambda"; 6 7 ··· 12 13 endpoint: "/api/trpc/lambda", 13 14 router: lambdaRouter, 14 15 req: req, 15 - createContext: () => createTRPCContext({ req }), 16 + createContext: () => createTRPCContext({ req, auth }), 16 17 onError: ({ error }) => { 17 18 console.log("Error in tRPC handler (lambda)"); 18 19 console.error(error);
+85
apps/status-page/src/components/forms/form-email.tsx
··· 1 + "use client"; 2 + 3 + import { Form } from "@/components/ui/form"; 4 + import { 5 + FormControl, 6 + FormField, 7 + FormItem, 8 + FormLabel, 9 + } from "@/components/ui/form"; 10 + import { Input } from "@/components/ui/input"; 11 + import { zodResolver } from "@hookform/resolvers/zod"; 12 + import { isTRPCClientError } from "@trpc/client"; 13 + import { useTransition } from "react"; 14 + import { useForm } from "react-hook-form"; 15 + import { toast } from "sonner"; 16 + import { z } from "zod"; 17 + 18 + const schema = z.object({ 19 + email: z.string().email(), 20 + }); 21 + 22 + export type FormValues = z.infer<typeof schema>; 23 + 24 + export function FormEmail({ 25 + onSubmit, 26 + ...props 27 + }: Omit<React.ComponentProps<"form">, "onSubmit"> & { 28 + onSubmit: (values: FormValues) => Promise<void>; 29 + }) { 30 + const form = useForm<FormValues>({ 31 + resolver: zodResolver(schema), 32 + defaultValues: { 33 + email: "", 34 + }, 35 + }); 36 + const [isPending, startTransition] = useTransition(); 37 + 38 + function submitAction(values: FormValues) { 39 + if (isPending) return; 40 + 41 + startTransition(async () => { 42 + try { 43 + const promise = onSubmit(values); 44 + toast.promise(promise, { 45 + loading: "Confirming...", 46 + success: "Confirmed", 47 + error: (error) => { 48 + console.error(error); 49 + if (isTRPCClientError(error)) { 50 + form.setError("email", { message: error.message }); 51 + return error.message; 52 + } 53 + if (error instanceof Error) { 54 + form.setError("email", { message: error.message }); 55 + return error.message; 56 + } 57 + return "Failed to confirm"; 58 + }, 59 + }); 60 + await promise; 61 + } catch (error) { 62 + console.error(error); 63 + } 64 + }); 65 + } 66 + 67 + return ( 68 + <Form {...form}> 69 + <form onSubmit={form.handleSubmit(submitAction)} {...props}> 70 + <FormField 71 + control={form.control} 72 + name="email" 73 + render={({ field }) => ( 74 + <FormItem> 75 + <FormLabel>Email</FormLabel> 76 + <FormControl> 77 + <Input type="email" {...field} /> 78 + </FormControl> 79 + </FormItem> 80 + )} 81 + /> 82 + </form> 83 + </Form> 84 + ); 85 + }
+23 -8
apps/status-page/src/components/nav/header.tsx
··· 1 1 "use client"; 2 2 3 3 import { Link } from "@/components/common/link"; 4 - import { StatusUpdates } from "@/components/status-page/status-updates"; 4 + import { 5 + type StatusUpdateType, 6 + StatusUpdates, 7 + } from "@/components/status-page/status-updates"; 5 8 import { Button } from "@/components/ui/button"; 6 9 import { 7 10 Sheet, ··· 19 22 import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; 20 23 import { useTRPC } from "@/lib/trpc/client"; 21 24 import { cn } from "@/lib/utils"; 25 + import type { RouterOutputs } from "@openstatus/api"; 22 26 import { useMutation, useQuery } from "@tanstack/react-query"; 23 27 import { isTRPCClientError } from "@trpc/client"; 24 28 import { Menu, MessageCircleMore } from "lucide-react"; ··· 26 30 import { useParams, usePathname } from "next/navigation"; 27 31 import { useState } from "react"; 28 32 import { toast } from "sonner"; 33 + 34 + type Page = RouterOutputs["statusPage"]["get"]; 29 35 30 36 function useNav() { 31 37 const pathname = usePathname(); ··· 50 56 ]; 51 57 } 52 58 59 + function getStatusUpdateTypes(page: Page): StatusUpdateType[] { 60 + if (!page) return []; 61 + 62 + // NOTE: rss or json are not supported because of authentication 63 + if (page?.accessType === "email-domain") { 64 + return ["email"] as const; 65 + } 66 + 67 + if (page?.workspacePlan === "free") { 68 + return ["slack", "rss", "json"] as const; 69 + } 70 + 71 + return ["email", "slack", "rss", "json"] as const; 72 + } 73 + 53 74 export function Header(props: React.ComponentProps<"header">) { 54 75 const trpc = useTRPC(); 55 76 const { domain } = useParams<{ domain: string }>(); ··· 81 102 }, 82 103 }), 83 104 ); 84 - 85 - const types = ( 86 - page?.workspacePlan === "free" 87 - ? ["slack", "rss", "json"] 88 - : ["email", "slack", "rss", "json"] 89 - ) satisfies ("email" | "rss" | "ssh" | "json" | "slack")[]; 90 105 91 106 return ( 92 107 <header {...props}> ··· 132 147 <GetInTouch buttonType="icon" link={page.contactUrl} /> 133 148 ) : null} 134 149 <StatusUpdates 135 - types={types} 150 + types={getStatusUpdateTypes(page)} 136 151 onSubscribe={async (email) => { 137 152 await subscribeMutation.mutateAsync({ slug: domain, email }); 138 153 }}
+19 -18
apps/status-page/src/components/status-page/status-updates.tsx
··· 17 17 import { Check, Copy, Inbox } from "lucide-react"; 18 18 import { useState } from "react"; 19 19 20 - type StatusUpdateType = "email" | "rss" | "ssh" | "json" | "slack"; 20 + export type StatusUpdateType = "email" | "rss" | "ssh" | "json" | "slack"; 21 21 22 22 type Page = NonNullable<RouterOutputs["statusPage"]["get"]>; 23 + 24 + function getUpdateLink(type: "rss" | "json" | "atom", page?: Page | null) { 25 + const baseUrl = getBaseUrl({ 26 + slug: page?.slug, 27 + customDomain: page?.customDomain, 28 + }); 29 + 30 + return `${baseUrl}/feed/${type}${ 31 + page?.accessType === "password" ? `?pw=${page?.password}` : "" 32 + }`; 33 + } 23 34 24 35 // TODO: use domain instead of openstatus subdomain if available 25 36 ··· 37 48 ...props 38 49 }: StatusUpdatesProps) { 39 50 const [success, setSuccess] = useState(false); 40 - const baseUrl = getBaseUrl({ 41 - slug: page?.slug, 42 - customDomain: page?.customDomain, 43 - }); 51 + 52 + if (types.length === 0) return null; 44 53 45 54 return ( 46 55 <Popover> ··· 55 64 </Button> 56 65 </PopoverTrigger> 57 66 <PopoverContent align="end" className="w-80 overflow-hidden p-0"> 58 - <Tabs defaultValue="email"> 67 + <Tabs defaultValue={types[0]}> 59 68 <TabsList className="w-full rounded-none border-b"> 60 69 {types.includes("email") ? ( 61 70 <TabsTrigger value="email">Email</TabsTrigger> ··· 105 114 <CopyInputButton 106 115 className="w-full" 107 116 id="rss" 108 - value={`${baseUrl}/feed/rss${ 109 - page?.passwordProtected ? `?pw=${page?.password}` : "" 110 - }`} 117 + value={getUpdateLink("rss", page)} 111 118 /> 112 119 </div> 113 120 <Separator /> ··· 116 123 <CopyInputButton 117 124 className="w-full" 118 125 id="atom" 119 - value={`${baseUrl}/feed/atom${ 120 - page?.passwordProtected ? `?pw=${page?.password}` : "" 121 - }`} 126 + value={getUpdateLink("atom", page)} 122 127 /> 123 128 </div> 124 129 </TabsContent> ··· 128 133 <CopyInputButton 129 134 className="w-full" 130 135 id="json" 131 - value={`${baseUrl}/feed/json${ 132 - page?.passwordProtected ? `?pw=${page?.password}` : "" 133 - }`} 136 + value={getUpdateLink("json", page)} 134 137 /> 135 138 </div> 136 139 </TabsContent> ··· 153 156 <CopyInputButton 154 157 className="w-full" 155 158 id="slack" 156 - value={`/feed subscribe ${baseUrl}/feed/rss${ 157 - page?.passwordProtected ? `?pw=${page?.password}` : "" 158 - }`} 159 + value={`/feed subscribe ${getUpdateLink("rss", page)}`} 159 160 /> 160 161 </div> 161 162 </TabsContent>
+24
apps/status-page/src/lib/auth/adapter.ts
··· 1 + import { DrizzleAdapter } from "@auth/drizzle-adapter"; 2 + import type { Adapter } from "next-auth/adapters"; 3 + 4 + import { db } from "@openstatus/db"; 5 + import { 6 + verificationToken, 7 + viewer, 8 + viewerAccounts, 9 + viewerSession, 10 + } from "@openstatus/db/src/schema"; 11 + 12 + export const adapter: Adapter = { 13 + ...DrizzleAdapter(db, { 14 + // @ts-ignore 15 + usersTable: viewer, 16 + // NOTE: only need accounts for external providers 17 + // @ts-ignore 18 + accountsTable: viewerAccounts, 19 + // @ts-ignore 20 + sessionsTable: viewerSession, 21 + // @ts-ignore 22 + verificationTokensTable: verificationToken, 23 + }), 24 + };
+63
apps/status-page/src/lib/auth/index.ts
··· 1 + import type { DefaultSession } from "next-auth"; 2 + import NextAuth, { AuthError } from "next-auth"; 3 + 4 + import { db, eq } from "@openstatus/db"; 5 + import { viewer } from "@openstatus/db/src/schema"; 6 + 7 + import { getValidCustomDomain } from "@/lib/domain"; 8 + import { getQueryClient, trpc } from "@/lib/trpc/server"; 9 + import { headers } from "next/headers"; 10 + import { adapter } from "./adapter"; 11 + import { ResendProvider } from "./providers"; 12 + 13 + export type { DefaultSession }; 14 + 15 + export const { handlers, signIn, signOut, auth } = NextAuth({ 16 + debug: process.env.NODE_ENV === "development", 17 + adapter, 18 + providers: [ResendProvider], 19 + callbacks: { 20 + async signIn(params) { 21 + const _headers = await headers(); 22 + const host = _headers.get("host"); 23 + 24 + if (!host) throw new AuthError("No host found"); 25 + 26 + const req = new Request(host, { headers: new Headers(_headers) }); 27 + const { prefix } = getValidCustomDomain(req); 28 + 29 + if (!prefix || !params.user.email) return false; 30 + 31 + const queryClient = getQueryClient(); 32 + // NOTE: throws an error if the email domain is not allowed 33 + const query = await queryClient.fetchQuery( 34 + trpc.statusPage.validateEmailDomain.queryOptions({ 35 + slug: prefix, 36 + email: params.user.email, 37 + }), 38 + ); 39 + 40 + if (!query) return false; 41 + 42 + if (params.account?.provider === "resend") { 43 + // if the user is new, the id is the verification_token and not the viewer id, so we cannot update the viewer 44 + if (Number.isNaN(Number(params.user.id))) return true; 45 + await db 46 + .update(viewer) 47 + .set({ updatedAt: new Date() }) 48 + .where(eq(viewer.id, Number(params.user.id))) 49 + .run(); 50 + 51 + return true; 52 + } 53 + 54 + return false; 55 + }, 56 + redirect: async (params) => { 57 + return params.url; 58 + }, 59 + async session(params) { 60 + return params.session; 61 + }, 62 + }, 63 + });
+33
apps/status-page/src/lib/auth/providers.ts
··· 1 + import { getQueryClient, trpc } from "@/lib/trpc/server"; 2 + import { EmailClient } from "@openstatus/emails"; 3 + import Resend from "next-auth/providers/resend"; 4 + import { getValidCustomDomain } from "../domain"; 5 + 6 + export const ResendProvider = Resend({ 7 + apiKey: undefined, 8 + async sendVerificationRequest(params) { 9 + const url = params.url; 10 + const email = params.identifier; 11 + 12 + const emailClient = new EmailClient({ 13 + apiKey: process.env.RESEND_API_KEY ?? "", 14 + }); 15 + 16 + const { prefix } = getValidCustomDomain(params.request); 17 + 18 + if (!prefix) return; 19 + 20 + const queryClient = getQueryClient(); 21 + const query = await queryClient.fetchQuery( 22 + trpc.statusPage.validateEmailDomain.queryOptions({ slug: prefix, email }), 23 + ); 24 + 25 + if (!query) return; 26 + 27 + await emailClient.sendStatusPageMagicLink({ 28 + page: query.page.title, 29 + link: url, 30 + to: params.identifier, 31 + }); 32 + }, 33 + });
+86
apps/status-page/src/lib/domain.ts
··· 1 + import type { NextRequest } from "next/server"; 2 + 3 + export const getValidSubdomain = (host?: string | null) => { 4 + let subdomain: string | null = null; 5 + if (!host && typeof window !== "undefined") { 6 + // On client side, get the host from window 7 + // biome-ignore lint: to fix later 8 + host = window.location.host; 9 + } 10 + 11 + // Exclude localhost and IP addresses from being treated as subdomains 12 + if ( 13 + host?.match(/^(localhost|127\\.0\\.0\\.1|::1|\\d+\\.\\d+\\.\\d+\\.\\d+)/) 14 + ) { 15 + return null; 16 + } 17 + 18 + // Handle subdomains of localhost (e.g., hello.localhost:3000) 19 + if (host?.match(/^([^.]+)\.localhost(:\d+)?$/)) { 20 + const match = host.match(/^([^.]+)\.localhost(:\d+)?$/); 21 + return match?.[1] || null; 22 + } 23 + 24 + // we should improve here for custom vercel deploy page 25 + if (host?.includes(".") && !host.includes(".vercel.app")) { 26 + const candidate = host.split(".")[0]; 27 + if (candidate && !candidate.includes("www")) { 28 + // Valid candidate 29 + subdomain = candidate; 30 + } 31 + } 32 + 33 + // In case the host is a custom domain 34 + if ( 35 + host && 36 + !( 37 + host?.includes("stpg.dev") || 38 + host?.includes("openstatus.dev") || 39 + host?.endsWith(".vercel.app") 40 + ) 41 + ) { 42 + subdomain = host; 43 + } 44 + return subdomain; 45 + }; 46 + 47 + export const getValidCustomDomain = (req: NextRequest | Request) => { 48 + const url = "nextUrl" in req ? req.nextUrl.clone() : new URL(req.url); 49 + const headers = req.headers; 50 + const host = headers.get("x-forwarded-host"); 51 + 52 + let prefix = ""; 53 + let type: "hostname" | "pathname"; 54 + 55 + const hostnames = host?.split(/[.:]/) ?? url.host.split(/[.:]/); 56 + const pathnames = url.pathname.split("/"); 57 + 58 + const subdomain = getValidSubdomain(url.host); 59 + console.log({ 60 + hostnames, 61 + pathnames, 62 + host, 63 + urlHost: url.host, 64 + subdomain, 65 + }); 66 + 67 + if ( 68 + hostnames.length > 2 && 69 + hostnames[0] !== "www" && 70 + !url.host.endsWith(".vercel.app") 71 + ) { 72 + prefix = hostnames[0].toLowerCase(); 73 + type = "hostname"; 74 + } else { 75 + prefix = pathnames[1].toLowerCase(); 76 + type = "pathname"; 77 + } 78 + 79 + if (subdomain !== null) { 80 + prefix = subdomain.toLowerCase(); 81 + } 82 + 83 + console.log({ type, prefix }); 84 + 85 + return { type, prefix }; 86 + };
+2 -2
apps/status-page/src/next-auth.d.ts
··· 1 - import type { User as DefaultUserSchema } from "@openstatus/db/src/schema"; 1 + import type { Viewer as DefaultViewerSchema } from "@openstatus/db/src/schema"; 2 2 3 3 declare module "next-auth" { 4 - interface User extends DefaultUserSchema {} 4 + interface User extends DefaultViewerSchema {} 5 5 }
+47 -55
apps/status-page/src/proxy.ts
··· 1 - import { type NextRequest, NextResponse } from "next/server"; 1 + import { NextResponse } from "next/server"; 2 + 3 + import { auth } from "@/lib/auth"; 2 4 3 5 import { db, sql } from "@openstatus/db"; 4 - import { page } from "@openstatus/db/src/schema"; 6 + import { page, selectPageSchema } from "@openstatus/db/src/schema"; 7 + import { getValidSubdomain } from "./lib/domain"; 5 8 import { createProtectedCookieKey } from "./lib/protected"; 6 9 7 - export default async function _proxy1(req: NextRequest) { 10 + export default auth(async (req) => { 8 11 const url = req.nextUrl.clone(); 9 12 const response = NextResponse.next(); 10 13 const cookies = req.cookies; ··· 48 51 return response; 49 52 } 50 53 51 - const _page = await db 54 + const query = await db 52 55 .select() 53 56 .from(page) 54 57 .where( ··· 56 59 ) 57 60 .get(); 58 61 59 - console.log({ slug: _page?.slug, customDomain: _page?.customDomain }); 62 + const validation = selectPageSchema.safeParse(query); 60 63 61 - if (!_page) { 62 - // return NextResponse.redirect(new URL("https://stpg.dev")); 63 - // TODO: work on 404 page 64 + if (!validation.success) { 64 65 return response; 65 66 } 66 67 67 - if (_page?.passwordProtected) { 68 + const _page = validation.data; 69 + 70 + console.log({ slug: _page?.slug, customDomain: _page?.customDomain }); 71 + 72 + if (_page?.accessType === "password") { 68 73 const protectedCookie = cookies.get(createProtectedCookieKey(_page.slug)); 69 74 const cookiePassword = protectedCookie ? protectedCookie.value : undefined; 70 75 const queryPassword = url.searchParams.get("pw"); 71 76 const password = queryPassword || cookiePassword; 72 77 73 - if (password !== _page.password && !url.pathname.endsWith("/protected")) { 78 + if (password !== _page.password && !url.pathname.endsWith("/login")) { 74 79 const { pathname, origin } = req.nextUrl; 75 80 76 81 // custom domain redirect 77 82 if (_page.customDomain && host !== `${_page.slug}.stpg.dev`) { 78 83 const redirect = pathname.replace(`/${_page.customDomain}`, ""); 79 84 const url = new URL( 80 - `https://${ 81 - _page.customDomain 82 - }/protected?redirect=${encodeURIComponent(redirect)}`, 85 + `https://${_page.customDomain}/login?redirect=${encodeURIComponent( 86 + redirect, 87 + )}`, 83 88 ); 84 - console.log("redirect to /protected", url.toString()); 89 + console.log("redirect to /login", url.toString()); 85 90 return NextResponse.redirect(url); 86 91 } 87 92 88 93 const url = new URL( 89 94 `${origin}${ 90 95 type === "pathname" ? `/${prefix}` : "" 91 - }/protected?redirect=${encodeURIComponent(pathname)}`, 96 + }/login?redirect=${encodeURIComponent(pathname)}`, 92 97 ); 93 98 return NextResponse.redirect(url); 94 99 } 95 - if (password === _page.password && url.pathname.endsWith("/protected")) { 100 + if (password === _page.password && url.pathname.endsWith("/login")) { 96 101 const redirect = url.searchParams.get("redirect"); 97 102 98 103 // custom domain redirect ··· 112 117 } 113 118 } 114 119 120 + if (_page.accessType === "email-domain") { 121 + const { origin, pathname } = req.nextUrl; 122 + const email = req.auth?.user?.email; 123 + const emailDomain = email?.split("@")[1]; 124 + if ( 125 + !pathname.endsWith("/login") && 126 + (!emailDomain || !_page.authEmailDomains.includes(emailDomain)) 127 + ) { 128 + const url = new URL( 129 + `${origin}${type === "pathname" ? `/${prefix}` : ""}/login`, 130 + ); 131 + return NextResponse.redirect(url); 132 + } 133 + if ( 134 + pathname.endsWith("/login") && 135 + emailDomain && 136 + _page.authEmailDomains.includes(emailDomain) 137 + ) { 138 + const url = new URL( 139 + `${origin}${type === "pathname" ? `/${prefix}` : ""}`, 140 + ); 141 + return NextResponse.redirect(url); 142 + } 143 + } 144 + 115 145 const proxy = req.headers.get("x-proxy"); 116 146 console.log({ proxy }); 117 147 ··· 169 199 return NextResponse.rewrite(rewriteUrl); 170 200 } 171 201 return response; 172 - } 202 + }); 173 203 174 204 export const config = { 175 205 matcher: [ 176 206 "/((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", 177 207 ], 178 208 }; 179 - 180 - export const getValidSubdomain = (host?: string | null) => { 181 - let subdomain: string | null = null; 182 - if (!host && typeof window !== "undefined") { 183 - // On client side, get the host from window 184 - // biome-ignore lint: to fix later 185 - host = window.location.host; 186 - } 187 - 188 - // Exclude localhost and IP addresses from being treated as subdomains 189 - if ( 190 - host?.match(/^(localhost|127\\.0\\.0\\.1|::1|\\d+\\.\\d+\\.\\d+\\.\\d+)/) 191 - ) { 192 - return null; 193 - } 194 - 195 - // we should improve here for custom vercel deploy page 196 - if (host?.includes(".") && !host.includes(".vercel.app")) { 197 - const candidate = host.split(".")[0]; 198 - if (candidate && !candidate.includes("www")) { 199 - // Valid candidate 200 - subdomain = candidate; 201 - } 202 - } 203 - 204 - // In case the host is a custom domain 205 - if ( 206 - host && 207 - !( 208 - host?.includes("stpg.dev") || 209 - host?.includes("openstatus.dev") || 210 - host?.endsWith(".vercel.app") 211 - ) 212 - ) { 213 - subdomain = host; 214 - } 215 - return subdomain; 216 - };
+1 -1
apps/web/next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 - import "./.next/types/routes.d.ts"; 3 + import "./.next/dev/types/routes.d.ts"; 4 4 5 5 // NOTE: This file should not be edited 6 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
apps/web/src/app/api/auth/[...nextauth]/route.ts apps/status-page/src/app/api/auth/[...nextauth]/route.ts
+6 -6
apps/web/src/app/api/og/page/route.tsx
··· 10 10 11 11 export const runtime = "edge"; 12 12 13 + // TODO: legacy Tracker - use api.statusPage.get.query instead 14 + 13 15 export async function GET(req: Request) { 14 16 const [interRegularData, interLightData, calSemiBoldData] = await Promise.all( 15 17 [interRegular, interLight, calSemiBold], ··· 18 20 const { searchParams } = new URL(req.url); 19 21 20 22 const slug = searchParams.has("slug") ? searchParams.get("slug") : undefined; 21 - const passwordProtected = searchParams.has("passwordProtected") 22 - ? searchParams.get("passwordProtected") === "true" // FIXME: can we use Boolean("true") or Boolean("false")? 23 - : undefined; 24 23 25 24 const page = await api.page.getPageBySlug.query({ slug: slug || "" }); 25 + const _protected = page?.accessType !== "public"; 26 26 const title = page ? page.title : TITLE; 27 27 const description = page ? "" : DESCRIPTION; 28 28 29 29 // REMINDER: if password protected, we keep the status 'operational' by default, hiding the actual status 30 30 const tracker = new Tracker({ 31 - incidents: passwordProtected ? undefined : page?.incidents, 32 - statusReports: passwordProtected ? undefined : page?.statusReports, 33 - maintenances: passwordProtected ? undefined : page?.maintenances, 31 + incidents: _protected ? undefined : page?.incidents, 32 + statusReports: _protected ? undefined : page?.statusReports, 33 + maintenances: _protected ? undefined : page?.maintenances, 34 34 }); 35 35 36 36 return new ImageResponse(
+6 -1
apps/web/src/app/api/webhook/stripe/route.ts
··· 29 29 case "checkout.session.completed": 30 30 await caller.stripeRouter.webhooks.sessionCompleted({ event }); 31 31 break; 32 - 32 + case "customer.subscription.updated": 33 + console.log(event); 34 + await caller.stripeRouter.webhooks.customerSubscriptionUpdated({ 35 + event, 36 + }); 37 + break; 33 38 case "customer.subscription.deleted": 34 39 await caller.stripeRouter.webhooks.customerSubscriptionDeleted({ 35 40 event,
+5 -3
apps/web/src/content/pages/unrelated/pricing.mdx
··· 25 25 ["Response logs", "", "+", "+"], 26 26 [<a href="/private-locations">Private locations</a>, "", "", "+"], 27 27 ["OTel Exporter", "", "", "+"], 28 - ["Number of on-demand checks", "30/mo", "100/mo", "300/mo"], 28 + ["Number of on-demand checks", "30/mo.", "100/mo.", "300/mo."], 29 29 [<a href="/status-page"><strong>Status Pages</strong></a>, "", "", ""], 30 30 ["Number of status pages", "1", "1", "5"], 31 31 ["Maintenance status", "+", "+", "+"], 32 32 ["Toggle numbers visibility", "+", "+", "+"], 33 33 ["Subscribers", "", "+", "+"], 34 34 ["Custom domain", "", "+", "+"], 35 - ["Password-protected", "", "+", "+"], 35 + [<strong>Audience</strong>, "", "", ""], 36 + ["Password Protection", "", "+", "+"], 37 + ["Email Authentification", "", "add-on $50/mo.", "add-on $50/mo."], 36 38 [<strong>Alerts</strong>, "", "", ""], 37 39 ["Slack, Discord, Email, Webhook, ntfy.sh", "+", "+", "+"], 38 40 ["WhatsApp", "", "+", "+"], ··· 45 47 }} 46 48 /> 47 49 48 - We provide pricing parity and support **EUR**/**USD**/**INR** as currency. Contact us at [ping@openstatus.dev](mailto:ping@openstatus.dev) or [book a call](https://openstatus.dev/cal) if you have questions. 50 + We provide pricing support for **EUR**/**USD**/**INR** as currency. Contact us at [ping@openstatus.dev](mailto:ping@openstatus.dev) or [book a call](https://openstatus.dev/cal) if you have questions. 49 51 50 52 --- 51 53
-42
apps/web/src/lib/auth/adapter.ts
··· 1 - import { DrizzleAdapter } from "@auth/drizzle-adapter"; 2 - import type { Adapter } from "next-auth/adapters"; 3 - 4 - import { db } from "@openstatus/db"; 5 - import { 6 - account, 7 - session, 8 - user, 9 - verificationToken, 10 - } from "@openstatus/db/src/schema"; 11 - 12 - import { createUser, getUser } from "./helpers"; 13 - 14 - export const adapter: Adapter = { 15 - ...DrizzleAdapter(db, { 16 - // @ts-ignore 17 - usersTable: user, 18 - // @ts-ignore 19 - accountsTable: account, 20 - // @ts-ignore 21 - sessionsTable: session, 22 - // @ts-ignore 23 - verificationTokensTable: verificationToken, 24 - }), 25 - createUser: async (data) => { 26 - const user = await createUser(data); 27 - return { 28 - ...user, 29 - id: user.id.toString(), 30 - email: user.email || "", 31 - }; 32 - }, 33 - getUser: async (id) => { 34 - const user = await getUser(id); 35 - if (!user) return null; 36 - return { 37 - ...user, 38 - id: user.id.toString(), 39 - email: user.email || "", 40 - }; 41 - }, 42 - };
-65
apps/web/src/lib/auth/helpers.ts
··· 1 - import type { AdapterUser } from "next-auth/adapters"; 2 - import * as randomWordSlugs from "random-word-slugs"; 3 - 4 - import { db, eq } from "@openstatus/db"; 5 - import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; 6 - 7 - export async function createUser(data: AdapterUser) { 8 - const { id, ...rest } = data; 9 - 10 - const newUser = await db 11 - .insert(user) 12 - .values({ 13 - email: rest.email, 14 - photoUrl: rest.image, 15 - name: rest.name, 16 - firstName: rest.firstName, 17 - lastName: rest.lastName, 18 - }) 19 - .returning() 20 - .get(); 21 - 22 - let slug: string | undefined = undefined; 23 - 24 - while (!slug) { 25 - slug = randomWordSlugs.generateSlug(2); 26 - const slugAlreadyExists = await db 27 - .select() 28 - .from(workspace) 29 - .where(eq(workspace.slug, slug)) 30 - .get(); 31 - 32 - if (slugAlreadyExists) { 33 - console.log(`slug already exists: '${slug}'`); 34 - slug = undefined; 35 - } 36 - } 37 - 38 - const newWorkspace = await db 39 - .insert(workspace) 40 - .values({ slug, name: "" }) 41 - .returning({ id: workspace.id }) 42 - .get(); 43 - 44 - await db 45 - .insert(usersToWorkspaces) 46 - .values({ 47 - userId: newUser.id, 48 - workspaceId: newWorkspace.id, 49 - role: "owner", 50 - }) 51 - .returning() 52 - .get(); 53 - 54 - return newUser; 55 - } 56 - 57 - export async function getUser(id: string) { 58 - const _user = await db 59 - .select() 60 - .from(user) 61 - .where(eq(user.id, Number(id))) 62 - .get(); 63 - 64 - return _user || null; 65 - }
-124
apps/web/src/lib/auth/index.ts
··· 1 - import type { DefaultSession } from "next-auth"; 2 - import NextAuth from "next-auth"; 3 - 4 - import { Events, setupAnalytics } from "@openstatus/analytics"; 5 - import { db, eq } from "@openstatus/db"; 6 - import { user } from "@openstatus/db/src/schema"; 7 - 8 - import { WelcomeEmail, sendEmail } from "@openstatus/emails"; 9 - import { headers } from "next/headers"; 10 - import { adapter } from "./adapter"; 11 - import { GitHubProvider, GoogleProvider, ResendProvider } from "./providers"; 12 - 13 - export type { DefaultSession }; 14 - 15 - export const { handlers, signIn, signOut, auth } = NextAuth({ 16 - // debug: true, 17 - adapter, 18 - providers: 19 - process.env.NODE_ENV === "development" 20 - ? [GitHubProvider, GoogleProvider, ResendProvider] 21 - : [GitHubProvider, GoogleProvider], 22 - callbacks: { 23 - async signIn(params) { 24 - // We keep updating the user info when we loggin in 25 - 26 - if (params.account?.provider === "google") { 27 - if (!params.profile) return true; 28 - if (Number.isNaN(Number(params.user.id))) return true; 29 - 30 - await db 31 - .update(user) 32 - .set({ 33 - firstName: params.profile.given_name, 34 - lastName: params.profile.family_name || "", 35 - photoUrl: params.profile.picture, 36 - // keep the name in sync 37 - name: `${params.profile.given_name} ${ 38 - params.profile.family_name || "" 39 - }`.trim(), 40 - updatedAt: new Date(), 41 - }) 42 - .where(eq(user.id, Number(params.user.id))) 43 - .run(); 44 - } 45 - if (params.account?.provider === "github") { 46 - if (!params.profile) return true; 47 - if (Number.isNaN(Number(params.user.id))) return true; 48 - 49 - await db 50 - .update(user) 51 - .set({ 52 - name: params.profile.name, 53 - photoUrl: String(params.profile.avatar_url), 54 - updatedAt: new Date(), 55 - }) 56 - .where(eq(user.id, Number(params.user.id))) 57 - .run(); 58 - } 59 - 60 - // REMINDER: only used in dev mode 61 - if (params.account?.provider === "resend") { 62 - if (Number.isNaN(Number(params.user.id))) return true; 63 - await db 64 - .update(user) 65 - .set({ updatedAt: new Date() }) 66 - .where(eq(user.id, Number(params.user.id))) 67 - .run(); 68 - } 69 - 70 - return true; 71 - }, 72 - async session(params) { 73 - return params.session; 74 - }, 75 - }, 76 - events: { 77 - // That should probably done in the callback method instead 78 - async createUser(params) { 79 - if (!params.user.id || !params.user.email) { 80 - throw new Error("User id & email is required"); 81 - } 82 - 83 - // this means the user has already been created with clerk 84 - if (params.user.tenantId) return; 85 - 86 - await sendEmail({ 87 - from: "Thibault from OpenStatus <welcome@openstatus.dev>", 88 - reply_to: "Thibault from OpenStatus <thibault@openstatus.dev>", 89 - subject: "Welcome to OpenStatus.", 90 - to: [params.user.email], 91 - react: WelcomeEmail(), 92 - }); 93 - 94 - const analytics = await setupAnalytics({ 95 - userId: `usr_${params.user.id}`, 96 - email: params.user.email, 97 - location: (await headers()).get("x-forwarded-for") ?? undefined, 98 - userAgent: (await headers()).get("user-agent") ?? undefined, 99 - }); 100 - 101 - await analytics.track(Events.CreateUser); 102 - }, 103 - 104 - async signIn(params) { 105 - if (params.isNewUser) return; 106 - if (!params.user.id || !params.user.email) return; 107 - 108 - const analytics = await setupAnalytics({ 109 - userId: `usr_${params.user.id}`, 110 - email: params.user.email, 111 - location: (await headers()).get("x-forwarded-for") ?? undefined, 112 - userAgent: (await headers()).get("user-agent") ?? undefined, 113 - }); 114 - 115 - await analytics.track(Events.SignInUser); 116 - }, 117 - }, 118 - pages: { 119 - signIn: "/app/login", 120 - newUser: "/app/onboarding", 121 - }, 122 - // basePath: "/api/auth", // default is `/api/auth` 123 - // secret: process.env.AUTH_SECRET, // default is `AUTH_SECRET` 124 - });
-28
apps/web/src/lib/auth/providers.ts
··· 1 - import GitHub from "next-auth/providers/github"; 2 - import Google from "next-auth/providers/google"; 3 - import Resend from "next-auth/providers/resend"; 4 - 5 - export const GitHubProvider = GitHub({ 6 - allowDangerousEmailAccountLinking: true, 7 - }); 8 - 9 - export const GoogleProvider = Google({ 10 - allowDangerousEmailAccountLinking: true, 11 - authorization: { 12 - params: { 13 - // See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest 14 - prompt: "select_account", 15 - // scope: 16 - // "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", 17 - }, 18 - }, 19 - }); 20 - 21 - export const ResendProvider = Resend({ 22 - apiKey: undefined, // REMINDER: keep undefined to avoid sending emails 23 - async sendVerificationRequest(params) { 24 - console.log(""); 25 - console.log(`>>> Magic Link: ${params.url}`); 26 - console.log(""); 27 - }, 28 - });
-5
apps/web/src/next-auth.d.ts
··· 1 - import type { User as DefaultUserSchema } from "@openstatus/db/src/schema"; 2 - 3 - declare module "next-auth" { 4 - interface User extends DefaultUserSchema {} 5 - }
+1 -4
apps/web/src/trpc/rq-server.ts
··· 1 1 import "server-only"; 2 2 3 - import { auth } from "@/lib/auth"; 4 3 import { type AppRouter, appRouter, t } from "@openstatus/api"; 5 4 import type { Context } from "@openstatus/api/src/trpc"; 6 5 import { db } from "@openstatus/db"; ··· 10 9 11 10 const createContextCached = cache( 12 11 async (..._args: unknown[]): Promise<Context> => { 13 - const session = await auth(); 14 - 15 12 return { 16 13 req: undefined, 17 14 db, 18 - session, 15 + session: null, 19 16 }; 20 17 }, 21 18 );
+8
packages/analytics/src/events.ts
··· 68 68 name: "user_subscribe_verified", 69 69 channel: "page", 70 70 }, 71 + ValidateEmailDomain: { 72 + name: "email_domain_validated", 73 + channel: "page", 74 + }, 71 75 CreateReport: { 72 76 name: "report_created", 73 77 channel: "report", ··· 159 163 UpdateWorkspace: { 160 164 name: "workspace_updated", 161 165 channel: "workspace", 166 + }, 167 + AddFeature: { 168 + name: "feature_added", 169 + channel: "billing", 162 170 }, 163 171 UpgradeWorkspace: { 164 172 name: "workspace_upgraded",
+23 -4
packages/api/src/router/page.ts
··· 21 21 monitorGroup, 22 22 monitorsToPages, 23 23 page, 24 + pageAccessTypes, 24 25 selectMaintenanceSchema, 25 26 selectMonitorGroupSchema, 26 27 selectMonitorSchema, ··· 112 113 workspaceId: opts.ctx.workspace.id, 113 114 configuration: JSON.stringify(configuration), 114 115 ...pageProps, 116 + authEmailDomains: pageProps.authEmailDomains?.join(","), 115 117 }) 116 118 .returning() 117 119 .get(); ··· 187 189 188 190 const currentPage = await opts.ctx.db 189 191 .update(page) 190 - .set({ ...pageInput, updatedAt: new Date() }) 192 + .set({ 193 + ...pageInput, 194 + updatedAt: new Date(), 195 + authEmailDomains: pageInput.authEmailDomains?.join(","), 196 + }) 191 197 .where( 192 198 and( 193 199 eq(page.id, pageInput.id), ··· 732 738 .input( 733 739 z.object({ 734 740 id: z.number(), 735 - passwordProtected: z.boolean(), 741 + accessType: z.enum(pageAccessTypes), 742 + authEmailDomains: z.array(z.string()).nullish(), 736 743 password: z.string().nullish(), 737 744 }), 738 745 ) ··· 747 754 // the user is not eligible for password protection 748 755 if ( 749 756 limit["password-protection"] === false && 750 - opts.input.passwordProtected === true 757 + opts.input.accessType === "password" 751 758 ) { 752 759 throw new TRPCError({ 753 760 code: "FORBIDDEN", ··· 756 763 }); 757 764 } 758 765 766 + if ( 767 + limit["email-domain-protection"] === false && 768 + opts.input.accessType === "email-domain" 769 + ) { 770 + throw new TRPCError({ 771 + code: "FORBIDDEN", 772 + message: 773 + "Email domain protection is not available for your current plan.", 774 + }); 775 + } 776 + 759 777 await opts.ctx.db 760 778 .update(page) 761 779 .set({ 762 - passwordProtected: opts.input.passwordProtected, 780 + accessType: opts.input.accessType, 781 + authEmailDomains: opts.input.authEmailDomains?.join(","), 763 782 password: opts.input.password, 764 783 updatedAt: new Date(), 765 784 })
+48
packages/api/src/router/statusPage.ts
··· 898 898 return _pageSubscriber.id; 899 899 }), 900 900 901 + validateEmailDomain: publicProcedure 902 + .meta({ track: Events.ValidateEmailDomain, trackProps: ["slug", "email"] }) 903 + .input(z.object({ slug: z.string().toLowerCase(), email: z.string() })) 904 + .query(async (opts) => { 905 + if (!opts.input.slug) return null; 906 + 907 + const _page = await opts.ctx.db.query.page.findFirst({ 908 + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 909 + }); 910 + 911 + if (!_page) { 912 + throw new TRPCError({ 913 + code: "NOT_FOUND", 914 + message: "Page not found", 915 + }); 916 + } 917 + 918 + if (_page.accessType !== "email-domain") { 919 + throw new TRPCError({ 920 + code: "BAD_REQUEST", 921 + message: 922 + "Page is not configured to allow email domain authentication", 923 + }); 924 + } 925 + 926 + const allowedDomains = _page.authEmailDomains?.split(",") ?? []; 927 + 928 + if (!allowedDomains.includes(opts.input.email.split("@")[1])) { 929 + throw new TRPCError({ 930 + code: "BAD_REQUEST", 931 + message: "Invalid email domain", 932 + }); 933 + } 934 + 935 + return { 936 + email: opts.input.email, 937 + slug: opts.input.slug, 938 + page: _page, 939 + }; 940 + }), 941 + 901 942 verifyEmail: publicProcedure 902 943 .meta({ track: Events.VerifySubscribePage, trackProps: ["slug"] }) 903 944 .input(z.object({ slug: z.string().toLowerCase(), token: z.string() })) ··· 955 996 throw new TRPCError({ 956 997 code: "NOT_FOUND", 957 998 message: "Page not found", 999 + }); 1000 + } 1001 + 1002 + if (_page.accessType !== "password") { 1003 + throw new TRPCError({ 1004 + code: "BAD_REQUEST", 1005 + message: "Page is not configured to allow password authentication", 958 1006 }); 959 1007 } 960 1008
+97 -1
packages/api/src/router/stripe/index.ts
··· 2 2 3 3 import { eq } from "@openstatus/db"; 4 4 import { 5 + selectWorkspaceSchema, 5 6 user, 6 7 usersToWorkspaces, 7 8 workspace, 8 9 workspacePlans, 9 10 } from "@openstatus/db/src/schema"; 10 11 12 + import { updateAddonInLimits } from "@openstatus/db/src/schema/plan/utils"; 13 + import { TRPCError } from "@trpc/server"; 14 + import type { Stripe } from "stripe"; 11 15 import { createTRPCRouter, protectedProcedure } from "../../trpc"; 12 16 import { stripe } from "./shared"; 13 - import { getPriceIdForPlan } from "./utils"; 17 + import { getPriceIdForFeature, getPriceIdForPlan } from "./utils"; 14 18 import { webhookRouter } from "./webhook"; 15 19 16 20 const url = ··· 164 168 }); 165 169 166 170 return session; 171 + }), 172 + 173 + addAddon: protectedProcedure 174 + .input( 175 + z.object({ 176 + workspaceSlug: z.string(), 177 + feature: z.enum(["email-domain-protection"]), 178 + successUrl: z.string().optional(), 179 + cancelUrl: z.string().optional(), 180 + remove: z.boolean().optional(), 181 + }), 182 + ) 183 + .mutation(async (opts) => { 184 + // The following code is duplicated we should extract it 185 + const result = await opts.ctx.db 186 + .select() 187 + .from(workspace) 188 + .where(eq(workspace.slug, opts.input.workspaceSlug)) 189 + .get(); 190 + 191 + if (!result) return; 192 + 193 + const ws = selectWorkspaceSchema.parse(result); 194 + 195 + const currentUser = opts.ctx.db 196 + .select() 197 + .from(user) 198 + .where(eq(user.id, opts.ctx.user.id)) 199 + .as("currentUser"); 200 + const userHasAccess = await opts.ctx.db 201 + .select() 202 + .from(usersToWorkspaces) 203 + .where(eq(usersToWorkspaces.workspaceId, result.id)) 204 + .innerJoin(currentUser, eq(usersToWorkspaces.userId, currentUser.id)) 205 + .get(); 206 + 207 + if (!userHasAccess || !userHasAccess.users_to_workspaces) return; 208 + const stripeId = result.stripeId; 209 + if (!stripeId) { 210 + throw new TRPCError({ 211 + code: "BAD_REQUEST", 212 + message: "Workspace has no Stripe ID", 213 + }); 214 + } 215 + 216 + const sub = (await stripe.customers.retrieve(stripeId, { 217 + expand: ["subscriptions"], 218 + })) as Stripe.Customer; 219 + 220 + if (!sub) { 221 + return; 222 + } 223 + 224 + if (!sub.subscriptions?.data[0]?.id) { 225 + return; 226 + } 227 + 228 + const priceId = getPriceIdForFeature(opts.input.feature); 229 + 230 + if (opts.input.remove) { 231 + const items = await stripe.subscriptionItems.list({ 232 + subscription: sub.subscriptions?.data[0]?.id, 233 + }); 234 + const item = items.data.find((item) => item.price.id === priceId); 235 + if (item) { 236 + await stripe.subscriptionItems.del(item.id); 237 + } 238 + } else { 239 + await stripe.subscriptionItems.create({ 240 + price: priceId, 241 + subscription: sub.subscriptions?.data[0]?.id, 242 + quantity: 1, 243 + }); 244 + } 245 + 246 + // NOTE: update the limits based on the feature type 247 + 248 + const newLimits = updateAddonInLimits( 249 + ws.limits, 250 + opts.input.feature, 251 + opts.input.remove ? "remove" : "add", 252 + ); 253 + 254 + await opts.ctx.db 255 + .update(workspace) 256 + .set({ limits: JSON.stringify(newLimits) }) 257 + .where(eq(workspace.id, result.id)) 258 + .run(); 259 + 260 + // TODO: send email to user notifying about the change if not already from stripe 261 + 262 + return; 167 263 }), 168 264 });
+39
packages/api/src/router/stripe/utils.ts
··· 1 1 // Shamelessly stolen from dub.co 2 2 3 3 import type { WorkspacePlan } from "@openstatus/db/src/schema"; 4 + import type { Addons } from "@openstatus/db/src/schema/plan/schema"; 4 5 5 6 export const getPlanFromPriceId = (priceId: string) => { 6 7 const env = ··· 8 9 return PLANS.find((plan) => plan.price.monthly.priceIds[env] === priceId); 9 10 }; 10 11 12 + export const getFeatureFromPriceId = (priceId: string) => { 13 + const env = 14 + process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "production" : "test"; 15 + return FEATURES.find( 16 + (feature) => feature.price.monthly.priceIds[env] === priceId, 17 + ); 18 + }; 19 + 11 20 export const getPriceIdForPlan = (plan: WorkspacePlan) => { 12 21 const env = 13 22 process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "production" : "test"; 14 23 return PLANS.find((p) => p.plan === plan)?.price.monthly.priceIds[env]; 15 24 }; 25 + 26 + export const getPriceIdForFeature = ( 27 + feature: "email-domain-protection" | "status-pages-whitelabel", 28 + ) => { 29 + const env = 30 + process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "production" : "test"; 31 + return FEATURES.find((f) => f.feature === feature)?.price.monthly.priceIds[ 32 + env 33 + ]; 34 + }; 35 + 16 36 export const PLANS = [ 17 37 { 18 38 plan: "team", ··· 42 62 monthly: { priceIds: { test: string; production: string } }; 43 63 }; 44 64 }>; 65 + 66 + export const FEATURES = [ 67 + { 68 + feature: "email-domain-protection", 69 + price: { 70 + monthly: { 71 + priceIds: { 72 + test: "price_1Sl4xqBXJcTfzsyJlzpD1DDm", 73 + production: "price_1Sl6oqBXJcTfzsyJCxtzDIx5", 74 + }, 75 + }, 76 + }, 77 + }, 78 + ] satisfies Array<{ 79 + feature: keyof Addons; 80 + price: { 81 + monthly: { priceIds: { test: string; production: string } }; 82 + }; 83 + }>;
+132 -34
packages/api/src/router/stripe/webhook.ts
··· 4 4 5 5 import { Events, setupAnalytics } from "@openstatus/analytics"; 6 6 import { eq } from "@openstatus/db"; 7 - import { user, workspace } from "@openstatus/db/src/schema"; 7 + import { 8 + selectWorkspaceSchema, 9 + user, 10 + workspace, 11 + } from "@openstatus/db/src/schema"; 8 12 9 - import { getLimits } from "@openstatus/db/src/schema/plan/utils"; 13 + import { 14 + getLimits, 15 + updateAddonInLimits, 16 + } from "@openstatus/db/src/schema/plan/utils"; 10 17 import { createTRPCRouter, publicProcedure } from "../../trpc"; 11 18 import { stripe } from "./shared"; 12 - import { getPlanFromPriceId } from "./utils"; 19 + import { getFeatureFromPriceId, getPlanFromPriceId } from "./utils"; 13 20 14 21 const webhookProcedure = publicProcedure.input( 15 22 z.object({ ··· 27 34 ); 28 35 29 36 export const webhookRouter = createTRPCRouter({ 30 - sessionCompleted: webhookProcedure.mutation(async (opts) => { 31 - const session = opts.input.event.data.object as Stripe.Checkout.Session; 32 - if (typeof session.subscription !== "string") { 33 - throw new TRPCError({ 34 - code: "BAD_REQUEST", 35 - message: "Missing or invalid subscription id", 36 - }); 37 - } 38 - const subscription = await stripe.subscriptions.retrieve( 39 - session.subscription, 40 - ); 37 + customerSubscriptionUpdated: webhookProcedure.mutation(async (opts) => { 38 + const subscription = opts.input.event.data.object as Stripe.Subscription; 39 + 41 40 const customerId = 42 41 typeof subscription.customer === "string" 43 42 ? subscription.customer 44 43 : subscription.customer.id; 44 + 45 45 const result = await opts.ctx.db 46 46 .select() 47 47 .from(workspace) ··· 53 53 message: "Workspace not found", 54 54 }); 55 55 } 56 - const plan = getPlanFromPriceId(subscription.items.data[0].price.id); 57 - if (!plan) { 58 - console.error("Invalid plan"); 59 - throw new TRPCError({ 60 - code: "BAD_REQUEST", 61 - message: "Invalid plan", 62 - }); 56 + 57 + for (const item of subscription.items.data) { 58 + console.log(item); 59 + const feature = getFeatureFromPriceId(item.price.id); 60 + if (!feature) { 61 + continue; 62 + } 63 + const _ws = await opts.ctx.db 64 + .select() 65 + .from(workspace) 66 + .where(eq(workspace.stripeId, customerId)) 67 + .get(); 68 + 69 + const ws = selectWorkspaceSchema.parse(_ws); 70 + 71 + const newLimits = updateAddonInLimits(ws.limits, feature.feature, "add"); 72 + 73 + await opts.ctx.db 74 + .update(workspace) 75 + .set({ 76 + limits: JSON.stringify(newLimits), 77 + }) 78 + .where(eq(workspace.id, result.id)) 79 + .run(); 63 80 } 64 - await opts.ctx.db 65 - .update(workspace) 66 - .set({ 67 - plan: plan.plan, 68 - subscriptionId: subscription.id, 69 - endsAt: new Date(subscription.current_period_end * 1000), 70 - paidUntil: new Date(subscription.current_period_end * 1000), 71 - limits: JSON.stringify(getLimits(plan.plan)), 72 - }) 73 - .where(eq(workspace.id, result.id)) 74 - .run(); 81 + 75 82 const customer = await stripe.customers.retrieve(customerId); 76 83 if (!customer.deleted && customer.email) { 77 84 const userResult = await opts.ctx.db ··· 85 92 userId: `usr_${userResult.id}`, 86 93 email: userResult.email || undefined, 87 94 workspaceId: String(result.id), 88 - plan: plan.plan, 89 95 }); 90 - await analytics.track(Events.UpgradeWorkspace); 96 + await analytics.track(Events.AddFeature); 97 + } 98 + }), 99 + sessionCompleted: webhookProcedure.mutation(async (opts) => { 100 + const session = opts.input.event.data.object as Stripe.Checkout.Session; 101 + if (typeof session.subscription !== "string") { 102 + throw new TRPCError({ 103 + code: "BAD_REQUEST", 104 + message: "Missing or invalid subscription id", 105 + }); 106 + } 107 + const subscription = await stripe.subscriptions.retrieve( 108 + session.subscription, 109 + ); 110 + const customerId = 111 + typeof subscription.customer === "string" 112 + ? subscription.customer 113 + : subscription.customer.id; 114 + 115 + const result = await opts.ctx.db 116 + .select() 117 + .from(workspace) 118 + .where(eq(workspace.stripeId, customerId)) 119 + .get(); 120 + if (!result) { 121 + throw new TRPCError({ 122 + code: "BAD_REQUEST", 123 + message: "Workspace not found", 124 + }); 125 + } 126 + 127 + for (const item of subscription.items.data) { 128 + const plan = getPlanFromPriceId(item.price.id); 129 + if (!plan) { 130 + const feature = getFeatureFromPriceId(item.price.id); 131 + if (feature) { 132 + const _ws = await opts.ctx.db 133 + .select() 134 + .from(workspace) 135 + .where(eq(workspace.stripeId, customerId)) 136 + .get(); 137 + 138 + const ws = selectWorkspaceSchema.parse(_ws); 139 + 140 + const newLimits = updateAddonInLimits( 141 + ws.limits, 142 + feature.feature, 143 + "add", 144 + ); 145 + 146 + await opts.ctx.db 147 + .update(workspace) 148 + .set({ 149 + limits: JSON.stringify(newLimits), 150 + }) 151 + .where(eq(workspace.id, result.id)) 152 + .run(); 153 + continue; 154 + } 155 + console.error("Invalid plan"); 156 + throw new TRPCError({ 157 + code: "BAD_REQUEST", 158 + message: "Invalid plan", 159 + }); 160 + } 161 + await opts.ctx.db 162 + .update(workspace) 163 + .set({ 164 + plan: plan.plan, 165 + subscriptionId: subscription.id, 166 + endsAt: new Date(subscription.current_period_end * 1000), 167 + paidUntil: new Date(subscription.current_period_end * 1000), 168 + limits: JSON.stringify(getLimits(plan.plan)), 169 + }) 170 + .where(eq(workspace.id, result.id)) 171 + .run(); 172 + const customer = await stripe.customers.retrieve(customerId); 173 + if (!customer.deleted && customer.email) { 174 + const userResult = await opts.ctx.db 175 + .select() 176 + .from(user) 177 + .where(eq(user.email, customer.email)) 178 + .get(); 179 + if (!userResult) return; 180 + 181 + const analytics = await setupAnalytics({ 182 + userId: `usr_${userResult.id}`, 183 + email: userResult.email || undefined, 184 + workspaceId: String(result.id), 185 + plan: plan.plan, 186 + }); 187 + await analytics.track(Events.UpgradeWorkspace); 188 + } 91 189 } 92 190 }), 93 191 customerSubscriptionDeleted: webhookProcedure.mutation(async (opts) => {
+10 -6
packages/api/src/trpc.ts
··· 12 12 import { db, eq, schema } from "@openstatus/db"; 13 13 import type { User, Workspace } from "@openstatus/db/src/schema"; 14 14 15 - // TODO: create a package for this 16 - import { 17 - type DefaultSession as Session, 18 - auth, 19 - } from "../../../apps/web/src/lib/auth"; 15 + // Generic session type that works with both User and Viewer 16 + type Session = { 17 + user?: { 18 + id?: string | null; 19 + email?: string | null; 20 + } | null; 21 + } | null; 20 22 21 23 /** 22 24 * 1. CONTEXT ··· 67 69 export const createTRPCContext = async (opts: { 68 70 req: NextRequest; 69 71 serverSideCall?: boolean; 72 + auth?: () => Promise<Session>; 70 73 }) => { 71 - const session = await auth(); 74 + // Use provided auth function or return null session 75 + const session = opts.auth ? await opts.auth() : null; 72 76 const workspace = null; 73 77 const user = null; 74 78
+38
packages/db/drizzle/0051_fuzzy_red_hulk.sql
··· 1 + CREATE TABLE `viewer` ( 2 + `id` integer PRIMARY KEY NOT NULL, 3 + `name` text, 4 + `email` text, 5 + `emailVerified` integer, 6 + `image` text, 7 + `created_at` integer DEFAULT (strftime('%s', 'now')), 8 + `updated_at` integer DEFAULT (strftime('%s', 'now')) 9 + ); 10 + --> statement-breakpoint 11 + CREATE UNIQUE INDEX `viewer_email_unique` ON `viewer` (`email`);--> statement-breakpoint 12 + CREATE TABLE `viewer_accounts` ( 13 + `user_id` text NOT NULL, 14 + `type` text NOT NULL, 15 + `provider` text NOT NULL, 16 + `providerAccountId` text NOT NULL, 17 + `refresh_token` text, 18 + `access_token` text, 19 + `expires_at` integer, 20 + `token_type` text, 21 + `scope` text, 22 + `id_token` text, 23 + `session_state` text, 24 + PRIMARY KEY(`provider`, `providerAccountId`), 25 + FOREIGN KEY (`user_id`) REFERENCES `viewer`(`id`) ON UPDATE no action ON DELETE cascade 26 + ); 27 + --> statement-breakpoint 28 + CREATE TABLE `viewer_session` ( 29 + `session_token` text PRIMARY KEY NOT NULL, 30 + `user_id` integer NOT NULL, 31 + `expires` integer NOT NULL, 32 + FOREIGN KEY (`user_id`) REFERENCES `viewer`(`id`) ON UPDATE no action ON DELETE cascade 33 + ); 34 + --> statement-breakpoint 35 + ALTER TABLE `page` ADD `access_type` text DEFAULT 'public';--> statement-breakpoint 36 + ALTER TABLE `page` ADD `auth_email_domains` text;--> statement-breakpoint 37 + -- NOTE: manual migration to set the access type based on the password column 38 + UPDATE `page` SET `access_type` = 'password' WHERE `password` IS NOT NULL AND `password` != '' AND `password_protected` = 1;
+2941
packages/db/drizzle/meta/0051_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "e8d9889c-8d28-4311-83e6-9fd28c840595", 5 + "prevId": "d7863fda-51fb-4d7c-b9e6-fd1943ad5d71", 6 + "tables": { 7 + "workspace": { 8 + "name": "workspace", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "integer", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "slug": { 18 + "name": "slug", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "name": { 25 + "name": "name", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": false, 29 + "autoincrement": false 30 + }, 31 + "stripe_id": { 32 + "name": "stripe_id", 33 + "type": "text(256)", 34 + "primaryKey": false, 35 + "notNull": false, 36 + "autoincrement": false 37 + }, 38 + "subscription_id": { 39 + "name": "subscription_id", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "plan": { 46 + "name": "plan", 47 + "type": "text", 48 + "primaryKey": false, 49 + "notNull": false, 50 + "autoincrement": false 51 + }, 52 + "ends_at": { 53 + "name": "ends_at", 54 + "type": "integer", 55 + "primaryKey": false, 56 + "notNull": false, 57 + "autoincrement": false 58 + }, 59 + "paid_until": { 60 + "name": "paid_until", 61 + "type": "integer", 62 + "primaryKey": false, 63 + "notNull": false, 64 + "autoincrement": false 65 + }, 66 + "limits": { 67 + "name": "limits", 68 + "type": "text", 69 + "primaryKey": false, 70 + "notNull": true, 71 + "autoincrement": false, 72 + "default": "'{}'" 73 + }, 74 + "created_at": { 75 + "name": "created_at", 76 + "type": "integer", 77 + "primaryKey": false, 78 + "notNull": false, 79 + "autoincrement": false, 80 + "default": "(strftime('%s', 'now'))" 81 + }, 82 + "updated_at": { 83 + "name": "updated_at", 84 + "type": "integer", 85 + "primaryKey": false, 86 + "notNull": false, 87 + "autoincrement": false, 88 + "default": "(strftime('%s', 'now'))" 89 + }, 90 + "dsn": { 91 + "name": "dsn", 92 + "type": "text", 93 + "primaryKey": false, 94 + "notNull": false, 95 + "autoincrement": false 96 + } 97 + }, 98 + "indexes": { 99 + "workspace_slug_unique": { 100 + "name": "workspace_slug_unique", 101 + "columns": [ 102 + "slug" 103 + ], 104 + "isUnique": true 105 + }, 106 + "workspace_stripe_id_unique": { 107 + "name": "workspace_stripe_id_unique", 108 + "columns": [ 109 + "stripe_id" 110 + ], 111 + "isUnique": true 112 + }, 113 + "workspace_id_dsn_unique": { 114 + "name": "workspace_id_dsn_unique", 115 + "columns": [ 116 + "id", 117 + "dsn" 118 + ], 119 + "isUnique": true 120 + } 121 + }, 122 + "foreignKeys": {}, 123 + "compositePrimaryKeys": {}, 124 + "uniqueConstraints": {}, 125 + "checkConstraints": {} 126 + }, 127 + "account": { 128 + "name": "account", 129 + "columns": { 130 + "user_id": { 131 + "name": "user_id", 132 + "type": "integer", 133 + "primaryKey": false, 134 + "notNull": true, 135 + "autoincrement": false 136 + }, 137 + "type": { 138 + "name": "type", 139 + "type": "text", 140 + "primaryKey": false, 141 + "notNull": true, 142 + "autoincrement": false 143 + }, 144 + "provider": { 145 + "name": "provider", 146 + "type": "text", 147 + "primaryKey": false, 148 + "notNull": true, 149 + "autoincrement": false 150 + }, 151 + "provider_account_id": { 152 + "name": "provider_account_id", 153 + "type": "text", 154 + "primaryKey": false, 155 + "notNull": true, 156 + "autoincrement": false 157 + }, 158 + "refresh_token": { 159 + "name": "refresh_token", 160 + "type": "text", 161 + "primaryKey": false, 162 + "notNull": false, 163 + "autoincrement": false 164 + }, 165 + "access_token": { 166 + "name": "access_token", 167 + "type": "text", 168 + "primaryKey": false, 169 + "notNull": false, 170 + "autoincrement": false 171 + }, 172 + "expires_at": { 173 + "name": "expires_at", 174 + "type": "integer", 175 + "primaryKey": false, 176 + "notNull": false, 177 + "autoincrement": false 178 + }, 179 + "token_type": { 180 + "name": "token_type", 181 + "type": "text", 182 + "primaryKey": false, 183 + "notNull": false, 184 + "autoincrement": false 185 + }, 186 + "scope": { 187 + "name": "scope", 188 + "type": "text", 189 + "primaryKey": false, 190 + "notNull": false, 191 + "autoincrement": false 192 + }, 193 + "id_token": { 194 + "name": "id_token", 195 + "type": "text", 196 + "primaryKey": false, 197 + "notNull": false, 198 + "autoincrement": false 199 + }, 200 + "session_state": { 201 + "name": "session_state", 202 + "type": "text", 203 + "primaryKey": false, 204 + "notNull": false, 205 + "autoincrement": false 206 + } 207 + }, 208 + "indexes": {}, 209 + "foreignKeys": { 210 + "account_user_id_user_id_fk": { 211 + "name": "account_user_id_user_id_fk", 212 + "tableFrom": "account", 213 + "tableTo": "user", 214 + "columnsFrom": [ 215 + "user_id" 216 + ], 217 + "columnsTo": [ 218 + "id" 219 + ], 220 + "onDelete": "cascade", 221 + "onUpdate": "no action" 222 + } 223 + }, 224 + "compositePrimaryKeys": { 225 + "account_provider_provider_account_id_pk": { 226 + "columns": [ 227 + "provider", 228 + "provider_account_id" 229 + ], 230 + "name": "account_provider_provider_account_id_pk" 231 + } 232 + }, 233 + "uniqueConstraints": {}, 234 + "checkConstraints": {} 235 + }, 236 + "session": { 237 + "name": "session", 238 + "columns": { 239 + "session_token": { 240 + "name": "session_token", 241 + "type": "text", 242 + "primaryKey": true, 243 + "notNull": true, 244 + "autoincrement": false 245 + }, 246 + "user_id": { 247 + "name": "user_id", 248 + "type": "integer", 249 + "primaryKey": false, 250 + "notNull": true, 251 + "autoincrement": false 252 + }, 253 + "expires": { 254 + "name": "expires", 255 + "type": "integer", 256 + "primaryKey": false, 257 + "notNull": true, 258 + "autoincrement": false 259 + } 260 + }, 261 + "indexes": {}, 262 + "foreignKeys": { 263 + "session_user_id_user_id_fk": { 264 + "name": "session_user_id_user_id_fk", 265 + "tableFrom": "session", 266 + "tableTo": "user", 267 + "columnsFrom": [ 268 + "user_id" 269 + ], 270 + "columnsTo": [ 271 + "id" 272 + ], 273 + "onDelete": "cascade", 274 + "onUpdate": "no action" 275 + } 276 + }, 277 + "compositePrimaryKeys": {}, 278 + "uniqueConstraints": {}, 279 + "checkConstraints": {} 280 + }, 281 + "user": { 282 + "name": "user", 283 + "columns": { 284 + "id": { 285 + "name": "id", 286 + "type": "integer", 287 + "primaryKey": true, 288 + "notNull": true, 289 + "autoincrement": false 290 + }, 291 + "tenant_id": { 292 + "name": "tenant_id", 293 + "type": "text(256)", 294 + "primaryKey": false, 295 + "notNull": false, 296 + "autoincrement": false 297 + }, 298 + "first_name": { 299 + "name": "first_name", 300 + "type": "text", 301 + "primaryKey": false, 302 + "notNull": false, 303 + "autoincrement": false, 304 + "default": "''" 305 + }, 306 + "last_name": { 307 + "name": "last_name", 308 + "type": "text", 309 + "primaryKey": false, 310 + "notNull": false, 311 + "autoincrement": false, 312 + "default": "''" 313 + }, 314 + "photo_url": { 315 + "name": "photo_url", 316 + "type": "text", 317 + "primaryKey": false, 318 + "notNull": false, 319 + "autoincrement": false, 320 + "default": "''" 321 + }, 322 + "name": { 323 + "name": "name", 324 + "type": "text", 325 + "primaryKey": false, 326 + "notNull": false, 327 + "autoincrement": false 328 + }, 329 + "email": { 330 + "name": "email", 331 + "type": "text", 332 + "primaryKey": false, 333 + "notNull": false, 334 + "autoincrement": false, 335 + "default": "''" 336 + }, 337 + "emailVerified": { 338 + "name": "emailVerified", 339 + "type": "integer", 340 + "primaryKey": false, 341 + "notNull": false, 342 + "autoincrement": false 343 + }, 344 + "created_at": { 345 + "name": "created_at", 346 + "type": "integer", 347 + "primaryKey": false, 348 + "notNull": false, 349 + "autoincrement": false, 350 + "default": "(strftime('%s', 'now'))" 351 + }, 352 + "updated_at": { 353 + "name": "updated_at", 354 + "type": "integer", 355 + "primaryKey": false, 356 + "notNull": false, 357 + "autoincrement": false, 358 + "default": "(strftime('%s', 'now'))" 359 + } 360 + }, 361 + "indexes": { 362 + "user_tenant_id_unique": { 363 + "name": "user_tenant_id_unique", 364 + "columns": [ 365 + "tenant_id" 366 + ], 367 + "isUnique": true 368 + } 369 + }, 370 + "foreignKeys": {}, 371 + "compositePrimaryKeys": {}, 372 + "uniqueConstraints": {}, 373 + "checkConstraints": {} 374 + }, 375 + "users_to_workspaces": { 376 + "name": "users_to_workspaces", 377 + "columns": { 378 + "user_id": { 379 + "name": "user_id", 380 + "type": "integer", 381 + "primaryKey": false, 382 + "notNull": true, 383 + "autoincrement": false 384 + }, 385 + "workspace_id": { 386 + "name": "workspace_id", 387 + "type": "integer", 388 + "primaryKey": false, 389 + "notNull": true, 390 + "autoincrement": false 391 + }, 392 + "role": { 393 + "name": "role", 394 + "type": "text", 395 + "primaryKey": false, 396 + "notNull": true, 397 + "autoincrement": false, 398 + "default": "'member'" 399 + }, 400 + "created_at": { 401 + "name": "created_at", 402 + "type": "integer", 403 + "primaryKey": false, 404 + "notNull": false, 405 + "autoincrement": false, 406 + "default": "(strftime('%s', 'now'))" 407 + } 408 + }, 409 + "indexes": {}, 410 + "foreignKeys": { 411 + "users_to_workspaces_user_id_user_id_fk": { 412 + "name": "users_to_workspaces_user_id_user_id_fk", 413 + "tableFrom": "users_to_workspaces", 414 + "tableTo": "user", 415 + "columnsFrom": [ 416 + "user_id" 417 + ], 418 + "columnsTo": [ 419 + "id" 420 + ], 421 + "onDelete": "no action", 422 + "onUpdate": "no action" 423 + }, 424 + "users_to_workspaces_workspace_id_workspace_id_fk": { 425 + "name": "users_to_workspaces_workspace_id_workspace_id_fk", 426 + "tableFrom": "users_to_workspaces", 427 + "tableTo": "workspace", 428 + "columnsFrom": [ 429 + "workspace_id" 430 + ], 431 + "columnsTo": [ 432 + "id" 433 + ], 434 + "onDelete": "no action", 435 + "onUpdate": "no action" 436 + } 437 + }, 438 + "compositePrimaryKeys": { 439 + "users_to_workspaces_user_id_workspace_id_pk": { 440 + "columns": [ 441 + "user_id", 442 + "workspace_id" 443 + ], 444 + "name": "users_to_workspaces_user_id_workspace_id_pk" 445 + } 446 + }, 447 + "uniqueConstraints": {}, 448 + "checkConstraints": {} 449 + }, 450 + "verification_token": { 451 + "name": "verification_token", 452 + "columns": { 453 + "identifier": { 454 + "name": "identifier", 455 + "type": "text", 456 + "primaryKey": false, 457 + "notNull": true, 458 + "autoincrement": false 459 + }, 460 + "token": { 461 + "name": "token", 462 + "type": "text", 463 + "primaryKey": false, 464 + "notNull": true, 465 + "autoincrement": false 466 + }, 467 + "expires": { 468 + "name": "expires", 469 + "type": "integer", 470 + "primaryKey": false, 471 + "notNull": true, 472 + "autoincrement": false 473 + } 474 + }, 475 + "indexes": {}, 476 + "foreignKeys": {}, 477 + "compositePrimaryKeys": { 478 + "verification_token_identifier_token_pk": { 479 + "columns": [ 480 + "identifier", 481 + "token" 482 + ], 483 + "name": "verification_token_identifier_token_pk" 484 + } 485 + }, 486 + "uniqueConstraints": {}, 487 + "checkConstraints": {} 488 + }, 489 + "status_report_to_monitors": { 490 + "name": "status_report_to_monitors", 491 + "columns": { 492 + "monitor_id": { 493 + "name": "monitor_id", 494 + "type": "integer", 495 + "primaryKey": false, 496 + "notNull": true, 497 + "autoincrement": false 498 + }, 499 + "status_report_id": { 500 + "name": "status_report_id", 501 + "type": "integer", 502 + "primaryKey": false, 503 + "notNull": true, 504 + "autoincrement": false 505 + }, 506 + "created_at": { 507 + "name": "created_at", 508 + "type": "integer", 509 + "primaryKey": false, 510 + "notNull": false, 511 + "autoincrement": false, 512 + "default": "(strftime('%s', 'now'))" 513 + } 514 + }, 515 + "indexes": {}, 516 + "foreignKeys": { 517 + "status_report_to_monitors_monitor_id_monitor_id_fk": { 518 + "name": "status_report_to_monitors_monitor_id_monitor_id_fk", 519 + "tableFrom": "status_report_to_monitors", 520 + "tableTo": "monitor", 521 + "columnsFrom": [ 522 + "monitor_id" 523 + ], 524 + "columnsTo": [ 525 + "id" 526 + ], 527 + "onDelete": "cascade", 528 + "onUpdate": "no action" 529 + }, 530 + "status_report_to_monitors_status_report_id_status_report_id_fk": { 531 + "name": "status_report_to_monitors_status_report_id_status_report_id_fk", 532 + "tableFrom": "status_report_to_monitors", 533 + "tableTo": "status_report", 534 + "columnsFrom": [ 535 + "status_report_id" 536 + ], 537 + "columnsTo": [ 538 + "id" 539 + ], 540 + "onDelete": "cascade", 541 + "onUpdate": "no action" 542 + } 543 + }, 544 + "compositePrimaryKeys": { 545 + "status_report_to_monitors_monitor_id_status_report_id_pk": { 546 + "columns": [ 547 + "monitor_id", 548 + "status_report_id" 549 + ], 550 + "name": "status_report_to_monitors_monitor_id_status_report_id_pk" 551 + } 552 + }, 553 + "uniqueConstraints": {}, 554 + "checkConstraints": {} 555 + }, 556 + "status_report": { 557 + "name": "status_report", 558 + "columns": { 559 + "id": { 560 + "name": "id", 561 + "type": "integer", 562 + "primaryKey": true, 563 + "notNull": true, 564 + "autoincrement": false 565 + }, 566 + "status": { 567 + "name": "status", 568 + "type": "text", 569 + "primaryKey": false, 570 + "notNull": true, 571 + "autoincrement": false 572 + }, 573 + "title": { 574 + "name": "title", 575 + "type": "text(256)", 576 + "primaryKey": false, 577 + "notNull": true, 578 + "autoincrement": false 579 + }, 580 + "workspace_id": { 581 + "name": "workspace_id", 582 + "type": "integer", 583 + "primaryKey": false, 584 + "notNull": false, 585 + "autoincrement": false 586 + }, 587 + "page_id": { 588 + "name": "page_id", 589 + "type": "integer", 590 + "primaryKey": false, 591 + "notNull": false, 592 + "autoincrement": false 593 + }, 594 + "created_at": { 595 + "name": "created_at", 596 + "type": "integer", 597 + "primaryKey": false, 598 + "notNull": false, 599 + "autoincrement": false, 600 + "default": "(strftime('%s', 'now'))" 601 + }, 602 + "updated_at": { 603 + "name": "updated_at", 604 + "type": "integer", 605 + "primaryKey": false, 606 + "notNull": false, 607 + "autoincrement": false, 608 + "default": "(strftime('%s', 'now'))" 609 + } 610 + }, 611 + "indexes": {}, 612 + "foreignKeys": { 613 + "status_report_workspace_id_workspace_id_fk": { 614 + "name": "status_report_workspace_id_workspace_id_fk", 615 + "tableFrom": "status_report", 616 + "tableTo": "workspace", 617 + "columnsFrom": [ 618 + "workspace_id" 619 + ], 620 + "columnsTo": [ 621 + "id" 622 + ], 623 + "onDelete": "no action", 624 + "onUpdate": "no action" 625 + }, 626 + "status_report_page_id_page_id_fk": { 627 + "name": "status_report_page_id_page_id_fk", 628 + "tableFrom": "status_report", 629 + "tableTo": "page", 630 + "columnsFrom": [ 631 + "page_id" 632 + ], 633 + "columnsTo": [ 634 + "id" 635 + ], 636 + "onDelete": "cascade", 637 + "onUpdate": "no action" 638 + } 639 + }, 640 + "compositePrimaryKeys": {}, 641 + "uniqueConstraints": {}, 642 + "checkConstraints": {} 643 + }, 644 + "status_report_update": { 645 + "name": "status_report_update", 646 + "columns": { 647 + "id": { 648 + "name": "id", 649 + "type": "integer", 650 + "primaryKey": true, 651 + "notNull": true, 652 + "autoincrement": false 653 + }, 654 + "status": { 655 + "name": "status", 656 + "type": "text", 657 + "primaryKey": false, 658 + "notNull": true, 659 + "autoincrement": false 660 + }, 661 + "date": { 662 + "name": "date", 663 + "type": "integer", 664 + "primaryKey": false, 665 + "notNull": true, 666 + "autoincrement": false 667 + }, 668 + "message": { 669 + "name": "message", 670 + "type": "text", 671 + "primaryKey": false, 672 + "notNull": true, 673 + "autoincrement": false 674 + }, 675 + "status_report_id": { 676 + "name": "status_report_id", 677 + "type": "integer", 678 + "primaryKey": false, 679 + "notNull": true, 680 + "autoincrement": false 681 + }, 682 + "created_at": { 683 + "name": "created_at", 684 + "type": "integer", 685 + "primaryKey": false, 686 + "notNull": false, 687 + "autoincrement": false, 688 + "default": "(strftime('%s', 'now'))" 689 + }, 690 + "updated_at": { 691 + "name": "updated_at", 692 + "type": "integer", 693 + "primaryKey": false, 694 + "notNull": false, 695 + "autoincrement": false, 696 + "default": "(strftime('%s', 'now'))" 697 + } 698 + }, 699 + "indexes": {}, 700 + "foreignKeys": { 701 + "status_report_update_status_report_id_status_report_id_fk": { 702 + "name": "status_report_update_status_report_id_status_report_id_fk", 703 + "tableFrom": "status_report_update", 704 + "tableTo": "status_report", 705 + "columnsFrom": [ 706 + "status_report_id" 707 + ], 708 + "columnsTo": [ 709 + "id" 710 + ], 711 + "onDelete": "cascade", 712 + "onUpdate": "no action" 713 + } 714 + }, 715 + "compositePrimaryKeys": {}, 716 + "uniqueConstraints": {}, 717 + "checkConstraints": {} 718 + }, 719 + "integration": { 720 + "name": "integration", 721 + "columns": { 722 + "id": { 723 + "name": "id", 724 + "type": "integer", 725 + "primaryKey": true, 726 + "notNull": true, 727 + "autoincrement": false 728 + }, 729 + "name": { 730 + "name": "name", 731 + "type": "text(256)", 732 + "primaryKey": false, 733 + "notNull": true, 734 + "autoincrement": false 735 + }, 736 + "workspace_id": { 737 + "name": "workspace_id", 738 + "type": "integer", 739 + "primaryKey": false, 740 + "notNull": false, 741 + "autoincrement": false 742 + }, 743 + "credential": { 744 + "name": "credential", 745 + "type": "text", 746 + "primaryKey": false, 747 + "notNull": false, 748 + "autoincrement": false 749 + }, 750 + "external_id": { 751 + "name": "external_id", 752 + "type": "text", 753 + "primaryKey": false, 754 + "notNull": true, 755 + "autoincrement": false 756 + }, 757 + "created_at": { 758 + "name": "created_at", 759 + "type": "integer", 760 + "primaryKey": false, 761 + "notNull": false, 762 + "autoincrement": false, 763 + "default": "(strftime('%s', 'now'))" 764 + }, 765 + "updated_at": { 766 + "name": "updated_at", 767 + "type": "integer", 768 + "primaryKey": false, 769 + "notNull": false, 770 + "autoincrement": false, 771 + "default": "(strftime('%s', 'now'))" 772 + }, 773 + "data": { 774 + "name": "data", 775 + "type": "text", 776 + "primaryKey": false, 777 + "notNull": true, 778 + "autoincrement": false 779 + } 780 + }, 781 + "indexes": {}, 782 + "foreignKeys": { 783 + "integration_workspace_id_workspace_id_fk": { 784 + "name": "integration_workspace_id_workspace_id_fk", 785 + "tableFrom": "integration", 786 + "tableTo": "workspace", 787 + "columnsFrom": [ 788 + "workspace_id" 789 + ], 790 + "columnsTo": [ 791 + "id" 792 + ], 793 + "onDelete": "no action", 794 + "onUpdate": "no action" 795 + } 796 + }, 797 + "compositePrimaryKeys": {}, 798 + "uniqueConstraints": {}, 799 + "checkConstraints": {} 800 + }, 801 + "page": { 802 + "name": "page", 803 + "columns": { 804 + "id": { 805 + "name": "id", 806 + "type": "integer", 807 + "primaryKey": true, 808 + "notNull": true, 809 + "autoincrement": false 810 + }, 811 + "workspace_id": { 812 + "name": "workspace_id", 813 + "type": "integer", 814 + "primaryKey": false, 815 + "notNull": true, 816 + "autoincrement": false 817 + }, 818 + "title": { 819 + "name": "title", 820 + "type": "text", 821 + "primaryKey": false, 822 + "notNull": true, 823 + "autoincrement": false 824 + }, 825 + "description": { 826 + "name": "description", 827 + "type": "text", 828 + "primaryKey": false, 829 + "notNull": true, 830 + "autoincrement": false 831 + }, 832 + "icon": { 833 + "name": "icon", 834 + "type": "text(256)", 835 + "primaryKey": false, 836 + "notNull": false, 837 + "autoincrement": false, 838 + "default": "''" 839 + }, 840 + "slug": { 841 + "name": "slug", 842 + "type": "text(256)", 843 + "primaryKey": false, 844 + "notNull": true, 845 + "autoincrement": false 846 + }, 847 + "custom_domain": { 848 + "name": "custom_domain", 849 + "type": "text(256)", 850 + "primaryKey": false, 851 + "notNull": true, 852 + "autoincrement": false 853 + }, 854 + "published": { 855 + "name": "published", 856 + "type": "integer", 857 + "primaryKey": false, 858 + "notNull": false, 859 + "autoincrement": false, 860 + "default": false 861 + }, 862 + "force_theme": { 863 + "name": "force_theme", 864 + "type": "text", 865 + "primaryKey": false, 866 + "notNull": true, 867 + "autoincrement": false, 868 + "default": "'system'" 869 + }, 870 + "password": { 871 + "name": "password", 872 + "type": "text(256)", 873 + "primaryKey": false, 874 + "notNull": false, 875 + "autoincrement": false 876 + }, 877 + "password_protected": { 878 + "name": "password_protected", 879 + "type": "integer", 880 + "primaryKey": false, 881 + "notNull": false, 882 + "autoincrement": false, 883 + "default": false 884 + }, 885 + "access_type": { 886 + "name": "access_type", 887 + "type": "text", 888 + "primaryKey": false, 889 + "notNull": false, 890 + "autoincrement": false, 891 + "default": "'public'" 892 + }, 893 + "auth_email_domains": { 894 + "name": "auth_email_domains", 895 + "type": "text", 896 + "primaryKey": false, 897 + "notNull": false, 898 + "autoincrement": false 899 + }, 900 + "homepage_url": { 901 + "name": "homepage_url", 902 + "type": "text(256)", 903 + "primaryKey": false, 904 + "notNull": false, 905 + "autoincrement": false 906 + }, 907 + "contact_url": { 908 + "name": "contact_url", 909 + "type": "text(256)", 910 + "primaryKey": false, 911 + "notNull": false, 912 + "autoincrement": false 913 + }, 914 + "legacy_page": { 915 + "name": "legacy_page", 916 + "type": "integer", 917 + "primaryKey": false, 918 + "notNull": true, 919 + "autoincrement": false, 920 + "default": true 921 + }, 922 + "configuration": { 923 + "name": "configuration", 924 + "type": "text", 925 + "primaryKey": false, 926 + "notNull": false, 927 + "autoincrement": false 928 + }, 929 + "show_monitor_values": { 930 + "name": "show_monitor_values", 931 + "type": "integer", 932 + "primaryKey": false, 933 + "notNull": false, 934 + "autoincrement": false, 935 + "default": true 936 + }, 937 + "created_at": { 938 + "name": "created_at", 939 + "type": "integer", 940 + "primaryKey": false, 941 + "notNull": false, 942 + "autoincrement": false, 943 + "default": "(strftime('%s', 'now'))" 944 + }, 945 + "updated_at": { 946 + "name": "updated_at", 947 + "type": "integer", 948 + "primaryKey": false, 949 + "notNull": false, 950 + "autoincrement": false, 951 + "default": "(strftime('%s', 'now'))" 952 + } 953 + }, 954 + "indexes": { 955 + "page_slug_unique": { 956 + "name": "page_slug_unique", 957 + "columns": [ 958 + "slug" 959 + ], 960 + "isUnique": true 961 + } 962 + }, 963 + "foreignKeys": { 964 + "page_workspace_id_workspace_id_fk": { 965 + "name": "page_workspace_id_workspace_id_fk", 966 + "tableFrom": "page", 967 + "tableTo": "workspace", 968 + "columnsFrom": [ 969 + "workspace_id" 970 + ], 971 + "columnsTo": [ 972 + "id" 973 + ], 974 + "onDelete": "cascade", 975 + "onUpdate": "no action" 976 + } 977 + }, 978 + "compositePrimaryKeys": {}, 979 + "uniqueConstraints": {}, 980 + "checkConstraints": {} 981 + }, 982 + "monitor": { 983 + "name": "monitor", 984 + "columns": { 985 + "id": { 986 + "name": "id", 987 + "type": "integer", 988 + "primaryKey": true, 989 + "notNull": true, 990 + "autoincrement": false 991 + }, 992 + "job_type": { 993 + "name": "job_type", 994 + "type": "text", 995 + "primaryKey": false, 996 + "notNull": true, 997 + "autoincrement": false, 998 + "default": "'http'" 999 + }, 1000 + "periodicity": { 1001 + "name": "periodicity", 1002 + "type": "text", 1003 + "primaryKey": false, 1004 + "notNull": true, 1005 + "autoincrement": false, 1006 + "default": "'other'" 1007 + }, 1008 + "status": { 1009 + "name": "status", 1010 + "type": "text", 1011 + "primaryKey": false, 1012 + "notNull": true, 1013 + "autoincrement": false, 1014 + "default": "'active'" 1015 + }, 1016 + "active": { 1017 + "name": "active", 1018 + "type": "integer", 1019 + "primaryKey": false, 1020 + "notNull": false, 1021 + "autoincrement": false, 1022 + "default": false 1023 + }, 1024 + "regions": { 1025 + "name": "regions", 1026 + "type": "text", 1027 + "primaryKey": false, 1028 + "notNull": true, 1029 + "autoincrement": false, 1030 + "default": "''" 1031 + }, 1032 + "url": { 1033 + "name": "url", 1034 + "type": "text(2048)", 1035 + "primaryKey": false, 1036 + "notNull": true, 1037 + "autoincrement": false 1038 + }, 1039 + "name": { 1040 + "name": "name", 1041 + "type": "text(256)", 1042 + "primaryKey": false, 1043 + "notNull": true, 1044 + "autoincrement": false, 1045 + "default": "''" 1046 + }, 1047 + "external_name": { 1048 + "name": "external_name", 1049 + "type": "text", 1050 + "primaryKey": false, 1051 + "notNull": false, 1052 + "autoincrement": false 1053 + }, 1054 + "description": { 1055 + "name": "description", 1056 + "type": "text", 1057 + "primaryKey": false, 1058 + "notNull": true, 1059 + "autoincrement": false, 1060 + "default": "''" 1061 + }, 1062 + "headers": { 1063 + "name": "headers", 1064 + "type": "text", 1065 + "primaryKey": false, 1066 + "notNull": false, 1067 + "autoincrement": false, 1068 + "default": "''" 1069 + }, 1070 + "body": { 1071 + "name": "body", 1072 + "type": "text", 1073 + "primaryKey": false, 1074 + "notNull": false, 1075 + "autoincrement": false, 1076 + "default": "''" 1077 + }, 1078 + "method": { 1079 + "name": "method", 1080 + "type": "text", 1081 + "primaryKey": false, 1082 + "notNull": false, 1083 + "autoincrement": false, 1084 + "default": "'GET'" 1085 + }, 1086 + "workspace_id": { 1087 + "name": "workspace_id", 1088 + "type": "integer", 1089 + "primaryKey": false, 1090 + "notNull": false, 1091 + "autoincrement": false 1092 + }, 1093 + "timeout": { 1094 + "name": "timeout", 1095 + "type": "integer", 1096 + "primaryKey": false, 1097 + "notNull": true, 1098 + "autoincrement": false, 1099 + "default": 45000 1100 + }, 1101 + "degraded_after": { 1102 + "name": "degraded_after", 1103 + "type": "integer", 1104 + "primaryKey": false, 1105 + "notNull": false, 1106 + "autoincrement": false 1107 + }, 1108 + "assertions": { 1109 + "name": "assertions", 1110 + "type": "text", 1111 + "primaryKey": false, 1112 + "notNull": false, 1113 + "autoincrement": false 1114 + }, 1115 + "otel_endpoint": { 1116 + "name": "otel_endpoint", 1117 + "type": "text", 1118 + "primaryKey": false, 1119 + "notNull": false, 1120 + "autoincrement": false 1121 + }, 1122 + "otel_headers": { 1123 + "name": "otel_headers", 1124 + "type": "text", 1125 + "primaryKey": false, 1126 + "notNull": false, 1127 + "autoincrement": false 1128 + }, 1129 + "public": { 1130 + "name": "public", 1131 + "type": "integer", 1132 + "primaryKey": false, 1133 + "notNull": false, 1134 + "autoincrement": false, 1135 + "default": false 1136 + }, 1137 + "retry": { 1138 + "name": "retry", 1139 + "type": "integer", 1140 + "primaryKey": false, 1141 + "notNull": false, 1142 + "autoincrement": false, 1143 + "default": 3 1144 + }, 1145 + "follow_redirects": { 1146 + "name": "follow_redirects", 1147 + "type": "integer", 1148 + "primaryKey": false, 1149 + "notNull": false, 1150 + "autoincrement": false, 1151 + "default": true 1152 + }, 1153 + "created_at": { 1154 + "name": "created_at", 1155 + "type": "integer", 1156 + "primaryKey": false, 1157 + "notNull": false, 1158 + "autoincrement": false, 1159 + "default": "(strftime('%s', 'now'))" 1160 + }, 1161 + "updated_at": { 1162 + "name": "updated_at", 1163 + "type": "integer", 1164 + "primaryKey": false, 1165 + "notNull": false, 1166 + "autoincrement": false, 1167 + "default": "(strftime('%s', 'now'))" 1168 + }, 1169 + "deleted_at": { 1170 + "name": "deleted_at", 1171 + "type": "integer", 1172 + "primaryKey": false, 1173 + "notNull": false, 1174 + "autoincrement": false 1175 + } 1176 + }, 1177 + "indexes": {}, 1178 + "foreignKeys": { 1179 + "monitor_workspace_id_workspace_id_fk": { 1180 + "name": "monitor_workspace_id_workspace_id_fk", 1181 + "tableFrom": "monitor", 1182 + "tableTo": "workspace", 1183 + "columnsFrom": [ 1184 + "workspace_id" 1185 + ], 1186 + "columnsTo": [ 1187 + "id" 1188 + ], 1189 + "onDelete": "no action", 1190 + "onUpdate": "no action" 1191 + } 1192 + }, 1193 + "compositePrimaryKeys": {}, 1194 + "uniqueConstraints": {}, 1195 + "checkConstraints": {} 1196 + }, 1197 + "monitors_to_pages": { 1198 + "name": "monitors_to_pages", 1199 + "columns": { 1200 + "monitor_id": { 1201 + "name": "monitor_id", 1202 + "type": "integer", 1203 + "primaryKey": false, 1204 + "notNull": true, 1205 + "autoincrement": false 1206 + }, 1207 + "page_id": { 1208 + "name": "page_id", 1209 + "type": "integer", 1210 + "primaryKey": false, 1211 + "notNull": true, 1212 + "autoincrement": false 1213 + }, 1214 + "created_at": { 1215 + "name": "created_at", 1216 + "type": "integer", 1217 + "primaryKey": false, 1218 + "notNull": false, 1219 + "autoincrement": false, 1220 + "default": "(strftime('%s', 'now'))" 1221 + }, 1222 + "order": { 1223 + "name": "order", 1224 + "type": "integer", 1225 + "primaryKey": false, 1226 + "notNull": false, 1227 + "autoincrement": false, 1228 + "default": 0 1229 + }, 1230 + "monitor_group_id": { 1231 + "name": "monitor_group_id", 1232 + "type": "integer", 1233 + "primaryKey": false, 1234 + "notNull": false, 1235 + "autoincrement": false 1236 + }, 1237 + "group_order": { 1238 + "name": "group_order", 1239 + "type": "integer", 1240 + "primaryKey": false, 1241 + "notNull": false, 1242 + "autoincrement": false, 1243 + "default": 0 1244 + } 1245 + }, 1246 + "indexes": {}, 1247 + "foreignKeys": { 1248 + "monitors_to_pages_monitor_id_monitor_id_fk": { 1249 + "name": "monitors_to_pages_monitor_id_monitor_id_fk", 1250 + "tableFrom": "monitors_to_pages", 1251 + "tableTo": "monitor", 1252 + "columnsFrom": [ 1253 + "monitor_id" 1254 + ], 1255 + "columnsTo": [ 1256 + "id" 1257 + ], 1258 + "onDelete": "cascade", 1259 + "onUpdate": "no action" 1260 + }, 1261 + "monitors_to_pages_page_id_page_id_fk": { 1262 + "name": "monitors_to_pages_page_id_page_id_fk", 1263 + "tableFrom": "monitors_to_pages", 1264 + "tableTo": "page", 1265 + "columnsFrom": [ 1266 + "page_id" 1267 + ], 1268 + "columnsTo": [ 1269 + "id" 1270 + ], 1271 + "onDelete": "cascade", 1272 + "onUpdate": "no action" 1273 + }, 1274 + "monitors_to_pages_monitor_group_id_monitor_group_id_fk": { 1275 + "name": "monitors_to_pages_monitor_group_id_monitor_group_id_fk", 1276 + "tableFrom": "monitors_to_pages", 1277 + "tableTo": "monitor_group", 1278 + "columnsFrom": [ 1279 + "monitor_group_id" 1280 + ], 1281 + "columnsTo": [ 1282 + "id" 1283 + ], 1284 + "onDelete": "cascade", 1285 + "onUpdate": "no action" 1286 + } 1287 + }, 1288 + "compositePrimaryKeys": { 1289 + "monitors_to_pages_monitor_id_page_id_pk": { 1290 + "columns": [ 1291 + "monitor_id", 1292 + "page_id" 1293 + ], 1294 + "name": "monitors_to_pages_monitor_id_page_id_pk" 1295 + } 1296 + }, 1297 + "uniqueConstraints": {}, 1298 + "checkConstraints": {} 1299 + }, 1300 + "page_subscriber": { 1301 + "name": "page_subscriber", 1302 + "columns": { 1303 + "id": { 1304 + "name": "id", 1305 + "type": "integer", 1306 + "primaryKey": true, 1307 + "notNull": true, 1308 + "autoincrement": false 1309 + }, 1310 + "email": { 1311 + "name": "email", 1312 + "type": "text", 1313 + "primaryKey": false, 1314 + "notNull": true, 1315 + "autoincrement": false 1316 + }, 1317 + "page_id": { 1318 + "name": "page_id", 1319 + "type": "integer", 1320 + "primaryKey": false, 1321 + "notNull": true, 1322 + "autoincrement": false 1323 + }, 1324 + "token": { 1325 + "name": "token", 1326 + "type": "text", 1327 + "primaryKey": false, 1328 + "notNull": false, 1329 + "autoincrement": false 1330 + }, 1331 + "accepted_at": { 1332 + "name": "accepted_at", 1333 + "type": "integer", 1334 + "primaryKey": false, 1335 + "notNull": false, 1336 + "autoincrement": false 1337 + }, 1338 + "expires_at": { 1339 + "name": "expires_at", 1340 + "type": "integer", 1341 + "primaryKey": false, 1342 + "notNull": false, 1343 + "autoincrement": false 1344 + }, 1345 + "created_at": { 1346 + "name": "created_at", 1347 + "type": "integer", 1348 + "primaryKey": false, 1349 + "notNull": false, 1350 + "autoincrement": false, 1351 + "default": "(strftime('%s', 'now'))" 1352 + }, 1353 + "updated_at": { 1354 + "name": "updated_at", 1355 + "type": "integer", 1356 + "primaryKey": false, 1357 + "notNull": false, 1358 + "autoincrement": false, 1359 + "default": "(strftime('%s', 'now'))" 1360 + } 1361 + }, 1362 + "indexes": {}, 1363 + "foreignKeys": { 1364 + "page_subscriber_page_id_page_id_fk": { 1365 + "name": "page_subscriber_page_id_page_id_fk", 1366 + "tableFrom": "page_subscriber", 1367 + "tableTo": "page", 1368 + "columnsFrom": [ 1369 + "page_id" 1370 + ], 1371 + "columnsTo": [ 1372 + "id" 1373 + ], 1374 + "onDelete": "cascade", 1375 + "onUpdate": "no action" 1376 + } 1377 + }, 1378 + "compositePrimaryKeys": {}, 1379 + "uniqueConstraints": {}, 1380 + "checkConstraints": {} 1381 + }, 1382 + "notification": { 1383 + "name": "notification", 1384 + "columns": { 1385 + "id": { 1386 + "name": "id", 1387 + "type": "integer", 1388 + "primaryKey": true, 1389 + "notNull": true, 1390 + "autoincrement": false 1391 + }, 1392 + "name": { 1393 + "name": "name", 1394 + "type": "text", 1395 + "primaryKey": false, 1396 + "notNull": true, 1397 + "autoincrement": false 1398 + }, 1399 + "provider": { 1400 + "name": "provider", 1401 + "type": "text", 1402 + "primaryKey": false, 1403 + "notNull": true, 1404 + "autoincrement": false 1405 + }, 1406 + "data": { 1407 + "name": "data", 1408 + "type": "text", 1409 + "primaryKey": false, 1410 + "notNull": false, 1411 + "autoincrement": false, 1412 + "default": "'{}'" 1413 + }, 1414 + "workspace_id": { 1415 + "name": "workspace_id", 1416 + "type": "integer", 1417 + "primaryKey": false, 1418 + "notNull": false, 1419 + "autoincrement": false 1420 + }, 1421 + "created_at": { 1422 + "name": "created_at", 1423 + "type": "integer", 1424 + "primaryKey": false, 1425 + "notNull": false, 1426 + "autoincrement": false, 1427 + "default": "(strftime('%s', 'now'))" 1428 + }, 1429 + "updated_at": { 1430 + "name": "updated_at", 1431 + "type": "integer", 1432 + "primaryKey": false, 1433 + "notNull": false, 1434 + "autoincrement": false, 1435 + "default": "(strftime('%s', 'now'))" 1436 + } 1437 + }, 1438 + "indexes": {}, 1439 + "foreignKeys": { 1440 + "notification_workspace_id_workspace_id_fk": { 1441 + "name": "notification_workspace_id_workspace_id_fk", 1442 + "tableFrom": "notification", 1443 + "tableTo": "workspace", 1444 + "columnsFrom": [ 1445 + "workspace_id" 1446 + ], 1447 + "columnsTo": [ 1448 + "id" 1449 + ], 1450 + "onDelete": "no action", 1451 + "onUpdate": "no action" 1452 + } 1453 + }, 1454 + "compositePrimaryKeys": {}, 1455 + "uniqueConstraints": {}, 1456 + "checkConstraints": {} 1457 + }, 1458 + "notification_trigger": { 1459 + "name": "notification_trigger", 1460 + "columns": { 1461 + "id": { 1462 + "name": "id", 1463 + "type": "integer", 1464 + "primaryKey": true, 1465 + "notNull": true, 1466 + "autoincrement": false 1467 + }, 1468 + "monitor_id": { 1469 + "name": "monitor_id", 1470 + "type": "integer", 1471 + "primaryKey": false, 1472 + "notNull": false, 1473 + "autoincrement": false 1474 + }, 1475 + "notification_id": { 1476 + "name": "notification_id", 1477 + "type": "integer", 1478 + "primaryKey": false, 1479 + "notNull": false, 1480 + "autoincrement": false 1481 + }, 1482 + "cron_timestamp": { 1483 + "name": "cron_timestamp", 1484 + "type": "integer", 1485 + "primaryKey": false, 1486 + "notNull": true, 1487 + "autoincrement": false 1488 + } 1489 + }, 1490 + "indexes": { 1491 + "notification_id_monitor_id_crontimestampe": { 1492 + "name": "notification_id_monitor_id_crontimestampe", 1493 + "columns": [ 1494 + "notification_id", 1495 + "monitor_id", 1496 + "cron_timestamp" 1497 + ], 1498 + "isUnique": true 1499 + } 1500 + }, 1501 + "foreignKeys": { 1502 + "notification_trigger_monitor_id_monitor_id_fk": { 1503 + "name": "notification_trigger_monitor_id_monitor_id_fk", 1504 + "tableFrom": "notification_trigger", 1505 + "tableTo": "monitor", 1506 + "columnsFrom": [ 1507 + "monitor_id" 1508 + ], 1509 + "columnsTo": [ 1510 + "id" 1511 + ], 1512 + "onDelete": "cascade", 1513 + "onUpdate": "no action" 1514 + }, 1515 + "notification_trigger_notification_id_notification_id_fk": { 1516 + "name": "notification_trigger_notification_id_notification_id_fk", 1517 + "tableFrom": "notification_trigger", 1518 + "tableTo": "notification", 1519 + "columnsFrom": [ 1520 + "notification_id" 1521 + ], 1522 + "columnsTo": [ 1523 + "id" 1524 + ], 1525 + "onDelete": "cascade", 1526 + "onUpdate": "no action" 1527 + } 1528 + }, 1529 + "compositePrimaryKeys": {}, 1530 + "uniqueConstraints": {}, 1531 + "checkConstraints": {} 1532 + }, 1533 + "notifications_to_monitors": { 1534 + "name": "notifications_to_monitors", 1535 + "columns": { 1536 + "monitor_id": { 1537 + "name": "monitor_id", 1538 + "type": "integer", 1539 + "primaryKey": false, 1540 + "notNull": true, 1541 + "autoincrement": false 1542 + }, 1543 + "notification_id": { 1544 + "name": "notification_id", 1545 + "type": "integer", 1546 + "primaryKey": false, 1547 + "notNull": true, 1548 + "autoincrement": false 1549 + }, 1550 + "created_at": { 1551 + "name": "created_at", 1552 + "type": "integer", 1553 + "primaryKey": false, 1554 + "notNull": false, 1555 + "autoincrement": false, 1556 + "default": "(strftime('%s', 'now'))" 1557 + } 1558 + }, 1559 + "indexes": {}, 1560 + "foreignKeys": { 1561 + "notifications_to_monitors_monitor_id_monitor_id_fk": { 1562 + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", 1563 + "tableFrom": "notifications_to_monitors", 1564 + "tableTo": "monitor", 1565 + "columnsFrom": [ 1566 + "monitor_id" 1567 + ], 1568 + "columnsTo": [ 1569 + "id" 1570 + ], 1571 + "onDelete": "cascade", 1572 + "onUpdate": "no action" 1573 + }, 1574 + "notifications_to_monitors_notification_id_notification_id_fk": { 1575 + "name": "notifications_to_monitors_notification_id_notification_id_fk", 1576 + "tableFrom": "notifications_to_monitors", 1577 + "tableTo": "notification", 1578 + "columnsFrom": [ 1579 + "notification_id" 1580 + ], 1581 + "columnsTo": [ 1582 + "id" 1583 + ], 1584 + "onDelete": "cascade", 1585 + "onUpdate": "no action" 1586 + } 1587 + }, 1588 + "compositePrimaryKeys": { 1589 + "notifications_to_monitors_monitor_id_notification_id_pk": { 1590 + "columns": [ 1591 + "monitor_id", 1592 + "notification_id" 1593 + ], 1594 + "name": "notifications_to_monitors_monitor_id_notification_id_pk" 1595 + } 1596 + }, 1597 + "uniqueConstraints": {}, 1598 + "checkConstraints": {} 1599 + }, 1600 + "monitor_status": { 1601 + "name": "monitor_status", 1602 + "columns": { 1603 + "monitor_id": { 1604 + "name": "monitor_id", 1605 + "type": "integer", 1606 + "primaryKey": false, 1607 + "notNull": true, 1608 + "autoincrement": false 1609 + }, 1610 + "region": { 1611 + "name": "region", 1612 + "type": "text", 1613 + "primaryKey": false, 1614 + "notNull": true, 1615 + "autoincrement": false, 1616 + "default": "''" 1617 + }, 1618 + "status": { 1619 + "name": "status", 1620 + "type": "text", 1621 + "primaryKey": false, 1622 + "notNull": true, 1623 + "autoincrement": false, 1624 + "default": "'active'" 1625 + }, 1626 + "created_at": { 1627 + "name": "created_at", 1628 + "type": "integer", 1629 + "primaryKey": false, 1630 + "notNull": false, 1631 + "autoincrement": false, 1632 + "default": "(strftime('%s', 'now'))" 1633 + }, 1634 + "updated_at": { 1635 + "name": "updated_at", 1636 + "type": "integer", 1637 + "primaryKey": false, 1638 + "notNull": false, 1639 + "autoincrement": false, 1640 + "default": "(strftime('%s', 'now'))" 1641 + } 1642 + }, 1643 + "indexes": { 1644 + "monitor_status_idx": { 1645 + "name": "monitor_status_idx", 1646 + "columns": [ 1647 + "monitor_id", 1648 + "region" 1649 + ], 1650 + "isUnique": false 1651 + } 1652 + }, 1653 + "foreignKeys": { 1654 + "monitor_status_monitor_id_monitor_id_fk": { 1655 + "name": "monitor_status_monitor_id_monitor_id_fk", 1656 + "tableFrom": "monitor_status", 1657 + "tableTo": "monitor", 1658 + "columnsFrom": [ 1659 + "monitor_id" 1660 + ], 1661 + "columnsTo": [ 1662 + "id" 1663 + ], 1664 + "onDelete": "cascade", 1665 + "onUpdate": "no action" 1666 + } 1667 + }, 1668 + "compositePrimaryKeys": { 1669 + "monitor_status_monitor_id_region_pk": { 1670 + "columns": [ 1671 + "monitor_id", 1672 + "region" 1673 + ], 1674 + "name": "monitor_status_monitor_id_region_pk" 1675 + } 1676 + }, 1677 + "uniqueConstraints": {}, 1678 + "checkConstraints": {} 1679 + }, 1680 + "invitation": { 1681 + "name": "invitation", 1682 + "columns": { 1683 + "id": { 1684 + "name": "id", 1685 + "type": "integer", 1686 + "primaryKey": true, 1687 + "notNull": true, 1688 + "autoincrement": false 1689 + }, 1690 + "email": { 1691 + "name": "email", 1692 + "type": "text", 1693 + "primaryKey": false, 1694 + "notNull": true, 1695 + "autoincrement": false 1696 + }, 1697 + "role": { 1698 + "name": "role", 1699 + "type": "text", 1700 + "primaryKey": false, 1701 + "notNull": true, 1702 + "autoincrement": false, 1703 + "default": "'member'" 1704 + }, 1705 + "workspace_id": { 1706 + "name": "workspace_id", 1707 + "type": "integer", 1708 + "primaryKey": false, 1709 + "notNull": true, 1710 + "autoincrement": false 1711 + }, 1712 + "token": { 1713 + "name": "token", 1714 + "type": "text", 1715 + "primaryKey": false, 1716 + "notNull": true, 1717 + "autoincrement": false 1718 + }, 1719 + "expires_at": { 1720 + "name": "expires_at", 1721 + "type": "integer", 1722 + "primaryKey": false, 1723 + "notNull": true, 1724 + "autoincrement": false 1725 + }, 1726 + "created_at": { 1727 + "name": "created_at", 1728 + "type": "integer", 1729 + "primaryKey": false, 1730 + "notNull": false, 1731 + "autoincrement": false, 1732 + "default": "(strftime('%s', 'now'))" 1733 + }, 1734 + "accepted_at": { 1735 + "name": "accepted_at", 1736 + "type": "integer", 1737 + "primaryKey": false, 1738 + "notNull": false, 1739 + "autoincrement": false 1740 + } 1741 + }, 1742 + "indexes": {}, 1743 + "foreignKeys": {}, 1744 + "compositePrimaryKeys": {}, 1745 + "uniqueConstraints": {}, 1746 + "checkConstraints": {} 1747 + }, 1748 + "incident": { 1749 + "name": "incident", 1750 + "columns": { 1751 + "id": { 1752 + "name": "id", 1753 + "type": "integer", 1754 + "primaryKey": true, 1755 + "notNull": true, 1756 + "autoincrement": false 1757 + }, 1758 + "title": { 1759 + "name": "title", 1760 + "type": "text", 1761 + "primaryKey": false, 1762 + "notNull": true, 1763 + "autoincrement": false, 1764 + "default": "''" 1765 + }, 1766 + "summary": { 1767 + "name": "summary", 1768 + "type": "text", 1769 + "primaryKey": false, 1770 + "notNull": true, 1771 + "autoincrement": false, 1772 + "default": "''" 1773 + }, 1774 + "status": { 1775 + "name": "status", 1776 + "type": "text", 1777 + "primaryKey": false, 1778 + "notNull": true, 1779 + "autoincrement": false, 1780 + "default": "'triage'" 1781 + }, 1782 + "monitor_id": { 1783 + "name": "monitor_id", 1784 + "type": "integer", 1785 + "primaryKey": false, 1786 + "notNull": false, 1787 + "autoincrement": false 1788 + }, 1789 + "workspace_id": { 1790 + "name": "workspace_id", 1791 + "type": "integer", 1792 + "primaryKey": false, 1793 + "notNull": false, 1794 + "autoincrement": false 1795 + }, 1796 + "started_at": { 1797 + "name": "started_at", 1798 + "type": "integer", 1799 + "primaryKey": false, 1800 + "notNull": true, 1801 + "autoincrement": false, 1802 + "default": "(strftime('%s', 'now'))" 1803 + }, 1804 + "acknowledged_at": { 1805 + "name": "acknowledged_at", 1806 + "type": "integer", 1807 + "primaryKey": false, 1808 + "notNull": false, 1809 + "autoincrement": false 1810 + }, 1811 + "acknowledged_by": { 1812 + "name": "acknowledged_by", 1813 + "type": "integer", 1814 + "primaryKey": false, 1815 + "notNull": false, 1816 + "autoincrement": false 1817 + }, 1818 + "resolved_at": { 1819 + "name": "resolved_at", 1820 + "type": "integer", 1821 + "primaryKey": false, 1822 + "notNull": false, 1823 + "autoincrement": false 1824 + }, 1825 + "resolved_by": { 1826 + "name": "resolved_by", 1827 + "type": "integer", 1828 + "primaryKey": false, 1829 + "notNull": false, 1830 + "autoincrement": false 1831 + }, 1832 + "incident_screenshot_url": { 1833 + "name": "incident_screenshot_url", 1834 + "type": "text", 1835 + "primaryKey": false, 1836 + "notNull": false, 1837 + "autoincrement": false 1838 + }, 1839 + "recovery_screenshot_url": { 1840 + "name": "recovery_screenshot_url", 1841 + "type": "text", 1842 + "primaryKey": false, 1843 + "notNull": false, 1844 + "autoincrement": false 1845 + }, 1846 + "auto_resolved": { 1847 + "name": "auto_resolved", 1848 + "type": "integer", 1849 + "primaryKey": false, 1850 + "notNull": false, 1851 + "autoincrement": false, 1852 + "default": false 1853 + }, 1854 + "created_at": { 1855 + "name": "created_at", 1856 + "type": "integer", 1857 + "primaryKey": false, 1858 + "notNull": false, 1859 + "autoincrement": false, 1860 + "default": "(strftime('%s', 'now'))" 1861 + }, 1862 + "updated_at": { 1863 + "name": "updated_at", 1864 + "type": "integer", 1865 + "primaryKey": false, 1866 + "notNull": false, 1867 + "autoincrement": false, 1868 + "default": "(strftime('%s', 'now'))" 1869 + } 1870 + }, 1871 + "indexes": { 1872 + "incident_monitor_id_started_at_unique": { 1873 + "name": "incident_monitor_id_started_at_unique", 1874 + "columns": [ 1875 + "monitor_id", 1876 + "started_at" 1877 + ], 1878 + "isUnique": true 1879 + } 1880 + }, 1881 + "foreignKeys": { 1882 + "incident_monitor_id_monitor_id_fk": { 1883 + "name": "incident_monitor_id_monitor_id_fk", 1884 + "tableFrom": "incident", 1885 + "tableTo": "monitor", 1886 + "columnsFrom": [ 1887 + "monitor_id" 1888 + ], 1889 + "columnsTo": [ 1890 + "id" 1891 + ], 1892 + "onDelete": "set default", 1893 + "onUpdate": "no action" 1894 + }, 1895 + "incident_workspace_id_workspace_id_fk": { 1896 + "name": "incident_workspace_id_workspace_id_fk", 1897 + "tableFrom": "incident", 1898 + "tableTo": "workspace", 1899 + "columnsFrom": [ 1900 + "workspace_id" 1901 + ], 1902 + "columnsTo": [ 1903 + "id" 1904 + ], 1905 + "onDelete": "no action", 1906 + "onUpdate": "no action" 1907 + }, 1908 + "incident_acknowledged_by_user_id_fk": { 1909 + "name": "incident_acknowledged_by_user_id_fk", 1910 + "tableFrom": "incident", 1911 + "tableTo": "user", 1912 + "columnsFrom": [ 1913 + "acknowledged_by" 1914 + ], 1915 + "columnsTo": [ 1916 + "id" 1917 + ], 1918 + "onDelete": "no action", 1919 + "onUpdate": "no action" 1920 + }, 1921 + "incident_resolved_by_user_id_fk": { 1922 + "name": "incident_resolved_by_user_id_fk", 1923 + "tableFrom": "incident", 1924 + "tableTo": "user", 1925 + "columnsFrom": [ 1926 + "resolved_by" 1927 + ], 1928 + "columnsTo": [ 1929 + "id" 1930 + ], 1931 + "onDelete": "no action", 1932 + "onUpdate": "no action" 1933 + } 1934 + }, 1935 + "compositePrimaryKeys": {}, 1936 + "uniqueConstraints": {}, 1937 + "checkConstraints": {} 1938 + }, 1939 + "monitor_tag": { 1940 + "name": "monitor_tag", 1941 + "columns": { 1942 + "id": { 1943 + "name": "id", 1944 + "type": "integer", 1945 + "primaryKey": true, 1946 + "notNull": true, 1947 + "autoincrement": false 1948 + }, 1949 + "workspace_id": { 1950 + "name": "workspace_id", 1951 + "type": "integer", 1952 + "primaryKey": false, 1953 + "notNull": true, 1954 + "autoincrement": false 1955 + }, 1956 + "name": { 1957 + "name": "name", 1958 + "type": "text", 1959 + "primaryKey": false, 1960 + "notNull": true, 1961 + "autoincrement": false 1962 + }, 1963 + "color": { 1964 + "name": "color", 1965 + "type": "text", 1966 + "primaryKey": false, 1967 + "notNull": true, 1968 + "autoincrement": false 1969 + }, 1970 + "created_at": { 1971 + "name": "created_at", 1972 + "type": "integer", 1973 + "primaryKey": false, 1974 + "notNull": false, 1975 + "autoincrement": false, 1976 + "default": "(strftime('%s', 'now'))" 1977 + }, 1978 + "updated_at": { 1979 + "name": "updated_at", 1980 + "type": "integer", 1981 + "primaryKey": false, 1982 + "notNull": false, 1983 + "autoincrement": false, 1984 + "default": "(strftime('%s', 'now'))" 1985 + } 1986 + }, 1987 + "indexes": {}, 1988 + "foreignKeys": { 1989 + "monitor_tag_workspace_id_workspace_id_fk": { 1990 + "name": "monitor_tag_workspace_id_workspace_id_fk", 1991 + "tableFrom": "monitor_tag", 1992 + "tableTo": "workspace", 1993 + "columnsFrom": [ 1994 + "workspace_id" 1995 + ], 1996 + "columnsTo": [ 1997 + "id" 1998 + ], 1999 + "onDelete": "cascade", 2000 + "onUpdate": "no action" 2001 + } 2002 + }, 2003 + "compositePrimaryKeys": {}, 2004 + "uniqueConstraints": {}, 2005 + "checkConstraints": {} 2006 + }, 2007 + "monitor_tag_to_monitor": { 2008 + "name": "monitor_tag_to_monitor", 2009 + "columns": { 2010 + "monitor_id": { 2011 + "name": "monitor_id", 2012 + "type": "integer", 2013 + "primaryKey": false, 2014 + "notNull": true, 2015 + "autoincrement": false 2016 + }, 2017 + "monitor_tag_id": { 2018 + "name": "monitor_tag_id", 2019 + "type": "integer", 2020 + "primaryKey": false, 2021 + "notNull": true, 2022 + "autoincrement": false 2023 + }, 2024 + "created_at": { 2025 + "name": "created_at", 2026 + "type": "integer", 2027 + "primaryKey": false, 2028 + "notNull": false, 2029 + "autoincrement": false, 2030 + "default": "(strftime('%s', 'now'))" 2031 + } 2032 + }, 2033 + "indexes": {}, 2034 + "foreignKeys": { 2035 + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { 2036 + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", 2037 + "tableFrom": "monitor_tag_to_monitor", 2038 + "tableTo": "monitor", 2039 + "columnsFrom": [ 2040 + "monitor_id" 2041 + ], 2042 + "columnsTo": [ 2043 + "id" 2044 + ], 2045 + "onDelete": "cascade", 2046 + "onUpdate": "no action" 2047 + }, 2048 + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { 2049 + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", 2050 + "tableFrom": "monitor_tag_to_monitor", 2051 + "tableTo": "monitor_tag", 2052 + "columnsFrom": [ 2053 + "monitor_tag_id" 2054 + ], 2055 + "columnsTo": [ 2056 + "id" 2057 + ], 2058 + "onDelete": "cascade", 2059 + "onUpdate": "no action" 2060 + } 2061 + }, 2062 + "compositePrimaryKeys": { 2063 + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { 2064 + "columns": [ 2065 + "monitor_id", 2066 + "monitor_tag_id" 2067 + ], 2068 + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" 2069 + } 2070 + }, 2071 + "uniqueConstraints": {}, 2072 + "checkConstraints": {} 2073 + }, 2074 + "application": { 2075 + "name": "application", 2076 + "columns": { 2077 + "id": { 2078 + "name": "id", 2079 + "type": "integer", 2080 + "primaryKey": true, 2081 + "notNull": true, 2082 + "autoincrement": false 2083 + }, 2084 + "name": { 2085 + "name": "name", 2086 + "type": "text", 2087 + "primaryKey": false, 2088 + "notNull": false, 2089 + "autoincrement": false 2090 + }, 2091 + "dsn": { 2092 + "name": "dsn", 2093 + "type": "text", 2094 + "primaryKey": false, 2095 + "notNull": false, 2096 + "autoincrement": false 2097 + }, 2098 + "workspace_id": { 2099 + "name": "workspace_id", 2100 + "type": "integer", 2101 + "primaryKey": false, 2102 + "notNull": false, 2103 + "autoincrement": false 2104 + }, 2105 + "created_at": { 2106 + "name": "created_at", 2107 + "type": "integer", 2108 + "primaryKey": false, 2109 + "notNull": false, 2110 + "autoincrement": false, 2111 + "default": "(strftime('%s', 'now'))" 2112 + }, 2113 + "updated_at": { 2114 + "name": "updated_at", 2115 + "type": "integer", 2116 + "primaryKey": false, 2117 + "notNull": false, 2118 + "autoincrement": false, 2119 + "default": "(strftime('%s', 'now'))" 2120 + } 2121 + }, 2122 + "indexes": { 2123 + "application_dsn_unique": { 2124 + "name": "application_dsn_unique", 2125 + "columns": [ 2126 + "dsn" 2127 + ], 2128 + "isUnique": true 2129 + } 2130 + }, 2131 + "foreignKeys": { 2132 + "application_workspace_id_workspace_id_fk": { 2133 + "name": "application_workspace_id_workspace_id_fk", 2134 + "tableFrom": "application", 2135 + "tableTo": "workspace", 2136 + "columnsFrom": [ 2137 + "workspace_id" 2138 + ], 2139 + "columnsTo": [ 2140 + "id" 2141 + ], 2142 + "onDelete": "no action", 2143 + "onUpdate": "no action" 2144 + } 2145 + }, 2146 + "compositePrimaryKeys": {}, 2147 + "uniqueConstraints": {}, 2148 + "checkConstraints": {} 2149 + }, 2150 + "maintenance": { 2151 + "name": "maintenance", 2152 + "columns": { 2153 + "id": { 2154 + "name": "id", 2155 + "type": "integer", 2156 + "primaryKey": true, 2157 + "notNull": true, 2158 + "autoincrement": false 2159 + }, 2160 + "title": { 2161 + "name": "title", 2162 + "type": "text(256)", 2163 + "primaryKey": false, 2164 + "notNull": true, 2165 + "autoincrement": false 2166 + }, 2167 + "message": { 2168 + "name": "message", 2169 + "type": "text", 2170 + "primaryKey": false, 2171 + "notNull": true, 2172 + "autoincrement": false 2173 + }, 2174 + "from": { 2175 + "name": "from", 2176 + "type": "integer", 2177 + "primaryKey": false, 2178 + "notNull": true, 2179 + "autoincrement": false 2180 + }, 2181 + "to": { 2182 + "name": "to", 2183 + "type": "integer", 2184 + "primaryKey": false, 2185 + "notNull": true, 2186 + "autoincrement": false 2187 + }, 2188 + "workspace_id": { 2189 + "name": "workspace_id", 2190 + "type": "integer", 2191 + "primaryKey": false, 2192 + "notNull": false, 2193 + "autoincrement": false 2194 + }, 2195 + "page_id": { 2196 + "name": "page_id", 2197 + "type": "integer", 2198 + "primaryKey": false, 2199 + "notNull": false, 2200 + "autoincrement": false 2201 + }, 2202 + "created_at": { 2203 + "name": "created_at", 2204 + "type": "integer", 2205 + "primaryKey": false, 2206 + "notNull": false, 2207 + "autoincrement": false, 2208 + "default": "(strftime('%s', 'now'))" 2209 + }, 2210 + "updated_at": { 2211 + "name": "updated_at", 2212 + "type": "integer", 2213 + "primaryKey": false, 2214 + "notNull": false, 2215 + "autoincrement": false, 2216 + "default": "(strftime('%s', 'now'))" 2217 + } 2218 + }, 2219 + "indexes": {}, 2220 + "foreignKeys": { 2221 + "maintenance_workspace_id_workspace_id_fk": { 2222 + "name": "maintenance_workspace_id_workspace_id_fk", 2223 + "tableFrom": "maintenance", 2224 + "tableTo": "workspace", 2225 + "columnsFrom": [ 2226 + "workspace_id" 2227 + ], 2228 + "columnsTo": [ 2229 + "id" 2230 + ], 2231 + "onDelete": "no action", 2232 + "onUpdate": "no action" 2233 + }, 2234 + "maintenance_page_id_page_id_fk": { 2235 + "name": "maintenance_page_id_page_id_fk", 2236 + "tableFrom": "maintenance", 2237 + "tableTo": "page", 2238 + "columnsFrom": [ 2239 + "page_id" 2240 + ], 2241 + "columnsTo": [ 2242 + "id" 2243 + ], 2244 + "onDelete": "cascade", 2245 + "onUpdate": "no action" 2246 + } 2247 + }, 2248 + "compositePrimaryKeys": {}, 2249 + "uniqueConstraints": {}, 2250 + "checkConstraints": {} 2251 + }, 2252 + "maintenance_to_monitor": { 2253 + "name": "maintenance_to_monitor", 2254 + "columns": { 2255 + "maintenance_id": { 2256 + "name": "maintenance_id", 2257 + "type": "integer", 2258 + "primaryKey": false, 2259 + "notNull": true, 2260 + "autoincrement": false 2261 + }, 2262 + "monitor_id": { 2263 + "name": "monitor_id", 2264 + "type": "integer", 2265 + "primaryKey": false, 2266 + "notNull": true, 2267 + "autoincrement": false 2268 + }, 2269 + "created_at": { 2270 + "name": "created_at", 2271 + "type": "integer", 2272 + "primaryKey": false, 2273 + "notNull": false, 2274 + "autoincrement": false, 2275 + "default": "(strftime('%s', 'now'))" 2276 + } 2277 + }, 2278 + "indexes": {}, 2279 + "foreignKeys": { 2280 + "maintenance_to_monitor_maintenance_id_maintenance_id_fk": { 2281 + "name": "maintenance_to_monitor_maintenance_id_maintenance_id_fk", 2282 + "tableFrom": "maintenance_to_monitor", 2283 + "tableTo": "maintenance", 2284 + "columnsFrom": [ 2285 + "maintenance_id" 2286 + ], 2287 + "columnsTo": [ 2288 + "id" 2289 + ], 2290 + "onDelete": "cascade", 2291 + "onUpdate": "no action" 2292 + }, 2293 + "maintenance_to_monitor_monitor_id_monitor_id_fk": { 2294 + "name": "maintenance_to_monitor_monitor_id_monitor_id_fk", 2295 + "tableFrom": "maintenance_to_monitor", 2296 + "tableTo": "monitor", 2297 + "columnsFrom": [ 2298 + "monitor_id" 2299 + ], 2300 + "columnsTo": [ 2301 + "id" 2302 + ], 2303 + "onDelete": "cascade", 2304 + "onUpdate": "no action" 2305 + } 2306 + }, 2307 + "compositePrimaryKeys": { 2308 + "maintenance_to_monitor_maintenance_id_monitor_id_pk": { 2309 + "columns": [ 2310 + "maintenance_id", 2311 + "monitor_id" 2312 + ], 2313 + "name": "maintenance_to_monitor_maintenance_id_monitor_id_pk" 2314 + } 2315 + }, 2316 + "uniqueConstraints": {}, 2317 + "checkConstraints": {} 2318 + }, 2319 + "check": { 2320 + "name": "check", 2321 + "columns": { 2322 + "id": { 2323 + "name": "id", 2324 + "type": "integer", 2325 + "primaryKey": true, 2326 + "notNull": true, 2327 + "autoincrement": true 2328 + }, 2329 + "regions": { 2330 + "name": "regions", 2331 + "type": "text", 2332 + "primaryKey": false, 2333 + "notNull": true, 2334 + "autoincrement": false, 2335 + "default": "''" 2336 + }, 2337 + "url": { 2338 + "name": "url", 2339 + "type": "text(4096)", 2340 + "primaryKey": false, 2341 + "notNull": true, 2342 + "autoincrement": false 2343 + }, 2344 + "headers": { 2345 + "name": "headers", 2346 + "type": "text", 2347 + "primaryKey": false, 2348 + "notNull": false, 2349 + "autoincrement": false, 2350 + "default": "''" 2351 + }, 2352 + "body": { 2353 + "name": "body", 2354 + "type": "text", 2355 + "primaryKey": false, 2356 + "notNull": false, 2357 + "autoincrement": false, 2358 + "default": "''" 2359 + }, 2360 + "method": { 2361 + "name": "method", 2362 + "type": "text", 2363 + "primaryKey": false, 2364 + "notNull": false, 2365 + "autoincrement": false, 2366 + "default": "'GET'" 2367 + }, 2368 + "count_requests": { 2369 + "name": "count_requests", 2370 + "type": "integer", 2371 + "primaryKey": false, 2372 + "notNull": false, 2373 + "autoincrement": false, 2374 + "default": 1 2375 + }, 2376 + "workspace_id": { 2377 + "name": "workspace_id", 2378 + "type": "integer", 2379 + "primaryKey": false, 2380 + "notNull": false, 2381 + "autoincrement": false 2382 + }, 2383 + "created_at": { 2384 + "name": "created_at", 2385 + "type": "integer", 2386 + "primaryKey": false, 2387 + "notNull": false, 2388 + "autoincrement": false, 2389 + "default": "(strftime('%s', 'now'))" 2390 + } 2391 + }, 2392 + "indexes": {}, 2393 + "foreignKeys": { 2394 + "check_workspace_id_workspace_id_fk": { 2395 + "name": "check_workspace_id_workspace_id_fk", 2396 + "tableFrom": "check", 2397 + "tableTo": "workspace", 2398 + "columnsFrom": [ 2399 + "workspace_id" 2400 + ], 2401 + "columnsTo": [ 2402 + "id" 2403 + ], 2404 + "onDelete": "no action", 2405 + "onUpdate": "no action" 2406 + } 2407 + }, 2408 + "compositePrimaryKeys": {}, 2409 + "uniqueConstraints": {}, 2410 + "checkConstraints": {} 2411 + }, 2412 + "monitor_run": { 2413 + "name": "monitor_run", 2414 + "columns": { 2415 + "id": { 2416 + "name": "id", 2417 + "type": "integer", 2418 + "primaryKey": true, 2419 + "notNull": true, 2420 + "autoincrement": false 2421 + }, 2422 + "workspace_id": { 2423 + "name": "workspace_id", 2424 + "type": "integer", 2425 + "primaryKey": false, 2426 + "notNull": false, 2427 + "autoincrement": false 2428 + }, 2429 + "monitor_id": { 2430 + "name": "monitor_id", 2431 + "type": "integer", 2432 + "primaryKey": false, 2433 + "notNull": false, 2434 + "autoincrement": false 2435 + }, 2436 + "runned_at": { 2437 + "name": "runned_at", 2438 + "type": "integer", 2439 + "primaryKey": false, 2440 + "notNull": false, 2441 + "autoincrement": false 2442 + }, 2443 + "created_at": { 2444 + "name": "created_at", 2445 + "type": "integer", 2446 + "primaryKey": false, 2447 + "notNull": false, 2448 + "autoincrement": false, 2449 + "default": "(strftime('%s', 'now'))" 2450 + } 2451 + }, 2452 + "indexes": {}, 2453 + "foreignKeys": { 2454 + "monitor_run_workspace_id_workspace_id_fk": { 2455 + "name": "monitor_run_workspace_id_workspace_id_fk", 2456 + "tableFrom": "monitor_run", 2457 + "tableTo": "workspace", 2458 + "columnsFrom": [ 2459 + "workspace_id" 2460 + ], 2461 + "columnsTo": [ 2462 + "id" 2463 + ], 2464 + "onDelete": "no action", 2465 + "onUpdate": "no action" 2466 + }, 2467 + "monitor_run_monitor_id_monitor_id_fk": { 2468 + "name": "monitor_run_monitor_id_monitor_id_fk", 2469 + "tableFrom": "monitor_run", 2470 + "tableTo": "monitor", 2471 + "columnsFrom": [ 2472 + "monitor_id" 2473 + ], 2474 + "columnsTo": [ 2475 + "id" 2476 + ], 2477 + "onDelete": "no action", 2478 + "onUpdate": "no action" 2479 + } 2480 + }, 2481 + "compositePrimaryKeys": {}, 2482 + "uniqueConstraints": {}, 2483 + "checkConstraints": {} 2484 + }, 2485 + "private_location": { 2486 + "name": "private_location", 2487 + "columns": { 2488 + "id": { 2489 + "name": "id", 2490 + "type": "integer", 2491 + "primaryKey": true, 2492 + "notNull": true, 2493 + "autoincrement": false 2494 + }, 2495 + "name": { 2496 + "name": "name", 2497 + "type": "text", 2498 + "primaryKey": false, 2499 + "notNull": true, 2500 + "autoincrement": false 2501 + }, 2502 + "token": { 2503 + "name": "token", 2504 + "type": "text", 2505 + "primaryKey": false, 2506 + "notNull": true, 2507 + "autoincrement": false 2508 + }, 2509 + "last_seen_at": { 2510 + "name": "last_seen_at", 2511 + "type": "integer", 2512 + "primaryKey": false, 2513 + "notNull": false, 2514 + "autoincrement": false 2515 + }, 2516 + "workspace_id": { 2517 + "name": "workspace_id", 2518 + "type": "integer", 2519 + "primaryKey": false, 2520 + "notNull": false, 2521 + "autoincrement": false 2522 + }, 2523 + "created_at": { 2524 + "name": "created_at", 2525 + "type": "integer", 2526 + "primaryKey": false, 2527 + "notNull": false, 2528 + "autoincrement": false, 2529 + "default": "(strftime('%s', 'now'))" 2530 + }, 2531 + "updated_at": { 2532 + "name": "updated_at", 2533 + "type": "integer", 2534 + "primaryKey": false, 2535 + "notNull": false, 2536 + "autoincrement": false, 2537 + "default": "(strftime('%s', 'now'))" 2538 + } 2539 + }, 2540 + "indexes": {}, 2541 + "foreignKeys": { 2542 + "private_location_workspace_id_workspace_id_fk": { 2543 + "name": "private_location_workspace_id_workspace_id_fk", 2544 + "tableFrom": "private_location", 2545 + "tableTo": "workspace", 2546 + "columnsFrom": [ 2547 + "workspace_id" 2548 + ], 2549 + "columnsTo": [ 2550 + "id" 2551 + ], 2552 + "onDelete": "no action", 2553 + "onUpdate": "no action" 2554 + } 2555 + }, 2556 + "compositePrimaryKeys": {}, 2557 + "uniqueConstraints": {}, 2558 + "checkConstraints": {} 2559 + }, 2560 + "private_location_to_monitor": { 2561 + "name": "private_location_to_monitor", 2562 + "columns": { 2563 + "private_location_id": { 2564 + "name": "private_location_id", 2565 + "type": "integer", 2566 + "primaryKey": false, 2567 + "notNull": false, 2568 + "autoincrement": false 2569 + }, 2570 + "monitor_id": { 2571 + "name": "monitor_id", 2572 + "type": "integer", 2573 + "primaryKey": false, 2574 + "notNull": false, 2575 + "autoincrement": false 2576 + }, 2577 + "created_at": { 2578 + "name": "created_at", 2579 + "type": "integer", 2580 + "primaryKey": false, 2581 + "notNull": false, 2582 + "autoincrement": false, 2583 + "default": "(strftime('%s', 'now'))" 2584 + }, 2585 + "deleted_at": { 2586 + "name": "deleted_at", 2587 + "type": "integer", 2588 + "primaryKey": false, 2589 + "notNull": false, 2590 + "autoincrement": false 2591 + } 2592 + }, 2593 + "indexes": {}, 2594 + "foreignKeys": { 2595 + "private_location_to_monitor_private_location_id_private_location_id_fk": { 2596 + "name": "private_location_to_monitor_private_location_id_private_location_id_fk", 2597 + "tableFrom": "private_location_to_monitor", 2598 + "tableTo": "private_location", 2599 + "columnsFrom": [ 2600 + "private_location_id" 2601 + ], 2602 + "columnsTo": [ 2603 + "id" 2604 + ], 2605 + "onDelete": "cascade", 2606 + "onUpdate": "no action" 2607 + }, 2608 + "private_location_to_monitor_monitor_id_monitor_id_fk": { 2609 + "name": "private_location_to_monitor_monitor_id_monitor_id_fk", 2610 + "tableFrom": "private_location_to_monitor", 2611 + "tableTo": "monitor", 2612 + "columnsFrom": [ 2613 + "monitor_id" 2614 + ], 2615 + "columnsTo": [ 2616 + "id" 2617 + ], 2618 + "onDelete": "cascade", 2619 + "onUpdate": "no action" 2620 + } 2621 + }, 2622 + "compositePrimaryKeys": {}, 2623 + "uniqueConstraints": {}, 2624 + "checkConstraints": {} 2625 + }, 2626 + "monitor_group": { 2627 + "name": "monitor_group", 2628 + "columns": { 2629 + "id": { 2630 + "name": "id", 2631 + "type": "integer", 2632 + "primaryKey": true, 2633 + "notNull": true, 2634 + "autoincrement": false 2635 + }, 2636 + "workspace_id": { 2637 + "name": "workspace_id", 2638 + "type": "integer", 2639 + "primaryKey": false, 2640 + "notNull": true, 2641 + "autoincrement": false 2642 + }, 2643 + "page_id": { 2644 + "name": "page_id", 2645 + "type": "integer", 2646 + "primaryKey": false, 2647 + "notNull": true, 2648 + "autoincrement": false 2649 + }, 2650 + "name": { 2651 + "name": "name", 2652 + "type": "text", 2653 + "primaryKey": false, 2654 + "notNull": true, 2655 + "autoincrement": false 2656 + }, 2657 + "created_at": { 2658 + "name": "created_at", 2659 + "type": "integer", 2660 + "primaryKey": false, 2661 + "notNull": false, 2662 + "autoincrement": false, 2663 + "default": "(strftime('%s', 'now'))" 2664 + }, 2665 + "updated_at": { 2666 + "name": "updated_at", 2667 + "type": "integer", 2668 + "primaryKey": false, 2669 + "notNull": false, 2670 + "autoincrement": false, 2671 + "default": "(strftime('%s', 'now'))" 2672 + } 2673 + }, 2674 + "indexes": {}, 2675 + "foreignKeys": { 2676 + "monitor_group_workspace_id_workspace_id_fk": { 2677 + "name": "monitor_group_workspace_id_workspace_id_fk", 2678 + "tableFrom": "monitor_group", 2679 + "tableTo": "workspace", 2680 + "columnsFrom": [ 2681 + "workspace_id" 2682 + ], 2683 + "columnsTo": [ 2684 + "id" 2685 + ], 2686 + "onDelete": "cascade", 2687 + "onUpdate": "no action" 2688 + }, 2689 + "monitor_group_page_id_page_id_fk": { 2690 + "name": "monitor_group_page_id_page_id_fk", 2691 + "tableFrom": "monitor_group", 2692 + "tableTo": "page", 2693 + "columnsFrom": [ 2694 + "page_id" 2695 + ], 2696 + "columnsTo": [ 2697 + "id" 2698 + ], 2699 + "onDelete": "cascade", 2700 + "onUpdate": "no action" 2701 + } 2702 + }, 2703 + "compositePrimaryKeys": {}, 2704 + "uniqueConstraints": {}, 2705 + "checkConstraints": {} 2706 + }, 2707 + "viewer": { 2708 + "name": "viewer", 2709 + "columns": { 2710 + "id": { 2711 + "name": "id", 2712 + "type": "integer", 2713 + "primaryKey": true, 2714 + "notNull": true, 2715 + "autoincrement": false 2716 + }, 2717 + "name": { 2718 + "name": "name", 2719 + "type": "text", 2720 + "primaryKey": false, 2721 + "notNull": false, 2722 + "autoincrement": false 2723 + }, 2724 + "email": { 2725 + "name": "email", 2726 + "type": "text", 2727 + "primaryKey": false, 2728 + "notNull": false, 2729 + "autoincrement": false 2730 + }, 2731 + "emailVerified": { 2732 + "name": "emailVerified", 2733 + "type": "integer", 2734 + "primaryKey": false, 2735 + "notNull": false, 2736 + "autoincrement": false 2737 + }, 2738 + "image": { 2739 + "name": "image", 2740 + "type": "text", 2741 + "primaryKey": false, 2742 + "notNull": false, 2743 + "autoincrement": false 2744 + }, 2745 + "created_at": { 2746 + "name": "created_at", 2747 + "type": "integer", 2748 + "primaryKey": false, 2749 + "notNull": false, 2750 + "autoincrement": false, 2751 + "default": "(strftime('%s', 'now'))" 2752 + }, 2753 + "updated_at": { 2754 + "name": "updated_at", 2755 + "type": "integer", 2756 + "primaryKey": false, 2757 + "notNull": false, 2758 + "autoincrement": false, 2759 + "default": "(strftime('%s', 'now'))" 2760 + } 2761 + }, 2762 + "indexes": { 2763 + "viewer_email_unique": { 2764 + "name": "viewer_email_unique", 2765 + "columns": [ 2766 + "email" 2767 + ], 2768 + "isUnique": true 2769 + } 2770 + }, 2771 + "foreignKeys": {}, 2772 + "compositePrimaryKeys": {}, 2773 + "uniqueConstraints": {}, 2774 + "checkConstraints": {} 2775 + }, 2776 + "viewer_accounts": { 2777 + "name": "viewer_accounts", 2778 + "columns": { 2779 + "user_id": { 2780 + "name": "user_id", 2781 + "type": "text", 2782 + "primaryKey": false, 2783 + "notNull": true, 2784 + "autoincrement": false 2785 + }, 2786 + "type": { 2787 + "name": "type", 2788 + "type": "text", 2789 + "primaryKey": false, 2790 + "notNull": true, 2791 + "autoincrement": false 2792 + }, 2793 + "provider": { 2794 + "name": "provider", 2795 + "type": "text", 2796 + "primaryKey": false, 2797 + "notNull": true, 2798 + "autoincrement": false 2799 + }, 2800 + "providerAccountId": { 2801 + "name": "providerAccountId", 2802 + "type": "text", 2803 + "primaryKey": false, 2804 + "notNull": true, 2805 + "autoincrement": false 2806 + }, 2807 + "refresh_token": { 2808 + "name": "refresh_token", 2809 + "type": "text", 2810 + "primaryKey": false, 2811 + "notNull": false, 2812 + "autoincrement": false 2813 + }, 2814 + "access_token": { 2815 + "name": "access_token", 2816 + "type": "text", 2817 + "primaryKey": false, 2818 + "notNull": false, 2819 + "autoincrement": false 2820 + }, 2821 + "expires_at": { 2822 + "name": "expires_at", 2823 + "type": "integer", 2824 + "primaryKey": false, 2825 + "notNull": false, 2826 + "autoincrement": false 2827 + }, 2828 + "token_type": { 2829 + "name": "token_type", 2830 + "type": "text", 2831 + "primaryKey": false, 2832 + "notNull": false, 2833 + "autoincrement": false 2834 + }, 2835 + "scope": { 2836 + "name": "scope", 2837 + "type": "text", 2838 + "primaryKey": false, 2839 + "notNull": false, 2840 + "autoincrement": false 2841 + }, 2842 + "id_token": { 2843 + "name": "id_token", 2844 + "type": "text", 2845 + "primaryKey": false, 2846 + "notNull": false, 2847 + "autoincrement": false 2848 + }, 2849 + "session_state": { 2850 + "name": "session_state", 2851 + "type": "text", 2852 + "primaryKey": false, 2853 + "notNull": false, 2854 + "autoincrement": false 2855 + } 2856 + }, 2857 + "indexes": {}, 2858 + "foreignKeys": { 2859 + "viewer_accounts_user_id_viewer_id_fk": { 2860 + "name": "viewer_accounts_user_id_viewer_id_fk", 2861 + "tableFrom": "viewer_accounts", 2862 + "tableTo": "viewer", 2863 + "columnsFrom": [ 2864 + "user_id" 2865 + ], 2866 + "columnsTo": [ 2867 + "id" 2868 + ], 2869 + "onDelete": "cascade", 2870 + "onUpdate": "no action" 2871 + } 2872 + }, 2873 + "compositePrimaryKeys": { 2874 + "viewer_accounts_provider_providerAccountId_pk": { 2875 + "columns": [ 2876 + "provider", 2877 + "providerAccountId" 2878 + ], 2879 + "name": "viewer_accounts_provider_providerAccountId_pk" 2880 + } 2881 + }, 2882 + "uniqueConstraints": {}, 2883 + "checkConstraints": {} 2884 + }, 2885 + "viewer_session": { 2886 + "name": "viewer_session", 2887 + "columns": { 2888 + "session_token": { 2889 + "name": "session_token", 2890 + "type": "text", 2891 + "primaryKey": true, 2892 + "notNull": true, 2893 + "autoincrement": false 2894 + }, 2895 + "user_id": { 2896 + "name": "user_id", 2897 + "type": "integer", 2898 + "primaryKey": false, 2899 + "notNull": true, 2900 + "autoincrement": false 2901 + }, 2902 + "expires": { 2903 + "name": "expires", 2904 + "type": "integer", 2905 + "primaryKey": false, 2906 + "notNull": true, 2907 + "autoincrement": false 2908 + } 2909 + }, 2910 + "indexes": {}, 2911 + "foreignKeys": { 2912 + "viewer_session_user_id_viewer_id_fk": { 2913 + "name": "viewer_session_user_id_viewer_id_fk", 2914 + "tableFrom": "viewer_session", 2915 + "tableTo": "viewer", 2916 + "columnsFrom": [ 2917 + "user_id" 2918 + ], 2919 + "columnsTo": [ 2920 + "id" 2921 + ], 2922 + "onDelete": "cascade", 2923 + "onUpdate": "no action" 2924 + } 2925 + }, 2926 + "compositePrimaryKeys": {}, 2927 + "uniqueConstraints": {}, 2928 + "checkConstraints": {} 2929 + } 2930 + }, 2931 + "views": {}, 2932 + "enums": {}, 2933 + "_meta": { 2934 + "schemas": {}, 2935 + "tables": {}, 2936 + "columns": {} 2937 + }, 2938 + "internal": { 2939 + "indexes": {} 2940 + } 2941 + }
+7
packages/db/drizzle/meta/_journal.json
··· 358 358 "when": 1765567734101, 359 359 "tag": "0050_damp_xorn", 360 360 "breakpoints": true 361 + }, 362 + { 363 + "idx": 51, 364 + "version": "6", 365 + "when": 1767362130713, 366 + "tag": "0051_fuzzy_red_hulk", 367 + "breakpoints": true 361 368 } 362 369 ] 363 370 }
+1
packages/db/src/schema/index.ts
··· 17 17 export * from "./monitor_run"; 18 18 export * from "./private_locations"; 19 19 export * from "./monitor_groups"; 20 + export * from "./viewers";
+2
packages/db/src/schema/pages/constants.ts
··· 13 13 "ssh", 14 14 "themes", 15 15 ]; 16 + 17 + export const pageAccessTypes = ["public", "password", "email-domain"] as const;
+5
packages/db/src/schema/pages/page.ts
··· 6 6 import { pageSubscriber } from "../page_subscribers"; 7 7 import { statusReport } from "../status_reports"; 8 8 import { workspace } from "../workspaces"; 9 + import { pageAccessTypes } from "./constants"; 9 10 10 11 export const page = sqliteTable("page", { 11 12 id: integer("id").primaryKey(), ··· 27 28 28 29 // Password protecting the status page - no specific restriction on password 29 30 password: text("password", { length: 256 }), 31 + // @deprecated: instead, use accessType 30 32 passwordProtected: integer("password_protected", { mode: "boolean" }).default( 31 33 false, 32 34 ), 35 + accessType: text("access_type", { enum: pageAccessTypes }).default("public"), 36 + authEmailDomains: text("auth_email_domains", { mode: "text" }), // TODO: change to json 33 37 34 38 // links and urls 35 39 homepageUrl: text("homepage_url", { length: 256 }), ··· 42 46 43 47 /** 44 48 * Displays the total and failed request numbers for each monitor 49 + * TODO: remove this column - we moved into configuration 45 50 */ 46 51 showMonitorValues: integer("show_monitor_values", { 47 52 mode: "boolean",
+12
packages/db/src/schema/pages/validation.ts
··· 3 3 import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 4 4 import { z } from "zod"; 5 5 6 + import { pageAccessTypes } from "./constants"; 6 7 import { page } from "./page"; 7 8 8 9 const slugSchema = z ··· 22 23 ) 23 24 .or(z.enum([""])); 24 25 26 + const stringToArray = z.preprocess((val) => { 27 + if (val && String(val).length > 0) { 28 + return String(val).split(","); 29 + } 30 + return []; 31 + }, z.array(z.string())); 32 + 25 33 export const insertPageSchema = createInsertSchema(page, { 26 34 customDomain: customDomainSchema.prefault(""), 35 + accessType: z.enum(pageAccessTypes).prefault("public"), 27 36 icon: z.string().optional(), 28 37 slug: slugSchema, 29 38 }).extend({ ··· 38 47 ) 39 48 .optional() 40 49 .prefault([]), 50 + authEmailDomains: z.array(z.string()).nullish(), 41 51 }); 42 52 43 53 export const pageConfigurationSchema = z.object({ ··· 56 66 export const selectPageSchema = createSelectSchema(page).extend({ 57 67 password: z.string().optional().nullable().prefault(""), 58 68 configuration: pageConfigurationSchema.nullish().prefault({}), 69 + accessType: z.enum(pageAccessTypes).prefault("public"), 70 + authEmailDomains: stringToArray.prefault([]), 59 71 }); 60 72 61 73 export type InsertPage = z.infer<typeof insertPageSchema>;
+37 -19
packages/db/src/schema/plan/config.ts
··· 1 1 import { AVAILABLE_REGIONS, FREE_FLY_REGIONS } from "@openstatus/regions"; 2 2 import type { WorkspacePlan } from "../workspaces/validation"; 3 - import type { Limits } from "./schema"; 3 + import type { Addons, PlanLimits, Price } from "./schema"; 4 + 5 + type PlanConfig = { 6 + title: "Hobby" | "Starter" | "Pro"; 7 + id: WorkspacePlan; 8 + description: string; 9 + price: Price; 10 + addons: Partial<Addons>; 11 + limits: PlanLimits; 12 + }; 4 13 5 14 // TODO: rename to `planConfig` 6 - export const allPlans: Record< 7 - WorkspacePlan, 8 - { 9 - title: "Hobby" | "Starter" | "Pro"; 10 - id: WorkspacePlan; 11 - description: string; 12 - price: { 13 - USD: number; 14 - EUR: number; 15 - INR: number; 16 - }; 17 - limits: Limits; 18 - } 19 - > = { 15 + export const allPlans: Record<WorkspacePlan, PlanConfig> = { 20 16 free: { 21 17 title: "Hobby", 22 18 id: "free", ··· 26 22 EUR: 0, 27 23 INR: 0, 28 24 }, 25 + addons: {}, 29 26 limits: { 30 27 version: undefined, 31 28 monitors: 1, ··· 43 40 "status-subscribers": false, 44 41 "custom-domain": false, 45 42 "password-protection": false, 43 + "email-domain-protection": false, 46 44 "white-label": false, 47 45 notifications: true, 48 46 sms: false, 49 47 "sms-limit": 0, 50 48 pagerduty: false, 51 49 opsgenie: false, 50 + whatsapp: false, 52 51 "notification-channels": 1, 53 52 members: 1, 54 53 "audit-log": false, 55 54 regions: [...FREE_FLY_REGIONS], 56 55 "private-locations": false, 57 - whatsapp: false, 58 56 }, 59 57 }, 60 58 starter: { ··· 66 64 EUR: 30, 67 65 INR: 3000, 68 66 }, 67 + addons: { 68 + "email-domain-protection": { 69 + price: { 70 + USD: 100, 71 + EUR: 100, 72 + INR: 10_000, 73 + }, 74 + }, 75 + }, 69 76 limits: { 70 77 version: undefined, 71 78 monitors: 20, ··· 83 90 "status-subscribers": true, 84 91 "custom-domain": true, 85 92 "password-protection": true, 93 + "email-domain-protection": false, 86 94 "white-label": false, 87 95 notifications: true, 88 96 pagerduty: true, 89 97 opsgenie: true, 98 + whatsapp: true, 90 99 sms: true, 91 100 "sms-limit": 50, 92 101 "notification-channels": 10, ··· 94 103 "audit-log": false, 95 104 regions: [...AVAILABLE_REGIONS], 96 105 "private-locations": false, 97 - whatsapp: true, 98 106 }, 99 107 }, 100 108 team: { ··· 104 112 price: { 105 113 USD: 100, 106 114 EUR: 100, 107 - INR: 10000, 115 + INR: 10_000, 116 + }, 117 + addons: { 118 + "email-domain-protection": { 119 + price: { 120 + USD: 100, 121 + EUR: 100, 122 + INR: 10_000, 123 + }, 124 + }, 108 125 }, 109 126 limits: { 110 127 version: undefined, ··· 123 140 "status-subscribers": true, 124 141 "custom-domain": true, 125 142 "password-protection": true, 143 + "email-domain-protection": false, 126 144 "white-label": false, 127 145 notifications: true, 128 146 sms: true, 129 147 "sms-limit": 100, 130 148 pagerduty: true, 131 149 opsgenie: true, 150 + whatsapp: true, 132 151 "notification-channels": 20, 133 152 members: "Unlimited", 134 153 "audit-log": true, 135 154 regions: [...AVAILABLE_REGIONS], 136 155 "private-locations": true, 137 - whatsapp: true, 138 156 }, 139 157 }, 140 158 };
+36
packages/db/src/schema/plan/schema.ts
··· 32 32 "status-subscribers": z.boolean().prefault(false), 33 33 "custom-domain": z.boolean().prefault(false), 34 34 "password-protection": z.boolean().prefault(false), 35 + "email-domain-protection": z.boolean().prefault(false), // add-on but required in limits 35 36 "white-label": z.boolean().prefault(false), 36 37 /** 37 38 * Notification limits ··· 53 54 }); 54 55 55 56 export type Limits = z.infer<typeof limitsSchema>; 57 + 58 + // 59 + 60 + const priceSchema = z.object({ 61 + USD: z.number(), 62 + EUR: z.number(), 63 + INR: z.number(), 64 + }); 65 + 66 + export type Price = z.infer<typeof priceSchema>; 67 + 68 + export const addons = ["email-domain-protection"] as const satisfies Partial< 69 + keyof Limits 70 + >[]; 71 + 72 + export const addonsSchema = z.partialRecord( 73 + z.enum(addons), 74 + z.object({ 75 + price: priceSchema, 76 + }), 77 + ) satisfies z.ZodType<Partial<Record<keyof Limits, { price: Price }>>>; 78 + 79 + export type Addons = z.infer<typeof addonsSchema>; 80 + 81 + /** 82 + * Enforces that addon keys in Limits must be set to false in plan configs 83 + * (since addons can only be enabled by purchasing them) 84 + */ 85 + export type PlanLimits = { 86 + [K in keyof Limits]: K extends keyof Addons 87 + ? Limits[K] extends boolean 88 + ? false // Force addon boolean fields to false 89 + : Limits[K] // Non-boolean fields stay as-is 90 + : Limits[K]; // Non-addon fields stay as-is 91 + };
+73 -9
packages/db/src/schema/plan/utils.ts
··· 1 1 import type { WorkspacePlan } from "../workspaces/validation"; 2 2 import { allPlans } from "./config"; 3 - import type { Limits } from "./schema"; 3 + import { type Addons, type Limits, limitsSchema } from "./schema"; 4 4 5 5 export function getLimit<T extends keyof Limits>(limits: Limits, limit: T) { 6 6 return limits[limit] || allPlans.free.limits[limit]; ··· 30 30 return "USD"; 31 31 } 32 32 33 + type PriceObject = { 34 + USD: number; 35 + EUR: number; 36 + INR: number; 37 + }; 38 + 39 + type PriceConfig = { 40 + value: number; 41 + locale: string; 42 + currency: string; 43 + }; 44 + 45 + function getLocaleForCurrency(currency: string): string { 46 + return currency === "EUR" ? "fr-FR" : "en-US"; 47 + } 48 + 49 + function resolvePriceConfig( 50 + price: PriceObject, 51 + currency?: string, 52 + ): PriceConfig { 53 + const effectiveCurrency = currency && currency in price ? currency : "USD"; 54 + const value = price[effectiveCurrency as keyof PriceObject]; 55 + const locale = getLocaleForCurrency(effectiveCurrency); 56 + 57 + return { value, locale, currency: effectiveCurrency }; 58 + } 59 + 33 60 export function getPriceConfig(plan: WorkspacePlan, currency?: string) { 34 61 const planConfig = allPlans[plan]; 35 - if (!currency) { 36 - return { value: planConfig.price.USD, locale: "en-US", currency: "USD" }; 37 - } 38 - if (currency in planConfig.price) { 39 - const value = planConfig.price[currency as keyof typeof planConfig.price]; 40 - const locale = currency === "EUR" ? "fr-FR" : "en-US"; 41 - return { value, locale, currency }; 62 + return resolvePriceConfig(planConfig.price, currency); 63 + } 64 + 65 + export function getAddonPriceConfig( 66 + plan: WorkspacePlan, 67 + addon: keyof Addons, 68 + currency?: string, 69 + ) { 70 + const addonConfig = allPlans[plan].addons[addon]; 71 + if (!addonConfig) { 72 + return null; 42 73 } 43 - return { value: planConfig.price.USD, locale: "en-US", currency: "USD" }; 74 + return resolvePriceConfig(addonConfig.price, currency); 44 75 } 45 76 46 77 export function getPlansForLimit( ··· 96 127 return false; 97 128 }); 98 129 } 130 + 131 + /** 132 + * Add or remove an addon from limits 133 + * Automatically infers addon type (toggle/quantity) from the limit field type 134 + * @param limits - Current workspace limits 135 + * @param addon - Addon key to add/remove 136 + * @param action - "add" to enable/increment, "remove" to disable/decrement 137 + * @param quantity - Optional quantity for quantity-based addons (defaults to 1) 138 + * @returns Updated limits object 139 + */ 140 + export function updateAddonInLimits( 141 + limits: Limits, 142 + addon: keyof Addons, 143 + action: "add" | "remove", 144 + _quantity = 1, 145 + ): Limits { 146 + const currentValue = limits[addon]; 147 + const newLimits = { ...limits }; 148 + 149 + // Infer addon type from the limit field type 150 + if (typeof currentValue === "boolean") { 151 + // Toggle addon: boolean on/off 152 + newLimits[addon] = action === "add"; 153 + } else if (typeof currentValue === "number") { 154 + // Quantity addon: increment/decrement 155 + // newLimits[addon] = Math.max( 156 + // 0, 157 + // currentValue + (action === "add" ? quantity : -quantity) 158 + // ); // Don't go below 0 159 + } 160 + 161 + return limitsSchema.parse(newLimits); 162 + }
+2
packages/db/src/schema/viewers/index.ts
··· 1 + export * from "./viewer"; 2 + export * from "./validation";
+11
packages/db/src/schema/viewers/validation.ts
··· 1 + import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 2 + import type { z } from "zod"; 3 + 4 + import { viewer } from "./viewer"; 5 + 6 + export const insertViewerSchema = createInsertSchema(viewer); 7 + 8 + export const selectViewerSchema = createSelectSchema(viewer); 9 + 10 + export type InsertViewer = z.infer<typeof insertViewerSchema>; 11 + export type Viewer = z.infer<typeof selectViewerSchema>;
+55
packages/db/src/schema/viewers/viewer.ts
··· 1 + import { sql } from "drizzle-orm"; 2 + import { 3 + integer, 4 + primaryKey, 5 + sqliteTable, 6 + text, 7 + } from "drizzle-orm/sqlite-core"; 8 + 9 + import type { AdapterAccountType } from "next-auth/adapters"; 10 + 11 + export const viewer = sqliteTable("viewer", { 12 + id: integer("id").primaryKey(), 13 + name: text("name"), 14 + email: text("email").unique(), 15 + emailVerified: integer("emailVerified", { mode: "timestamp" }), 16 + image: text("image"), 17 + createdAt: integer("created_at", { mode: "timestamp" }).default( 18 + sql`(strftime('%s', 'now'))`, 19 + ), 20 + updatedAt: integer("updated_at", { mode: "timestamp" }).default( 21 + sql`(strftime('%s', 'now'))`, 22 + ), 23 + }); 24 + 25 + export const viewerSession = sqliteTable("viewer_session", { 26 + sessionToken: text("session_token").primaryKey(), 27 + userId: integer("user_id") 28 + .notNull() 29 + .references(() => viewer.id, { onDelete: "cascade" }), 30 + expires: integer("expires", { mode: "timestamp_ms" }).notNull(), 31 + }); 32 + 33 + export const viewerAccounts = sqliteTable( 34 + "viewer_accounts", 35 + { 36 + userId: text("user_id") 37 + .notNull() 38 + .references(() => viewer.id, { onDelete: "cascade" }), 39 + type: text("type").$type<AdapterAccountType>().notNull(), 40 + provider: text("provider").notNull(), 41 + providerAccountId: text("providerAccountId").notNull(), 42 + refresh_token: text("refresh_token"), 43 + access_token: text("access_token"), 44 + expires_at: integer("expires_at"), 45 + token_type: text("token_type"), 46 + scope: text("scope"), 47 + id_token: text("id_token"), 48 + session_state: text("session_state"), 49 + }, 50 + (account) => [ 51 + primaryKey({ 52 + columns: [account.provider, account.providerAccountId], 53 + }), 54 + ], 55 + );
+55
packages/emails/emails/status-page-magic-link.tsx
··· 1 + /** @jsxImportSource react */ 2 + 3 + import { 4 + Body, 5 + Head, 6 + Heading, 7 + Html, 8 + Link, 9 + Preview, 10 + Text, 11 + } from "@react-email/components"; 12 + import { Layout } from "./_components/layout"; 13 + import { styles } from "./_components/styles"; 14 + 15 + export interface StatusPageMagicLinkProps { 16 + page: string; 17 + link: string; 18 + } 19 + 20 + const StatusPageMagicLinkEmail = ({ page, link }: StatusPageMagicLinkProps) => { 21 + return ( 22 + <Html> 23 + <Head> 24 + <title>Authenticate to "{page}" Status Page</title> 25 + </Head> 26 + <Preview>Authenticate to "{page}" Status Page</Preview> 27 + <Body> 28 + <Layout> 29 + <Heading as="h3">Access to "{page}" Status Page</Heading> 30 + <Text> 31 + You are receiving this email because you have requested access to 32 + the "{page}" Status Page. 33 + </Text> 34 + <Text> 35 + To authenticate, please click the link below. The link is valid for 36 + 24 hours. If you believe this is a mistake, please ignore this 37 + email. 38 + </Text> 39 + <Text> 40 + <Link style={styles.link} href={link}> 41 + Authenticate 42 + </Link> 43 + </Text> 44 + </Layout> 45 + </Body> 46 + </Html> 47 + ); 48 + }; 49 + 50 + StatusPageMagicLinkEmail.PreviewProps = { 51 + page: "OpenStatus", 52 + link: "https://slug.openstatus.dev/verify/token-xyz", 53 + } satisfies StatusPageMagicLinkProps; 54 + 55 + export default StatusPageMagicLinkEmail;
+31
packages/emails/src/client.tsx
··· 6 6 import type { MonitorAlertProps } from "../emails/monitor-alert"; 7 7 import PageSubscriptionEmail from "../emails/page-subscription"; 8 8 import type { PageSubscriptionProps } from "../emails/page-subscription"; 9 + import StatusPageMagicLinkEmail from "../emails/status-page-magic-link"; 10 + import type { StatusPageMagicLinkProps } from "../emails/status-page-magic-link"; 9 11 import StatusReportEmail from "../emails/status-report"; 10 12 import type { StatusReportProps } from "../emails/status-report"; 11 13 import TeamInvitationEmail from "../emails/team-invitation"; ··· 210 212 throw result.error; 211 213 } catch (err) { 212 214 console.error(`Error sending page subscription to ${req.to}`, err); 215 + } 216 + } 217 + 218 + public async sendStatusPageMagicLink( 219 + req: StatusPageMagicLinkProps & { to: string }, 220 + ) { 221 + if (process.env.NODE_ENV === "development") { 222 + console.log(`Sending status page magic link email to ${req.to}`); 223 + console.log(`>>> Magic Link: ${req.link}`); 224 + return; 225 + } 226 + 227 + try { 228 + const html = await render(<StatusPageMagicLinkEmail {...req} />); 229 + const result = await this.client.emails.send({ 230 + from: "Status Page <notifications@notifications.openstatus.dev>", 231 + subject: `Authenticate to ${req.page}`, 232 + to: req.to, 233 + html, 234 + }); 235 + 236 + if (!result.error) { 237 + console.log(`Sent status page magic link email to ${req.to}`); 238 + return; 239 + } 240 + 241 + throw result.error; 242 + } catch (err) { 243 + console.error(`Error sending status page magic link to ${req.to}`, err); 213 244 } 214 245 } 215 246 }
+1
packages/emails/src/index.ts
··· 4 4 export { default as TeamInvitationEmail } from "../emails/team-invitation"; 5 5 export { default as MonitorPausedEmail } from "../emails/monitor-paused"; 6 6 export { default as MonitorDeactivationEmail } from "../emails/monitor-deactivation"; 7 + export { default as StatusPageMagicLinkEmail } from "../emails/status-page-magic-link"; 7 8 8 9 export { monitorDeactivationEmail } from "../hotfix/monitor-deactivation"; 9 10 export { monitorPausedEmail } from "../hotfix/monitor-paused";
+8
pnpm-lock.yaml
··· 534 534 535 535 apps/status-page: 536 536 dependencies: 537 + '@auth/core': 538 + specifier: 0.40.0 539 + version: 0.40.0 540 + '@auth/drizzle-adapter': 541 + specifier: 1.10.0 542 + version: 1.10.0 537 543 '@date-fns/tz': 538 544 specifier: 1.2.0 539 545 version: 1.2.0 ··· 1795 1801 typescript: 1796 1802 specifier: 5.9.3 1797 1803 version: 5.9.3 1804 + 1805 + packages/react/dist: {} 1798 1806 1799 1807 packages/regions: 1800 1808 dependencies: