Openstatus www.openstatus.dev

refactor: schema (#407)

* wip:

* fix: incident-form

authored by

Maximilian Kaske and committed by
GitHub
541b7708 e886e47e

+575 -542
+7 -4
apps/web/src/app/api/checker/regions/_checker.ts
··· 1 1 import { Receiver } from "@upstash/qstash"; 2 2 import { nanoid } from "nanoid"; 3 - import type { z } from "zod"; 4 3 5 4 import { db, eq, schema } from "@openstatus/db"; 6 - import { selectNotificationSchema } from "@openstatus/db/src/schema"; 5 + import type { MonitorStatus } from "@openstatus/db/src/schema"; 6 + import { 7 + selectMonitorSchema, 8 + selectNotificationSchema, 9 + } from "@openstatus/db/src/schema"; 7 10 import { 8 11 publishPingResponse, 9 12 tbIngestPingResponse, ··· 145 148 .all(); 146 149 for (const notif of notifications) { 147 150 await providerToFunction[notif.notification.provider]({ 148 - monitor: notif.monitor, 151 + monitor: selectMonitorSchema.parse(notif.monitor), 149 152 notification: selectNotificationSchema.parse(notif.notification), 150 153 }); 151 154 } ··· 156 159 status, 157 160 }: { 158 161 monitorId: string; 159 - status: z.infer<typeof schema.statusSchema>; 162 + status: MonitorStatus; 160 163 }) => { 161 164 await db 162 165 .update(schema.monitor)
+3 -3
apps/web/src/app/api/checker/schema.ts
··· 1 1 import { z } from "zod"; 2 2 3 - import { methods, status } from "@openstatus/db/src/schema"; 3 + import { monitorMethods, monitorStatus } from "@openstatus/db/src/schema"; 4 4 5 5 export const payloadSchema = z.object({ 6 6 workspaceId: z.string(), 7 7 monitorId: z.string(), 8 - method: z.enum(methods), 8 + method: z.enum(monitorMethods), 9 9 body: z.string().optional(), 10 10 headers: z.array(z.object({ key: z.string(), value: z.string() })).optional(), 11 11 url: z.string(), 12 12 cronTimestamp: z.number(), 13 13 pageIds: z.array(z.string()), 14 - status: z.enum(status), 14 + status: z.enum(monitorStatus), 15 15 }); 16 16 17 17 export type Payload = z.infer<typeof payloadSchema>;
+6 -10
apps/web/src/app/api/checker/utils.ts
··· 1 - import type { z } from "zod"; 2 - 3 1 import type { 4 - basicMonitorSchema, 5 - providerName, 6 - selectNotificationSchema, 2 + Monitor, 3 + Notification, 4 + NotificationProvider, 7 5 } from "@openstatus/db/src/schema"; 8 6 import { send as sendEmail } from "@openstatus/notification-emails"; 9 - 10 - type ProviderName = (typeof providerName)[number]; 11 7 12 8 type sendNotificationType = ({ 13 9 monitor, 14 10 notification, 15 11 }: { 16 - monitor: z.infer<typeof basicMonitorSchema>; 17 - notification: z.infer<typeof selectNotificationSchema>; 12 + monitor: Monitor; 13 + notification: Notification; 18 14 }) => Promise<void>; 19 15 20 16 export const providerToFunction = { ··· 37 33 }) => { 38 34 /* TODO: implement */ 39 35 }, 40 - } satisfies Record<ProviderName, sendNotificationType>; 36 + } satisfies Record<NotificationProvider, sendNotificationType>;
+1
apps/web/src/app/app/(dashboard)/[workspaceSlug]/incidents/edit/page.tsx
··· 57 57 ({ monitorId }) => monitorId, 58 58 ), 59 59 pages: incident?.pagesToIncidents.map(({ pageId }) => pageId), 60 + message: "", 60 61 } 61 62 : undefined 62 63 }
+2 -2
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/_components/billing/plan.tsx
··· 4 4 import { useRouter } from "next/navigation"; 5 5 import type { z } from "zod"; 6 6 7 - import type { selectWorkspaceSchema } from "@openstatus/db/src/schema/workspace"; 7 + import type { Workspace } from "@openstatus/db/src/schema"; 8 8 9 9 import { Shell } from "@/components/dashboard/shell"; 10 10 import { Plan } from "@/components/marketing/plans"; ··· 18 18 workspaceData, 19 19 }: { 20 20 workspaceSlug: string; 21 - workspaceData: z.infer<typeof selectWorkspaceSchema>; 21 + workspaceData: Workspace; 22 22 }) => { 23 23 const router = useRouter(); 24 24 const [isPending, startTransition] = useTransition();
+2 -5
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/_components/action-button.tsx
··· 4 4 import Link from "next/link"; 5 5 import { useRouter } from "next/navigation"; 6 6 import { MoreVertical } from "lucide-react"; 7 - import type * as z from "zod"; 8 7 9 - import type { insertPageSchemaWithMonitors } from "@openstatus/db/src/schema"; 8 + import type { Page } from "@openstatus/db/src/schema"; 10 9 import { 11 10 AlertDialog, 12 11 AlertDialogAction, ··· 28 27 import { useToastAction } from "@/hooks/use-toast-action"; 29 28 import { api } from "@/trpc/client"; 30 29 31 - type PageSchema = z.infer<typeof insertPageSchemaWithMonitors>; 32 - 33 30 interface ActionButtonProps { 34 - page: PageSchema; 31 + page: Page; 35 32 } 36 33 37 34 export function ActionButton({ page }: ActionButtonProps) {
+2 -8
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/_components/empty-state.tsx
··· 1 1 import Link from "next/link"; 2 - import type * as z from "zod"; 3 2 4 - import type { allMonitorsExtendedSchema } from "@openstatus/db/src/schema"; 3 + import type { Monitor } from "@openstatus/db/src/schema"; 5 4 import { Button } from "@openstatus/ui"; 6 5 7 6 import { EmptyState as DefaultEmptyState } from "@/components/dashboard/empty-state"; 8 7 9 - export function EmptyState({ 10 - allMonitors, 11 - }: { 12 - allMonitors?: z.infer<typeof allMonitorsExtendedSchema>; 13 - }) { 14 - // Navigate user to monitor if they don't have one 8 + export function EmptyState({ allMonitors }: { allMonitors?: Monitor[] }) { 15 9 if (!Boolean(allMonitors?.length)) { 16 10 return ( 17 11 <DefaultEmptyState
+4 -2
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/edit/page.tsx
··· 30 30 } 31 31 32 32 const { id } = search.data; 33 + const { workspaceSlug } = params; 33 34 34 35 // TODO: too many requests to db 35 36 const page = id && (await api.page.getPageByID.query({ id })); 36 37 const monitors = await api.monitor.getMonitorsByWorkspace.query({ 37 - workspaceSlug: params.workspaceSlug, 38 + workspaceSlug, 38 39 }); 39 40 const workspace = await api.workspace.getWorkspace.query({ 40 - slug: params.workspaceSlug, 41 + slug: workspaceSlug, 41 42 }); 42 43 43 44 const isProPlan = workspace?.plan === "pro"; ··· 71 72 page 72 73 ? { 73 74 ...page, 75 + workspaceSlug: params.workspaceSlug, 74 76 monitors: page.monitorsToPages.map( 75 77 ({ monitor }) => monitor.id, 76 78 ),
+2 -4
apps/web/src/components/data-table/monitor/columns.tsx
··· 4 4 import type { ColumnDef } from "@tanstack/react-table"; 5 5 import * as z from "zod"; 6 6 7 - import type { ExtendedMonitor } from "@openstatus/db/src/schema"; 7 + import type { Monitor } from "@openstatus/db/src/schema"; 8 8 import { Badge } from "@openstatus/ui"; 9 9 10 10 import { DataTableStatusBadge } from "../data-table-status-badge"; 11 11 import { DataTableRowActions } from "./data-table-row-actions"; 12 12 13 - export const columns: ColumnDef< 14 - ExtendedMonitor & { lastStatusCode?: number } 15 - >[] = [ 13 + export const columns: ColumnDef<Monitor & { lastStatusCode?: number }>[] = [ 16 14 { 17 15 accessorKey: "name", 18 16 header: "Name",
+2 -2
apps/web/src/components/data-table/monitor/data-table-row-actions.tsx
··· 6 6 import type { Row } from "@tanstack/react-table"; 7 7 import { MoreHorizontal } from "lucide-react"; 8 8 9 - import { selectMonitorExtendedSchema } from "@openstatus/db/src/schema"; 9 + import { insertMonitorSchema } from "@openstatus/db/src/schema"; 10 10 import { 11 11 AlertDialog, 12 12 AlertDialogAction, ··· 36 36 export function DataTableRowActions<TData>({ 37 37 row, 38 38 }: DataTableRowActionsProps<TData>) { 39 - const monitor = selectMonitorExtendedSchema.parse(row.original); 39 + const monitor = insertMonitorSchema.parse(row.original); 40 40 const router = useRouter(); 41 41 const { toast } = useToastAction(); 42 42 const [alertOpen, setAlertOpen] = React.useState(false);
+4 -2
apps/web/src/components/forms/custom-domain-form.tsx
··· 6 6 import { useForm } from "react-hook-form"; 7 7 import type * as z from "zod"; 8 8 9 - import { insertPageSchemaWithMonitors } from "@openstatus/db/src/schema"; 9 + import { insertPageSchema } from "@openstatus/db/src/schema"; 10 10 import { 11 11 Button, 12 12 Form, ··· 26 26 import DomainStatusIcon from "../domains/domain-status-icon"; 27 27 import { LoadingAnimation } from "../loading-animation"; 28 28 29 - const customDomain = insertPageSchemaWithMonitors.pick({ 29 + const customDomain = insertPageSchema.pick({ 30 30 customDomain: true, 31 31 id: true, 32 32 }); 33 33 34 34 type Schema = z.infer<typeof customDomain>; 35 + 36 + // TODO: check 35 37 36 38 export function CustomDomainForm({ defaultValues }: { defaultValues: Schema }) { 37 39 const form = useForm<Schema>({
+31 -35
apps/web/src/components/forms/incident-form.tsx
··· 4 4 import { useRouter } from "next/navigation"; 5 5 import { zodResolver } from "@hookform/resolvers/zod"; 6 6 import { useForm } from "react-hook-form"; 7 - import * as z from "zod"; 8 7 9 - import type { 10 - allMonitorsExtendedSchema, 11 - Page, 12 - } from "@openstatus/db/src/schema"; 8 + import type { InsertIncident, Monitor, Page } from "@openstatus/db/src/schema"; 13 9 import { 14 - availableStatus, 10 + incidentStatus, 11 + incidentStatusSchema, 15 12 insertIncidentSchema, 16 - StatusEnum, 17 13 } from "@openstatus/db/src/schema"; 18 14 import { 19 15 Accordion, ··· 48 44 import { cn } from "@/lib/utils"; 49 45 import { api } from "@/trpc/client"; 50 46 51 - // include update on creation 52 - const insertSchema = insertIncidentSchema.extend({ 53 - message: z.string().optional(), 54 - date: z.date().optional().default(new Date()), 55 - }); 56 - 57 - type IncidentProps = z.infer<typeof insertSchema>; 58 - type MonitorsProps = z.infer<typeof allMonitorsExtendedSchema>; 59 - 60 47 interface Props { 61 - defaultValues?: IncidentProps; 62 - monitors?: MonitorsProps; 63 - pages?: Page[]; // 48 + defaultValues?: InsertIncident; 49 + monitors?: Monitor[]; 50 + pages?: Page[]; 64 51 workspaceSlug: string; 65 52 } 66 53 ··· 70 57 pages, 71 58 workspaceSlug, 72 59 }: Props) { 73 - const form = useForm<IncidentProps>({ 74 - resolver: zodResolver(insertSchema), 75 - defaultValues: { 76 - id: defaultValues?.id || 0, 77 - title: defaultValues?.title || "", 78 - status: defaultValues?.status || "investigating", 79 - monitors: defaultValues?.monitors || [], 80 - pages: defaultValues?.pages || [], 81 - workspaceSlug, 82 - // include update on creation 83 - message: "", 84 - date: defaultValues?.date || new Date(), 85 - }, 60 + const form = useForm<InsertIncident>({ 61 + resolver: zodResolver(insertIncidentSchema), 62 + defaultValues: defaultValues 63 + ? { 64 + id: defaultValues.id, 65 + title: defaultValues.title, 66 + status: defaultValues.status, 67 + monitors: defaultValues.monitors, 68 + pages: defaultValues.pages, 69 + workspaceSlug, 70 + // include update on creation 71 + message: defaultValues.message, 72 + date: defaultValues.date, 73 + } 74 + : { 75 + status: "investigating", 76 + date: new Date(), 77 + workspaceSlug, 78 + }, 86 79 }); 87 80 const router = useRouter(); 88 81 const [isPending, startTransition] = React.useTransition(); 89 82 const { toast } = useToastAction(); 90 83 91 - const onSubmit = ({ ...props }: IncidentProps) => { 84 + const onSubmit = ({ ...props }: InsertIncident) => { 92 85 startTransition(async () => { 93 86 try { 94 87 if (defaultValues) { ··· 99 92 const incident = await api.incident.createIncident.mutate({ 100 93 workspaceSlug, 101 94 status, 95 + message, 102 96 ...rest, 103 97 }); 104 98 // include update on creation ··· 119 113 } 120 114 }); 121 115 }; 116 + 117 + console.log(form.formState.errors); 122 118 return ( 123 119 <Form {...form}> 124 120 <form ··· 160 156 <FormMessage /> 161 157 <RadioGroup 162 158 onValueChange={(value) => 163 - field.onChange(StatusEnum.parse(value)) 159 + field.onChange(incidentStatusSchema.parse(value)) 164 160 } // value is a string 165 161 defaultValue={field.value} 166 162 className="grid grid-cols-2 gap-4 sm:grid-cols-4" 167 163 > 168 - {availableStatus.map((status) => { 164 + {incidentStatus.map((status) => { 169 165 const { value, label, icon } = statusDict[status]; 170 166 const Icon = Icons[icon]; 171 167 return (
+9 -13
apps/web/src/components/forms/incident-update-form.tsx
··· 4 4 import { useRouter } from "next/navigation"; 5 5 import { zodResolver } from "@hookform/resolvers/zod"; 6 6 import { useForm } from "react-hook-form"; 7 - import type * as z from "zod"; 8 7 8 + import type { InsertIncidentUpdate } from "@openstatus/db/src/schema"; 9 9 import { 10 - availableStatus, 10 + incidentStatus, 11 + incidentStatusSchema, 11 12 insertIncidentUpdateSchema, 12 - StatusEnum, 13 13 } from "@openstatus/db/src/schema"; 14 14 import { 15 15 Button, ··· 37 37 import { useToastAction } from "@/hooks/use-toast-action"; 38 38 import { api } from "@/trpc/client"; 39 39 40 - // TODO: for UX, using the form inside of a Dialog feels more suitable 41 - 42 - type IncidentUpdateProps = z.infer<typeof insertIncidentUpdateSchema>; 43 - 44 40 interface Props { 45 - defaultValues?: IncidentUpdateProps; 41 + defaultValues?: InsertIncidentUpdate; 46 42 workspaceSlug: string; 47 43 incidentId: number; 48 44 } ··· 52 48 workspaceSlug, 53 49 incidentId, 54 50 }: Props) { 55 - const form = useForm<IncidentUpdateProps>({ 51 + const form = useForm<InsertIncidentUpdate>({ 56 52 resolver: zodResolver(insertIncidentUpdateSchema), 57 53 defaultValues: { 58 54 id: defaultValues?.id || 0, 59 55 status: defaultValues?.status || "investigating", 60 - message: defaultValues?.message || "", 56 + message: defaultValues?.message, 61 57 date: defaultValues?.date || new Date(), 62 58 incidentId, 63 59 workspaceSlug, ··· 67 63 const [isPending, startTransition] = React.useTransition(); 68 64 const { toast } = useToastAction(); 69 65 70 - const onSubmit = ({ ...props }: IncidentUpdateProps) => { 66 + const onSubmit = ({ ...props }: InsertIncidentUpdate) => { 71 67 startTransition(async () => { 72 68 try { 73 69 if (defaultValues) { ··· 110 106 <FormMessage /> 111 107 <RadioGroup 112 108 onValueChange={(value) => 113 - field.onChange(StatusEnum.parse(value)) 109 + field.onChange(incidentStatusSchema.parse(value)) 114 110 } // value is a string 115 111 defaultValue={field.value} 116 112 className="grid grid-cols-2 gap-4 sm:grid-cols-4" 117 113 > 118 - {availableStatus.map((status) => { 114 + {incidentStatus.map((status) => { 119 115 const { value, label, icon } = statusDict[status]; 120 116 const Icon = Icons[icon]; 121 117 return (
+20 -29
apps/web/src/components/forms/monitor-form.tsx
··· 5 5 import { zodResolver } from "@hookform/resolvers/zod"; 6 6 import { Check, ChevronsUpDown, Wand2, X } from "lucide-react"; 7 7 import { useFieldArray, useForm } from "react-hook-form"; 8 - import * as z from "zod"; 9 8 10 - import type { selectNotificationSchema } from "@openstatus/db/src/schema"; 9 + import type { 10 + InsertMonitor, 11 + Notification, 12 + WorkspacePlan, 13 + } from "@openstatus/db/src/schema"; 11 14 import { 12 15 flyRegions, 13 16 insertMonitorSchema, 14 - methods, 15 - methodsSchema, 16 - periodicityEnum, 17 + monitorMethods, 18 + monitorMethodsSchema, 19 + monitorPeriodicitySchema, 17 20 } from "@openstatus/db/src/schema"; 18 21 import { allPlans } from "@openstatus/plans"; 19 22 import { ··· 76 79 { value: "1h", label: "1 hour" }, 77 80 ] as const; 78 81 79 - const headersSchema = z 80 - .array(z.object({ key: z.string(), value: z.string() })) 81 - .optional(); 82 - 83 - const advancedSchema = z.object({ 84 - method: methodsSchema, 85 - body: z.string().optional(), 86 - headers: headersSchema, 87 - }); 88 - 89 - const mergedSchema = insertMonitorSchema.merge(advancedSchema); 90 - 91 - export type MonitorProps = z.infer<typeof mergedSchema>; 92 - 93 82 interface Props { 94 - defaultValues?: MonitorProps; 83 + defaultValues?: InsertMonitor; 95 84 workspaceSlug: string; 96 - plan?: "free" | "pro"; 97 - notifications?: z.infer<typeof selectNotificationSchema>[]; // HOTFIX - We can think of returning `workspace` instead of `workspaceSlug` 85 + plan?: WorkspacePlan; 86 + notifications?: Notification[]; 98 87 } 99 88 100 89 export function MonitorForm({ ··· 103 92 plan = "free", 104 93 notifications, 105 94 }: Props) { 106 - const form = useForm<MonitorProps>({ 107 - resolver: zodResolver(mergedSchema), // too much - we should only validate the values we ask inside of the form! 95 + const form = useForm<InsertMonitor>({ 96 + resolver: zodResolver(insertMonitorSchema), 108 97 defaultValues: { 109 98 url: defaultValues?.url || "", 110 99 name: defaultValues?.name || "", ··· 136 125 control: form.control, 137 126 }); 138 127 139 - const handleDataUpdateOrInsertion = async (props: MonitorProps) => { 128 + const handleDataUpdateOrInsertion = async (props: InsertMonitor) => { 140 129 try { 141 130 if (defaultValues) { 142 131 await api.monitor.updateMonitor.mutate(props); ··· 155 144 } 156 145 }; 157 146 158 - const onSubmit = ({ ...props }: MonitorProps) => { 147 + const onSubmit = ({ ...props }: InsertMonitor) => { 159 148 startTransition(async () => { 160 149 const pingResult = await pingEndpoint(); 161 150 if (!pingResult) { ··· 296 285 <FormLabel>Method</FormLabel> 297 286 <Select 298 287 onValueChange={(value) => { 299 - field.onChange(methodsSchema.parse(value)); 288 + field.onChange(monitorMethodsSchema.parse(value)); 300 289 form.resetField("body", { defaultValue: "" }); 301 290 }} 302 291 defaultValue={field.value} ··· 307 296 </SelectTrigger> 308 297 </FormControl> 309 298 <SelectContent> 310 - {methods.map((method) => ( 299 + {monitorMethods.map((method) => ( 311 300 <SelectItem key={method} value={method}> 312 301 {method} 313 302 </SelectItem> ··· 435 424 <FormLabel>Frequency</FormLabel> 436 425 <Select 437 426 onValueChange={(value) => 438 - field.onChange(periodicityEnum.parse(value)) 427 + field.onChange( 428 + monitorPeriodicitySchema.parse(value), 429 + ) 439 430 } 440 431 defaultValue={field.value} 441 432 >
+10 -8
apps/web/src/components/forms/notification-form.tsx
··· 5 5 import { zodResolver } from "@hookform/resolvers/zod"; 6 6 import { useForm } from "react-hook-form"; 7 7 8 - import type { Notification } from "@openstatus/db/src/schema"; 8 + import type { InsertNotification } from "@openstatus/db/src/schema"; 9 9 import { 10 10 insertNotificationSchema, 11 - providerEnum, 12 - providerName, 11 + notificationProvider, 12 + notificationProviderSchema, 13 13 } from "@openstatus/db/src/schema"; 14 14 import { 15 15 Button, ··· 41 41 */ 42 42 43 43 interface Props { 44 - defaultValues?: Notification; 44 + defaultValues?: InsertNotification; 45 45 workspaceSlug: string; 46 46 onSubmit?: () => void; 47 47 } ··· 54 54 const [isPending, startTransition] = useTransition(); 55 55 const { toast } = useToastAction(); 56 56 const router = useRouter(); 57 - const form = useForm<Notification>({ 57 + const form = useForm<InsertNotification>({ 58 58 resolver: zodResolver(insertNotificationSchema), 59 59 defaultValues: { 60 60 ...defaultValues, 61 + name: defaultValues?.name || "", 61 62 data: 62 63 defaultValues?.provider === "email" 63 64 ? JSON.parse(defaultValues?.data).email ··· 65 66 }, 66 67 }); 67 68 68 - async function onSubmit({ provider, data, ...rest }: Notification) { 69 + async function onSubmit({ provider, data, ...rest }: InsertNotification) { 69 70 startTransition(async () => { 70 71 try { 71 72 if (defaultValues) { ··· 114 115 <FormLabel>Provider</FormLabel> 115 116 <Select 116 117 onValueChange={(value) => 117 - field.onChange(providerEnum.parse(value)) 118 + field.onChange(notificationProviderSchema.parse(value)) 118 119 } 119 120 defaultValue={field.value} 120 121 > ··· 124 125 </SelectTrigger> 125 126 </FormControl> 126 127 <SelectContent> 127 - {providerName.map((provider) => ( 128 + {notificationProvider.map((provider) => ( 128 129 <SelectItem 129 130 key={provider} 130 131 value={provider} ··· 168 169 <FormControl> 169 170 <Input 170 171 type="email" 172 + required 171 173 placeholder="dev@documenso.com" 172 174 {...field} 173 175 />
+9 -13
apps/web/src/components/forms/status-page-form.tsx
··· 7 7 import { zodResolver } from "@hookform/resolvers/zod"; 8 8 import type { PutBlobResult } from "@vercel/blob"; 9 9 import { useForm } from "react-hook-form"; 10 - import type * as z from "zod"; 11 10 12 - import type { allMonitorsExtendedSchema } from "@openstatus/db/src/schema"; 13 - import { insertPageSchemaWithMonitors } from "@openstatus/db/src/schema"; 11 + import { insertPageSchema } from "@openstatus/db/src/schema"; 12 + import type { InsertPage, Monitor } from "@openstatus/db/src/schema"; 14 13 import { 15 14 Accordion, 16 15 AccordionContent, ··· 39 38 40 39 // REMINDER: only use the props you need! 41 40 42 - type Schema = z.infer<typeof insertPageSchemaWithMonitors>; 43 - 44 41 interface Props { 45 - defaultValues?: Schema; 42 + defaultValues?: InsertPage; 46 43 workspaceSlug: string; 47 - allMonitors?: z.infer<typeof allMonitorsExtendedSchema>; 44 + allMonitors?: Monitor[]; 48 45 /** 49 46 * gives the possibility to check all the monitors 50 47 */ ··· 62 59 checkAllMonitors, 63 60 nextUrl, 64 61 }: Props) { 65 - const form = useForm<Schema>({ 66 - resolver: zodResolver(insertPageSchemaWithMonitors), 62 + console.log({ defaultValues }); 63 + const form = useForm<InsertPage>({ 64 + resolver: zodResolver(insertPageSchema), 67 65 defaultValues: { 68 - title: defaultValues?.title || "", 66 + title: defaultValues?.title || "", // FIXME: you can save a page without title, causing unexpected slug behavior 69 67 slug: defaultValues?.slug || "", 70 68 description: defaultValues?.description || "", 71 69 workspaceId: defaultValues?.workspaceId || 0, ··· 121 119 } 122 120 }, [watchTitle, form, defaultValues?.title]); 123 121 124 - const onSubmit = async ({ 125 - ...props 126 - }: z.infer<typeof insertPageSchemaWithMonitors>) => { 122 + const onSubmit = async ({ ...props }: InsertPage) => { 127 123 startTransition(async () => { 128 124 // TODO: we could use an upsertPage function instead - insert if not exist otherwise update 129 125 try {
+3 -3
apps/web/src/components/modals/failed-ping-alert-confirmation.tsx
··· 1 1 import React from "react"; 2 2 3 + import type { InsertMonitor } from "@openstatus/db/src/schema"; 3 4 import { 4 5 AlertDialog, 5 6 AlertDialogAction, ··· 12 13 } from "@openstatus/ui"; 13 14 14 15 import { LoadingAnimation } from "@/components/loading-animation"; 15 - import type { MonitorProps } from "../forms/monitor-form"; 16 16 17 17 type FailedPingAlertConfirmationProps = { 18 - monitor: MonitorProps; 18 + monitor: InsertMonitor; 19 19 pingFailed: boolean; 20 20 setPingFailed: React.Dispatch<React.SetStateAction<boolean>>; 21 - onConfirm: (props: MonitorProps) => Promise<void>; 21 + onConfirm: (props: InsertMonitor) => Promise<void>; 22 22 }; 23 23 24 24 export const FailedPingAlertConfirmation = ({
+13 -7
packages/api/src/router/incident.ts
··· 3 3 import { and, eq, inArray } from "@openstatus/db"; 4 4 import { 5 5 incident, 6 + incidentStatusSchema, 6 7 incidentUpdate, 7 8 insertIncidentSchema, 8 - insertIncidentSchemaWithMonitors, 9 9 insertIncidentUpdateSchema, 10 10 monitorsToIncidents, 11 11 pagesToIncidents, 12 12 selectIncidentSchema, 13 13 selectIncidentUpdateSchema, 14 14 selectMonitorSchema, 15 - StatusEnum, 16 15 user, 17 16 usersToWorkspaces, 18 17 workspace, ··· 31 30 }); 32 31 if (!result) return; 33 32 34 - const { id, workspaceSlug, monitors, pages, date, ...incidentInput } = 35 - opts.input; 33 + const { 34 + id, 35 + workspaceSlug, 36 + monitors, 37 + pages, 38 + date, 39 + message, 40 + ...incidentInput 41 + } = opts.input; 36 42 37 43 const newIncident = await opts.ctx.db 38 44 .insert(incident) ··· 111 117 }), 112 118 113 119 updateIncident: protectedProcedure 114 - .input(insertIncidentSchemaWithMonitors) 120 + .input(insertIncidentSchema) 115 121 .mutation(async (opts) => { 116 122 const data = await hasUserAccessToWorkspace({ 117 123 workspaceSlug: opts.input.workspaceSlug, ··· 323 329 .input(z.object({ id: z.number() })) 324 330 .query(async (opts) => { 325 331 const selectIncidentSchemaWithRelation = selectIncidentSchema.extend({ 326 - status: StatusEnum.default("investigating"), // TODO: remove! 332 + status: incidentStatusSchema.default("investigating"), // TODO: remove! 327 333 monitorsToIncidents: z 328 334 .array(z.object({ incidentId: z.number(), monitorId: z.number() })) 329 335 .default([]), ··· 369 375 if (!data) return; 370 376 371 377 const selectIncidentSchemaWithRelation = selectIncidentSchema.extend({ 372 - status: StatusEnum.default("investigating"), // TODO: remove! 378 + status: incidentStatusSchema.default("investigating"), // TODO: remove! 373 379 monitorsToIncidents: z 374 380 .array( 375 381 z.object({
+8 -9
packages/api/src/router/monitor.ts
··· 4 4 import { analytics, trackAnalytics } from "@openstatus/analytics"; 5 5 import { and, eq, inArray, sql } from "@openstatus/db"; 6 6 import { 7 - allMonitorsExtendedSchema, 8 7 insertMonitorSchema, 9 - methods, 10 8 monitor, 9 + monitorMethodsSchema, 10 + monitorPeriodicitySchema, 11 11 monitorsToPages, 12 12 notification, 13 13 notificationsToMonitors, 14 - periodicityEnum, 15 - selectMonitorExtendedSchema, 14 + selectMonitorSchema, 16 15 selectNotificationSchema, 17 16 } from "@openstatus/db/src/schema"; 18 17 import { allPlans } from "@openstatus/plans"; ··· 107 106 }); 108 107 if (!result) return; 109 108 110 - const _monitor = selectMonitorExtendedSchema.parse(result.monitor); 109 + const _monitor = selectMonitorSchema.parse(result.monitor); 111 110 return _monitor; 112 111 }), 113 112 ··· 225 224 // const selectMonitorsArray = selectMonitorSchema.array(); 226 225 227 226 try { 228 - return allMonitorsExtendedSchema.parse(monitors); 227 + return z.array(selectMonitorSchema).parse(monitors); 229 228 } catch (e) { 230 229 console.log(e); 231 230 } ··· 236 235 .input( 237 236 z.object({ 238 237 id: z.number(), 239 - method: z.enum(methods).default("GET"), 238 + method: monitorMethodsSchema.default("GET"), 240 239 body: z.string().default("").optional(), 241 240 headers: z 242 241 .array(z.object({ key: z.string(), value: z.string() })) ··· 263 262 }), 264 263 265 264 getMonitorsForPeriodicity: protectedProcedure 266 - .input(z.object({ periodicity: periodicityEnum })) 265 + .input(z.object({ periodicity: monitorPeriodicitySchema })) 267 266 .query(async (opts) => { 268 267 const result = await opts.ctx.db 269 268 .select() ··· 275 274 ), 276 275 ) 277 276 .all(); 278 - return allMonitorsExtendedSchema.parse(result); 277 + return z.array(selectMonitorSchema).parse(result); 279 278 }), 280 279 281 280 getAllPagesForMonitor: protectedProcedure
+2 -4
packages/api/src/router/notification.ts
··· 1 1 import { z } from "zod"; 2 2 3 3 import { analytics, trackAnalytics } from "@openstatus/analytics"; 4 - import { and, eq } from "@openstatus/db"; 4 + import { eq } from "@openstatus/db"; 5 5 import { 6 - allNotifications, 7 6 insertNotificationSchema, 8 7 notification, 9 - notificationsToMonitors, 10 8 selectNotificationSchema, 11 9 } from "@openstatus/db/src/schema"; 12 10 ··· 119 117 .all(); 120 118 121 119 try { 122 - return allNotifications.parse(notifications); 120 + return z.array(selectNotificationSchema).parse(notifications); 123 121 } catch (e) { 124 122 console.log(e); 125 123 }
+4 -5
packages/api/src/router/page.ts
··· 2 2 import { z } from "zod"; 3 3 4 4 import { analytics, trackAnalytics } from "@openstatus/analytics"; 5 - import { and, eq, inArray, not, or, sql } from "@openstatus/db"; 5 + import { and, eq, inArray, or, sql } from "@openstatus/db"; 6 6 import { 7 7 incident, 8 - insertPageSchemaWithMonitors, 8 + insertPageSchema, 9 9 monitor, 10 10 monitorsToIncidents, 11 11 monitorsToPages, ··· 16 16 usersToWorkspaces, 17 17 workspace, 18 18 } from "@openstatus/db/src/schema"; 19 - import { allPlans } from "@openstatus/plans"; 20 19 21 20 import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; 22 21 import { hasUserAccessToWorkspace } from "./utils"; ··· 24 23 // TODO: deletePageById - updatePageById 25 24 export const pageRouter = createTRPCRouter({ 26 25 createPage: protectedProcedure 27 - .input(insertPageSchemaWithMonitors) 26 + .input(insertPageSchema) 28 27 .mutation(async (opts) => { 29 28 if (!opts.input.workspaceSlug) return; 30 29 const data = await hasUserAccessToWorkspace({ ··· 110 109 }); 111 110 }), 112 111 updatePage: protectedProcedure 113 - .input(insertPageSchemaWithMonitors) 112 + .input(insertPageSchema) 114 113 .mutation(async (opts) => { 115 114 if (!opts.input.id) return; 116 115
+1 -1
packages/api/src/router/stripe/webhook.ts
··· 91 91 .update(workspace) 92 92 .set({ 93 93 subscriptionId: null, 94 - plan: "FREE", 94 + plan: "free", 95 95 paidUntil: null, 96 96 }) 97 97 .where(eq(workspace.stripeId, customerId))
+6 -53
packages/db/src/schema/incident.ts packages/db/src/schema/incidents/incident.ts
··· 5 5 sqliteTable, 6 6 text, 7 7 } from "drizzle-orm/sqlite-core"; 8 - import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 9 - import * as z from "zod"; 10 8 11 - import { monitor } from "./monitor"; 12 - import { page } from "./page"; 13 - import { workspace } from "./workspace"; 9 + import { monitor } from "../monitors"; 10 + import { page } from "../pages"; 11 + import { workspace } from "../workspaces"; 14 12 15 - export const availableStatus = [ 13 + export const incidentStatus = [ 16 14 "investigating", 17 15 "identified", 18 16 "monitoring", 19 17 "resolved", 20 18 ] as const; 21 19 22 - export const StatusEnum = z.enum(availableStatus); 23 - 24 - // We should have a self relation. Such that we show the parent. 25 20 export const incident = sqliteTable("incident", { 26 21 id: integer("id").primaryKey(), 27 - status: text("status", availableStatus).notNull(), // FIXME: delete from table! 22 + status: text("status", { enum: incidentStatus }).notNull(), 28 23 title: text("title", { length: 256 }).notNull(), 29 24 30 25 workspaceId: integer("workspace_id").references(() => workspace.id), ··· 35 30 updatedAt: integer("updated_at", { mode: "timestamp" }).default( 36 31 sql`(strftime('%s', 'now'))`, 37 32 ), 38 - // createdBy 39 33 }); 40 34 41 35 export const incidentUpdate = sqliteTable("incident_update", { 42 36 id: integer("id").primaryKey(), 43 37 44 - status: text("status", availableStatus).notNull(), 38 + status: text("status", incidentStatus).notNull(), 45 39 date: integer("date", { mode: "timestamp" }).notNull(), 46 40 message: text("message").notNull(), 47 41 ··· 130 124 }), 131 125 }), 132 126 ); 133 - 134 - // Schema for inserting a Incident - can be used to validate API requests 135 - export const insertIncidentSchema = createInsertSchema(incident).extend({ 136 - title: z.string().default(""), 137 - // message: z.string().optional().default(""), 138 - status: StatusEnum, 139 - date: z.date().optional().default(new Date()), 140 - // date: z.number().optional().default(new Date().getTime()), 141 - workspaceSlug: z.string(), 142 - monitors: z.number().array(), 143 - pages: z.number().array(), 144 - }); 145 - 146 - export const insertIncidentUpdateSchema = createInsertSchema( 147 - incidentUpdate, 148 - ).extend({ 149 - status: StatusEnum, 150 - message: z.string().optional().default(""), 151 - // date: z.number().optional().default(new Date().getTime()), 152 - workspaceSlug: z.string(), 153 - }); 154 - 155 - export const insertIncidentSchemaWithIncidentUpdates = 156 - insertIncidentSchema.extend({ 157 - incidentUpdates: insertIncidentUpdateSchema.array(), 158 - }); 159 - 160 - // TODO: remove!!! 161 - export const insertIncidentSchemaWithMonitors = insertIncidentSchema.extend({ 162 - monitors: z.number().array(), 163 - }); 164 - 165 - export const selectIncidentSchema = createSelectSchema(incident).extend({ 166 - status: StatusEnum, 167 - }); 168 - 169 - export const selectIncidentUpdateSchema = createSelectSchema( 170 - incidentUpdate, 171 - ).extend({ 172 - status: StatusEnum, 173 - });
+3
packages/db/src/schema/incidents/index.ts
··· 1 + export * from "./incident"; 2 + export * from "./validation"; 3 + export type * from "./validation";
+44
packages/db/src/schema/incidents/validation.ts
··· 1 + import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 2 + import * as z from "zod"; 3 + 4 + import { incident, incidentStatus, incidentUpdate } from "./incident"; 5 + 6 + export const incidentStatusSchema = z.enum(incidentStatus); 7 + 8 + export const insertIncidentUpdateSchema = createInsertSchema(incidentUpdate, { 9 + status: incidentStatusSchema, 10 + }).extend({ 11 + workspaceSlug: z.string(), // FIXME: we should do it differently! 12 + }); 13 + 14 + export const insertIncidentSchema = createInsertSchema(incident, { 15 + status: incidentStatusSchema, 16 + }) 17 + .extend({ 18 + date: z.date().optional().default(new Date()), 19 + workspaceSlug: z.string(), 20 + /** 21 + * relationship to monitors and pages 22 + */ 23 + monitors: z.number().array().optional().default([]), 24 + pages: z.number().array().optional().default([]), 25 + }) 26 + .extend({ 27 + /** 28 + * message for the `InsertIncidentUpdate` 29 + */ 30 + message: z.string(), 31 + }); 32 + 33 + export const selectIncidentSchema = createSelectSchema(incident, { 34 + status: incidentStatusSchema, 35 + }); 36 + 37 + export const selectIncidentUpdateSchema = createSelectSchema(incidentUpdate, { 38 + status: incidentStatusSchema, 39 + }); 40 + 41 + export type InsertIncident = z.infer<typeof insertIncidentSchema>; 42 + export type Incident = z.infer<typeof selectIncidentSchema>; 43 + export type InsertIncidentUpdate = z.infer<typeof insertIncidentUpdateSchema>; 44 + export type IncidentUpdate = z.infer<typeof selectIncidentUpdateSchema>;
+6 -6
packages/db/src/schema/index.ts
··· 1 - export * from "./incident"; 1 + export * from "./incidents"; 2 2 export * from "./integration"; 3 - export * from "./page"; 4 - export * from "./monitor"; 5 - export * from "./user"; 6 - export * from "./workspace"; 3 + export * from "./pages"; 4 + export * from "./monitors"; 5 + export * from "./users"; 6 + export * from "./workspaces"; 7 7 export * from "./shared"; 8 - export * from "./notification"; 8 + export * from "./notifications";
+2 -2
packages/db/src/schema/integration.ts
··· 1 1 import { sql } from "drizzle-orm"; 2 2 import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 - import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 3 + import { createInsertSchema } from "drizzle-zod"; 4 4 5 - import { workspace } from "./workspace"; 5 + import { workspace } from "./workspaces"; 6 6 7 7 export const integration = sqliteTable("integration", { 8 8 id: integer("id").primaryKey(),
-180
packages/db/src/schema/monitor.ts
··· 1 - import { relations, sql } from "drizzle-orm"; 2 - import { 3 - integer, 4 - primaryKey, 5 - sqliteTable, 6 - text, 7 - } from "drizzle-orm/sqlite-core"; 8 - import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 9 - import { z } from "zod"; 10 - 11 - import { monitorsToIncidents } from "./incident"; 12 - import { notificationsToMonitors } from "./notification"; 13 - import { page } from "./page"; 14 - import { workspace } from "./workspace"; 15 - 16 - export const vercelRegions = [ 17 - "arn1", 18 - "bom1", 19 - "cdg1", 20 - "cle1", 21 - "cpt1", 22 - "dub1", 23 - "fra1", 24 - "gru1", 25 - "hkg1", 26 - "hnd1", 27 - "iad1", 28 - "icn1", 29 - "kix1", 30 - "lhr1", 31 - "pdx1", 32 - "sfo1", 33 - "sin1", 34 - "syd1", 35 - ] as const; 36 - 37 - export const flyRegions = ["ams", "iad", "hkg", "jnb", "syd", "gru"] as const; 38 - 39 - export const periodicity = ["1m", "5m", "10m", "30m", "1h", "other"] as const; 40 - export const periodicityEnum = z.enum(periodicity); 41 - export const methods = ["GET", "POST", "HEAD"] as const; 42 - export const methodsSchema = z.enum(methods); 43 - export const status = ["active", "error"] as const; 44 - export const statusSchema = z.enum(status); 45 - export const RegionEnum = z.enum([...flyRegions, ...vercelRegions, "auto"]); 46 - 47 - export const monitor = sqliteTable("monitor", { 48 - id: integer("id").primaryKey(), 49 - jobType: text("job_type", ["website", "cron", "other"]) 50 - .default("other") 51 - .notNull(), 52 - periodicity: text("periodicity", ["1m", "5m", "10m", "30m", "1h", "other"]) 53 - .default("other") 54 - .notNull(), 55 - status: text("status", status).default("active").notNull(), 56 - active: integer("active", { mode: "boolean" }).default(false), 57 - 58 - regions: text("regions").default("").notNull(), 59 - 60 - url: text("url", { length: 2048 }).notNull(), 61 - 62 - name: text("name", { length: 256 }).default("").notNull(), 63 - description: text("description").default("").notNull(), 64 - 65 - headers: text("headers").default(""), 66 - body: text("body").default(""), 67 - method: text("method", methods).default("GET"), 68 - workspaceId: integer("workspace_id").references(() => workspace.id), 69 - 70 - createdAt: integer("created_at", { mode: "timestamp" }).default( 71 - sql`(strftime('%s', 'now'))`, 72 - ), 73 - updatedAt: integer("updated_at", { mode: "timestamp" }).default( 74 - sql`(strftime('%s', 'now'))`, 75 - ), 76 - }); 77 - 78 - export const monitorRelation = relations(monitor, ({ one, many }) => ({ 79 - monitorsToPages: many(monitorsToPages), 80 - monitorsToIncidents: many(monitorsToIncidents), 81 - workspace: one(workspace, { 82 - fields: [monitor.workspaceId], 83 - references: [workspace.id], 84 - }), 85 - monitorsToNotifications: many(notificationsToMonitors), 86 - })); 87 - 88 - export const monitorsToPages = sqliteTable( 89 - "monitors_to_pages", 90 - { 91 - monitorId: integer("monitor_id") 92 - .notNull() 93 - .references(() => monitor.id, { onDelete: "cascade" }), 94 - pageId: integer("page_id") 95 - .notNull() 96 - .references(() => page.id, { onDelete: "cascade" }), 97 - }, 98 - (t) => ({ 99 - pk: primaryKey(t.monitorId, t.pageId), 100 - }), 101 - ); 102 - 103 - export const monitorsToPagesRelation = relations( 104 - monitorsToPages, 105 - ({ one }) => ({ 106 - monitor: one(monitor, { 107 - fields: [monitorsToPages.monitorId], 108 - references: [monitor.id], 109 - }), 110 - page: one(page, { 111 - fields: [monitorsToPages.pageId], 112 - references: [page.id], 113 - }), 114 - }), 115 - ); 116 - 117 - // Schema for inserting a Monitor - can be used to validate API requests 118 - export const insertMonitorSchema = createInsertSchema(monitor, { 119 - periodicity: periodicityEnum, 120 - url: z.string().url(), 121 - status: z.enum(status).default("active"), 122 - active: z.boolean().default(false), 123 - regions: z.array(RegionEnum).default([]).optional(), 124 - method: z.enum(methods).default("GET"), 125 - body: z.string().default("").optional(), 126 - headers: z 127 - .array(z.object({ key: z.string(), value: z.string() })) 128 - .default([]), 129 - }).extend({ 130 - notifications: z.array(z.number()).optional(), 131 - }); 132 - 133 - export const basicMonitorSchema = createSelectSchema(monitor); 134 - 135 - // Schema for selecting a Monitor - can be used to validate API responses 136 - export const selectMonitorSchema = createSelectSchema(monitor, { 137 - periodicity: periodicityEnum, 138 - status: z.enum(status).default("active"), 139 - jobType: z.enum(["website", "cron", "other"]).default("other"), 140 - active: z.boolean().default(false), 141 - regions: z 142 - .preprocess((val) => { 143 - if (String(val).length > 0) { 144 - return String(val).split(","); 145 - } else { 146 - return []; 147 - } 148 - }, z.array(RegionEnum)) 149 - .default([]), 150 - method: z.enum(methods).default("GET"), 151 - }); 152 - 153 - // FIXME: can be removed as we do not use the advanced tab anymore 154 - export const selectMonitorExtendedSchema = selectMonitorSchema.extend({ 155 - method: z.enum(methods).default("GET"), 156 - body: z 157 - .preprocess((val) => { 158 - return String(val); 159 - }, z.string()) 160 - .default(""), 161 - headers: z.preprocess( 162 - (val) => { 163 - // early return in case the header is already an array 164 - if (Array.isArray(val)) { 165 - return val; 166 - } 167 - if (String(val).length > 0) { 168 - return JSON.parse(String(val)); 169 - } else { 170 - return []; 171 - } 172 - }, 173 - z.array(z.object({ key: z.string(), value: z.string() })).default([]), 174 - ), 175 - }); 176 - 177 - // FIXME: ExtendedMonitor can be renamed to Monitor 178 - export const allMonitorsExtendedSchema = z.array(selectMonitorExtendedSchema); 179 - export type ExtendedMonitor = z.infer<typeof selectMonitorExtendedSchema>; 180 - export type ExtendedMonitors = z.infer<typeof allMonitorsExtendedSchema>;
+43
packages/db/src/schema/monitors/constants.ts
··· 1 + /** 2 + * @deprecated 3 + */ 4 + export const vercelRegions = [ 5 + "arn1", 6 + "bom1", 7 + "cdg1", 8 + "cle1", 9 + "cpt1", 10 + "dub1", 11 + "fra1", 12 + "gru1", 13 + "hkg1", 14 + "hnd1", 15 + "iad1", 16 + "icn1", 17 + "kix1", 18 + "lhr1", 19 + "pdx1", 20 + "sfo1", 21 + "sin1", 22 + "syd1", 23 + ] as const; 24 + 25 + export const flyRegions = ["ams", "iad", "hkg", "jnb", "syd", "gru"] as const; 26 + 27 + export const monitorPeriodicity = [ 28 + "1m", 29 + "5m", 30 + "10m", 31 + "30m", 32 + "1h", 33 + "other", 34 + ] as const; 35 + export const monitorMethods = ["GET", "POST", "HEAD"] as const; 36 + export const monitorStatus = ["active", "error"] as const; 37 + export const monitorRegions = [ 38 + ...flyRegions, 39 + ...vercelRegions, 40 + "auto", 41 + ] as const; 42 + 43 + export const monitorJobTypes = ["website", "cron", "other"] as const;
+4
packages/db/src/schema/monitors/index.ts
··· 1 + export * from "./constants"; 2 + export * from "./monitor"; 3 + export * from "./validation"; 4 + export type * from "./validation";
+88
packages/db/src/schema/monitors/monitor.ts
··· 1 + import { relations, sql } from "drizzle-orm"; 2 + import { 3 + integer, 4 + primaryKey, 5 + sqliteTable, 6 + text, 7 + } from "drizzle-orm/sqlite-core"; 8 + 9 + import { monitorsToIncidents } from "../incidents"; 10 + import { notificationsToMonitors } from "../notifications"; 11 + import { page } from "../pages"; 12 + import { workspace } from "../workspaces"; 13 + import { 14 + monitorJobTypes, 15 + monitorMethods, 16 + monitorPeriodicity, 17 + monitorStatus, 18 + } from "./constants"; 19 + 20 + export const monitor = sqliteTable("monitor", { 21 + id: integer("id").primaryKey(), 22 + jobType: text("job_type", { enum: monitorJobTypes }) 23 + .default("other") 24 + .notNull(), 25 + periodicity: text("periodicity", { enum: monitorPeriodicity }) 26 + .default("other") 27 + .notNull(), 28 + status: text("status", { enum: monitorStatus }).default("active").notNull(), 29 + active: integer("active", { mode: "boolean" }).default(false), 30 + 31 + regions: text("regions").default("").notNull(), 32 + 33 + url: text("url", { length: 2048 }).notNull(), 34 + 35 + name: text("name", { length: 256 }).default("").notNull(), 36 + description: text("description").default("").notNull(), 37 + 38 + headers: text("headers").default(""), 39 + body: text("body").default(""), 40 + method: text("method", { enum: monitorMethods }).default("GET"), 41 + workspaceId: integer("workspace_id").references(() => workspace.id), 42 + 43 + createdAt: integer("created_at", { mode: "timestamp" }).default( 44 + sql`(strftime('%s', 'now'))`, 45 + ), 46 + updatedAt: integer("updated_at", { mode: "timestamp" }).default( 47 + sql`(strftime('%s', 'now'))`, 48 + ), 49 + }); 50 + 51 + export const monitorRelation = relations(monitor, ({ one, many }) => ({ 52 + monitorsToPages: many(monitorsToPages), 53 + monitorsToIncidents: many(monitorsToIncidents), 54 + workspace: one(workspace, { 55 + fields: [monitor.workspaceId], 56 + references: [workspace.id], 57 + }), 58 + monitorsToNotifications: many(notificationsToMonitors), 59 + })); 60 + 61 + export const monitorsToPages = sqliteTable( 62 + "monitors_to_pages", 63 + { 64 + monitorId: integer("monitor_id") 65 + .notNull() 66 + .references(() => monitor.id, { onDelete: "cascade" }), 67 + pageId: integer("page_id") 68 + .notNull() 69 + .references(() => page.id, { onDelete: "cascade" }), 70 + }, 71 + (t) => ({ 72 + pk: primaryKey(t.monitorId, t.pageId), 73 + }), 74 + ); 75 + 76 + export const monitorsToPagesRelation = relations( 77 + monitorsToPages, 78 + ({ one }) => ({ 79 + monitor: one(monitor, { 80 + fields: [monitorsToPages.monitorId], 81 + references: [monitor.id], 82 + }), 83 + page: one(page, { 84 + fields: [monitorsToPages.pageId], 85 + references: [page.id], 86 + }), 87 + }), 88 + );
+82
packages/db/src/schema/monitors/validation.ts
··· 1 + import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 2 + import { z } from "zod"; 3 + 4 + import { 5 + monitorJobTypes, 6 + monitorMethods, 7 + monitorPeriodicity, 8 + monitorRegions, 9 + monitorStatus, 10 + } from "./constants"; 11 + import { monitor } from "./monitor"; 12 + 13 + export const monitorPeriodicitySchema = z.enum(monitorPeriodicity); 14 + export const monitorMethodsSchema = z.enum(monitorMethods); 15 + export const monitorStatusSchema = z.enum(monitorStatus); 16 + export const monitorRegionSchema = z.enum(monitorRegions); 17 + export const monitorJobTypesSchema = z.enum(monitorJobTypes); 18 + 19 + // TODO: shared function 20 + function stringToArrayProcess<T>(string: T) {} 21 + 22 + const regionsToArraySchema = z.preprocess((val) => { 23 + if (String(val).length > 0) { 24 + return String(val).split(","); 25 + } else { 26 + return []; 27 + } 28 + }, z.array(monitorRegionSchema)); 29 + 30 + const bodyToStringSchema = z.preprocess((val) => { 31 + return String(val); 32 + }, z.string()); 33 + 34 + const headersToArraySchema = z.preprocess( 35 + (val) => { 36 + // early return in case the header is already an array 37 + if (Array.isArray(val)) { 38 + return val; 39 + } 40 + if (String(val).length > 0) { 41 + return JSON.parse(String(val)); 42 + } else { 43 + return []; 44 + } 45 + }, 46 + z.array(z.object({ key: z.string(), value: z.string() })).default([]), 47 + ); 48 + 49 + export const selectMonitorSchema = createSelectSchema(monitor, { 50 + periodicity: monitorPeriodicitySchema.default("10m"), 51 + status: monitorStatusSchema.default("active"), 52 + jobType: monitorJobTypesSchema.default("other"), 53 + regions: regionsToArraySchema.default([]), 54 + }).extend({ 55 + headers: headersToArraySchema.default([]), 56 + body: bodyToStringSchema.default(""), 57 + method: monitorMethodsSchema.default("GET"), 58 + }); 59 + 60 + const headersSchema = z 61 + .array(z.object({ key: z.string(), value: z.string() })) 62 + .optional(); 63 + 64 + export const insertMonitorSchema = createInsertSchema(monitor, { 65 + periodicity: monitorPeriodicitySchema.default("10m"), 66 + url: z.string().url(), // find a better way to not always start with "https://" including the `InputWithAddons` 67 + status: monitorStatusSchema.default("active"), 68 + regions: z.array(monitorRegionSchema).default([]).optional(), 69 + headers: headersSchema.default([]), 70 + }).extend({ 71 + method: monitorMethodsSchema.default("GET"), 72 + notifications: z.array(z.number()).optional(), 73 + body: z.string().default("").optional(), 74 + }); 75 + 76 + export type InsertMonitor = z.infer<typeof insertMonitorSchema>; 77 + export type Monitor = z.infer<typeof selectMonitorSchema>; 78 + export type MonitorStatus = z.infer<typeof monitorStatusSchema>; 79 + export type MonitorPeriodicity = z.infer<typeof monitorPeriodicitySchema>; 80 + export type MonitorMethod = z.infer<typeof monitorMethodsSchema>; 81 + export type MonitorRegion = z.infer<typeof monitorRegionSchema>; 82 + export type MonitorJobType = z.infer<typeof monitorJobTypesSchema>;
+4 -27
packages/db/src/schema/notification.ts packages/db/src/schema/notifications/notification.ts
··· 8 8 import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 9 9 import * as z from "zod"; 10 10 11 - import { monitor } from "./monitor"; 12 - import { workspace } from "./workspace"; 13 - 14 - export const providerName = ["email", "discord", "slack"] as const; 11 + import { monitor } from "../monitors"; 12 + import { workspace } from "../workspaces"; 13 + import { notificationProvider } from "./constants"; 15 14 16 15 export const notification = sqliteTable("notification", { 17 16 id: integer("id").primaryKey(), 18 17 name: text("name").notNull(), 19 - provider: text("provider", { enum: providerName }).notNull(), 18 + provider: text("provider", { enum: notificationProvider }).notNull(), 20 19 data: text("data").default("{}"), 21 20 workspaceId: integer("workspace_id").references(() => workspace.id), 22 21 createdAt: integer("created_at", { mode: "timestamp" }).default( ··· 66 65 monitor: many(notificationsToMonitors), 67 66 }), 68 67 ); 69 - 70 - export const providerEnum = z.enum(providerName); 71 - 72 - export const selectNotificationSchema = createSelectSchema(notification).extend( 73 - { 74 - data: z 75 - .preprocess((val) => { 76 - return String(val); 77 - }, z.string()) 78 - .default(""), 79 - }, 80 - ); 81 - 82 - export const insertNotificationSchema = createInsertSchema(notification).extend( 83 - { 84 - data: z.string().default("").optional(), 85 - }, 86 - ); 87 - 88 - export const allNotifications = z.array(selectNotificationSchema); 89 - 90 - export type Notification = z.infer<typeof selectNotificationSchema>;
+1
packages/db/src/schema/notifications/constants.ts
··· 1 + export const notificationProvider = ["email", "discord", "slack"] as const;
+4
packages/db/src/schema/notifications/index.ts
··· 1 + export * from "./constants"; 2 + export * from "./notification"; 3 + export * from "./validation"; 4 + export type * from "./validation";
+28
packages/db/src/schema/notifications/validation.ts
··· 1 + import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 2 + import * as z from "zod"; 3 + 4 + import { notificationProvider } from "./constants"; 5 + import { notification } from "./notification"; 6 + 7 + export const notificationProviderSchema = z.enum(notificationProvider); 8 + 9 + export const selectNotificationSchema = createSelectSchema(notification).extend( 10 + { 11 + data: z 12 + .preprocess((val) => { 13 + return String(val); 14 + }, z.string()) 15 + .default("{}"), 16 + }, 17 + ); 18 + 19 + // we need to extend, otherwise data can be `null` or `undefined` - default is not 20 + export const insertNotificationSchema = createInsertSchema(notification).extend( 21 + { 22 + data: z.string().default("{}"), 23 + }, 24 + ); 25 + 26 + export type InsertNotification = z.infer<typeof insertNotificationSchema>; 27 + export type Notification = z.infer<typeof selectNotificationSchema>; 28 + export type NotificationProvider = z.infer<typeof notificationProviderSchema>;
-74
packages/db/src/schema/page.ts
··· 1 - import { relations, sql } from "drizzle-orm"; 2 - import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 - import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 4 - import { z } from "zod"; 5 - 6 - import { pagesToIncidents } from "./incident"; 7 - import { monitorsToPages } from "./monitor"; 8 - import { workspace } from "./workspace"; 9 - 10 - export const page = sqliteTable("page", { 11 - id: integer("id").primaryKey(), 12 - 13 - workspaceId: integer("workspace_id") 14 - .notNull() 15 - .references(() => workspace.id, { onDelete: "cascade" }), 16 - 17 - title: text("title").notNull(), // title of the page 18 - description: text("description").notNull(), // description of the page 19 - icon: text("icon", { length: 256 }).default(""), // icon of the page 20 - slug: text("slug", { length: 256 }).notNull().unique(), // which is used for https://slug.openstatus.dev 21 - customDomain: text("custom_domain", { length: 256 }).notNull(), 22 - published: integer("published", { mode: "boolean" }).default(false), 23 - 24 - createdAt: integer("created_at", { mode: "timestamp" }).default( 25 - sql`(strftime('%s', 'now'))`, 26 - ), 27 - updatedAt: integer("updated_at", { mode: "timestamp" }).default( 28 - sql`(strftime('%s', 'now'))`, 29 - ), 30 - }); 31 - 32 - export const pageRelations = relations(page, ({ many, one }) => ({ 33 - monitorsToPages: many(monitorsToPages), 34 - pagesToIncidents: many(pagesToIncidents), 35 - workspace: one(workspace, { 36 - fields: [page.workspaceId], 37 - references: [workspace.id], 38 - }), 39 - })); 40 - 41 - const slugSchema = z 42 - .string() 43 - .regex( 44 - new RegExp("^[A-Za-z0-9-]+$"), 45 - "Only use digits (0-9), hyphen (-) or characters (A-Z, a-z).", 46 - ) 47 - .min(3) 48 - .toLowerCase(); 49 - 50 - const customDomainSchema = z 51 - .string() 52 - .regex( 53 - new RegExp("^(?!https?://|www.)([a-zA-Z0-9]+(.[a-zA-Z0-9]+)+.*)$"), 54 - "Should not start with http://, https:// or www.", 55 - ) 56 - .or(z.enum([""])); 57 - 58 - // Schema for inserting a Page - can be used to validate API requests 59 - export const insertPageSchema = createInsertSchema(page, { 60 - customDomain: customDomainSchema.optional(), 61 - icon: z.string().optional(), 62 - slug: slugSchema, 63 - }); 64 - 65 - export const insertPageSchemaWithMonitors = insertPageSchema.extend({ 66 - customDomain: customDomainSchema.optional().default(""), 67 - monitors: z.array(z.number()).optional(), 68 - workspaceSlug: z.string().optional(), 69 - slug: slugSchema, 70 - }); 71 - 72 - // Schema for selecting a Page - can be used to validate API responses 73 - export const selectPageSchema = createSelectSchema(page); 74 - export type Page = z.infer<typeof selectPageSchema>;
+3
packages/db/src/schema/pages/index.ts
··· 1 + export * from "./page"; 2 + export * from "./validation"; 3 + export type * from "./validation";
+37
packages/db/src/schema/pages/page.ts
··· 1 + import { relations, sql } from "drizzle-orm"; 2 + import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 + 4 + import { pagesToIncidents } from "../incidents"; 5 + import { monitorsToPages } from "../monitors"; 6 + import { workspace } from "../workspaces"; 7 + 8 + export const page = sqliteTable("page", { 9 + id: integer("id").primaryKey(), 10 + 11 + workspaceId: integer("workspace_id") 12 + .notNull() 13 + .references(() => workspace.id, { onDelete: "cascade" }), 14 + 15 + title: text("title").notNull(), // title of the page 16 + description: text("description").notNull(), // description of the page 17 + icon: text("icon", { length: 256 }).default(""), // icon of the page 18 + slug: text("slug", { length: 256 }).notNull().unique(), // which is used for https://slug.openstatus.dev 19 + customDomain: text("custom_domain", { length: 256 }).notNull(), 20 + published: integer("published", { mode: "boolean" }).default(false), 21 + 22 + createdAt: integer("created_at", { mode: "timestamp" }).default( 23 + sql`(strftime('%s', 'now'))`, 24 + ), 25 + updatedAt: integer("updated_at", { mode: "timestamp" }).default( 26 + sql`(strftime('%s', 'now'))`, 27 + ), 28 + }); 29 + 30 + export const pageRelations = relations(page, ({ many, one }) => ({ 31 + monitorsToPages: many(monitorsToPages), 32 + pagesToIncidents: many(pagesToIncidents), 33 + workspace: one(workspace, { 34 + fields: [page.workspaceId], 35 + references: [workspace.id], 36 + }), 37 + }));
+35
packages/db/src/schema/pages/validation.ts
··· 1 + import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 2 + import { z } from "zod"; 3 + 4 + import { page } from "./page"; 5 + 6 + const slugSchema = z 7 + .string() 8 + .regex( 9 + new RegExp("^[A-Za-z0-9-]+$"), 10 + "Only use digits (0-9), hyphen (-) or characters (A-Z, a-z).", 11 + ) 12 + .min(3) 13 + .toLowerCase(); 14 + 15 + const customDomainSchema = z 16 + .string() 17 + .regex( 18 + new RegExp("^(?!https?://|www.)([a-zA-Z0-9]+(.[a-zA-Z0-9]+)+.*)$"), 19 + "Should not start with http://, https:// or www.", 20 + ) 21 + .or(z.enum([""])); 22 + 23 + export const insertPageSchema = createInsertSchema(page, { 24 + customDomain: customDomainSchema.default(""), 25 + icon: z.string().optional(), 26 + slug: slugSchema, 27 + }).extend({ 28 + monitors: z.array(z.number()).optional().default([]), 29 + workspaceSlug: z.string(), 30 + }); 31 + 32 + export const selectPageSchema = createSelectSchema(page); 33 + 34 + export type InsertPage = z.infer<typeof insertPageSchema>; 35 + export type Page = z.infer<typeof selectPageSchema>;
+3 -3
packages/db/src/schema/shared.ts
··· 1 1 import { z } from "zod"; 2 2 3 - import { selectIncidentSchema, selectIncidentUpdateSchema } from "./incident"; 4 - import { selectMonitorSchema } from "./monitor"; 5 - import { selectPageSchema } from "./page"; 3 + import { selectIncidentSchema, selectIncidentUpdateSchema } from "./incidents"; 4 + import { selectMonitorSchema } from "./monitors"; 5 + import { selectPageSchema } from "./pages"; 6 6 7 7 export const selectIncidentsPageSchema = z.array( 8 8 selectIncidentSchema.extend({
+1 -1
packages/db/src/schema/user.ts packages/db/src/schema/users/user.ts
··· 6 6 text, 7 7 } from "drizzle-orm/sqlite-core"; 8 8 9 - import { workspace } from "./workspace"; 9 + import { workspace } from "../workspaces"; 10 10 11 11 export const user = sqliteTable("user", { 12 12 id: integer("id").primaryKey(),
+1
packages/db/src/schema/users/index.ts
··· 1 + export * from "./user";
+4 -15
packages/db/src/schema/workspace.ts packages/db/src/schema/workspaces/workspace.ts
··· 1 1 import { relations, sql } from "drizzle-orm"; 2 2 import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 - import { createSelectSchema } from "drizzle-zod"; 4 - import { z } from "zod"; 5 3 6 - import { page } from "./page"; 7 - import { usersToWorkspaces } from "./user"; 8 - 9 - const plan = ["free", "pro"] as const; 4 + import { page } from "../pages"; 5 + import { usersToWorkspaces } from "../users"; 6 + import { workspacePlans } from "./constants"; 10 7 11 8 export const workspace = sqliteTable("workspace", { 12 9 id: integer("id").primaryKey(), ··· 15 12 16 13 stripeId: text("stripe_id", { length: 256 }).unique(), 17 14 subscriptionId: text("subscription_id"), 18 - plan: text("plan", plan), 15 + plan: text("plan", { enum: workspacePlans }), 19 16 endsAt: integer("ends_at", { mode: "timestamp" }), 20 17 paidUntil: integer("paid_until", { mode: "timestamp" }), 21 18 ··· 31 28 usersToWorkspaces: many(usersToWorkspaces), 32 29 pages: many(page), 33 30 })); 34 - 35 - export const selectWorkspaceSchema = createSelectSchema(workspace).extend({ 36 - plan: z 37 - .enum(plan) 38 - .nullable() 39 - .default("free") 40 - .transform((val) => val ?? "free"), 41 - });
+1
packages/db/src/schema/workspaces/constants.ts
··· 1 + export const workspacePlans = ["free", "pro"] as const;
+4
packages/db/src/schema/workspaces/index.ts
··· 1 + export * from "./constants"; 2 + export * from "./workspace"; 3 + export * from "./validation"; 4 + export type * from "./validation";
+20
packages/db/src/schema/workspaces/validation.ts
··· 1 + import { createSelectSchema } from "drizzle-zod"; 2 + import { z } from "zod"; 3 + 4 + import { workspacePlans } from "./constants"; 5 + import { workspace } from "./workspace"; 6 + 7 + export const workspacePlanSchema = z.enum(workspacePlans); 8 + 9 + export const selectWorkspaceSchema = createSelectSchema(workspace).extend({ 10 + plan: z 11 + .enum(workspacePlans) 12 + .nullable() 13 + .default("free") 14 + .transform((val) => val ?? "free"), 15 + }); 16 + 17 + export const insertWorkspaceSchema = createSelectSchema(workspace); 18 + 19 + export type Workspace = z.infer<typeof selectWorkspaceSchema>; 20 + export type WorkspacePlan = z.infer<typeof workspacePlanSchema>;
+4 -8
packages/notifications/email/src/index.ts
··· 1 - import type { z } from "zod"; 2 - 3 - import type { 4 - basicMonitorSchema, 5 - selectNotificationSchema, 6 - } from "@openstatus/db/src/schema"; 1 + import type { Monitor, Notification } from "@openstatus/db/src/schema"; 7 2 8 3 import { env } from "../env"; 9 4 import { EmailConfigurationSchema } from "./schema/config"; ··· 12 7 monitor, 13 8 notification, 14 9 }: { 15 - monitor: z.infer<typeof basicMonitorSchema>; 16 - notification: z.infer<typeof selectNotificationSchema>; 10 + monitor: Monitor; 11 + notification: Notification; 17 12 }) => { 13 + // FIXME: 18 14 const config = EmailConfigurationSchema.parse(JSON.parse(notification.data)); 19 15 const { email } = config; 20 16
+2 -4
packages/plans/index.ts
··· 1 - import type * as z from "zod"; 2 - 3 - import type { periodicityEnum } from "@openstatus/db/src/schema"; 1 + import type { MonitorPeriodicity } from "@openstatus/db/src/schema"; 4 2 5 3 export type Plan = { 6 4 limits: { 7 5 monitors: number; 8 6 "status-pages": number; 9 - periodicity: Partial<z.infer<typeof periodicityEnum>>[]; 7 + periodicity: Partial<MonitorPeriodicity>[]; 10 8 }; 11 9 }; 12 10