Openstatus www.openstatus.dev

๐Ÿš€ SMS notifications (#463)

* ๐Ÿš€

* ๐Ÿš€

* ๐Ÿ›‚ new enw

authored by

Thibault Le Ouay and committed by
GitHub
a4c8e0f3 8af1a6cf

+197 -48
+3 -1
apps/server/.env.example
··· 5 5 TINY_BIRD_API_KEY= 6 6 UPSTASH_REDIS_REST_URL= 7 7 UPSTASH_REDIS_REST_TOKEN= 8 - RESEND_API_KEY= 8 + RESEND_API_KEY= 9 + TWILLIO_AUTH_TOKEN=your_auth_token 10 + TWILLIO_ACCOUNT_ID=your_account_id
+1
apps/server/package.json
··· 17 17 "@openstatus/notification-discord": "workspace:*", 18 18 "@openstatus/notification-emails": "workspace:*", 19 19 "@openstatus/notification-slack": "workspace:*", 20 + "@openstatus/notification-twillio-sms": "workspace:*", 20 21 "@openstatus/plans": "workspace:*", 21 22 "@openstatus/tinybird": "workspace:*", 22 23 "@openstatus/upstash": "workspace:*",
+2
apps/server/src/checker/utils.ts
··· 6 6 import { sendDiscordMessage } from "@openstatus/notification-discord"; 7 7 import { send as sendEmail } from "@openstatus/notification-emails"; 8 8 import { sendSlackMessage } from "@openstatus/notification-slack"; 9 + import { sendTextMessage } from "@openstatus/notification-twillio-sms"; 9 10 import type { flyRegionsDict } from "@openstatus/utils"; 10 11 11 12 type SendNotification = ({ ··· 24 25 email: sendEmail, 25 26 slack: sendSlackMessage, 26 27 discord: sendDiscordMessage, 28 + sms: sendTextMessage, 27 29 } satisfies Record<NotificationProvider, SendNotification>;
+3 -2
apps/server/src/v1/incident.ts
··· 7 7 incidentUpdate, 8 8 } from "@openstatus/db/src/schema"; 9 9 10 + import { incidentUpdateSchema } from "./incidentUpdate"; 10 11 import type { Variables } from "./index"; 11 12 import { ErrorSchema } from "./shared"; 12 13 ··· 26 27 }), 27 28 }); 28 29 29 - const incidentUpdateSchema = z.object({ 30 + const createIncidentUpdateSchema = z.object({ 30 31 status: z.enum(incidentStatus).openapi({ 31 32 description: "The status of the update", 32 33 }), ··· 280 281 description: "the incident update", 281 282 content: { 282 283 "application/json": { 283 - schema: incidentUpdateSchema, 284 + schema: createIncidentUpdateSchema, 284 285 }, 285 286 }, 286 287 },
+1 -1
apps/server/src/v1/incidentUpdate.ts
··· 26 26 }), 27 27 }); 28 28 29 - const incidentUpdateSchema = z.object({ 29 + export const incidentUpdateSchema = z.object({ 30 30 status: z.enum(incidentStatus).openapi({ 31 31 description: "The status of the update", 32 32 }),
+5 -2
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/edit/page.tsx
··· 13 13 }); 14 14 15 15 export default async function EditPage({ 16 - params, 17 16 searchParams, 18 17 }: { 19 18 params: { workspaceSlug: string }; ··· 24 23 if (!search.success) { 25 24 return notFound(); 26 25 } 26 + const workspace = await api.workspace.getWorkspace.query(); 27 27 28 28 const { id } = search.data; 29 29 ··· 42 42 } 43 43 /> 44 44 <div className="col-span-full"> 45 - <NotificationForm defaultValues={notification} /> 45 + <NotificationForm 46 + defaultValues={notification} 47 + workspacePlan={workspace.plan} 48 + /> 46 49 </div> 47 50 </div> 48 51 );
+4 -1
apps/web/src/components/forms/monitor-form.tsx
··· 711 711 Get alerted when your endpoint is down. 712 712 </DialogDescription> 713 713 </DialogHeader> 714 - <NotificationForm onSubmit={() => setOpenDialog(false)} /> 714 + <NotificationForm 715 + onSubmit={() => setOpenDialog(false)} 716 + workspacePlan={plan} 717 + /> 715 718 </DialogContent> 716 719 <FailedPingAlertConfirmation 717 720 monitor={form.getValues()}
+14
apps/web/src/components/forms/notification-form.tsx
··· 8 8 import type { 9 9 InsertNotification, 10 10 NotificationProvider, 11 + WorkspacePlan, 11 12 } from "@openstatus/db/src/schema"; 12 13 import { 13 14 insertNotificationSchema, ··· 73 74 setupDocLink: "https://support.discord.com/hc/en-us/articles/228383668", 74 75 sendTest: sendTestDiscordMessage, 75 76 }; 77 + case "sms": 78 + return { 79 + dataType: "tel", 80 + placeholder: "+123456789", 81 + setupDocLink: null, 82 + sendTest: null, 83 + isPro: true, 84 + }; 76 85 77 86 default: 78 87 return { ··· 87 96 interface Props { 88 97 defaultValues?: InsertNotification; 89 98 onSubmit?: () => void; 99 + workspacePlan: WorkspacePlan; 90 100 } 91 101 92 102 export function NotificationForm({ 93 103 defaultValues, 94 104 onSubmit: onExternalSubmit, 105 + workspacePlan, 95 106 }: Props) { 96 107 const [isPending, startTransition] = useTransition(); 97 108 const [isTestPending, startTestTransition] = useTransition(); ··· 232 243 type={providerMetaData.dataType} 233 244 placeholder={providerMetaData.placeholder} 234 245 {...field} 246 + disabled={ 247 + providerMetaData.isPro && workspacePlan !== "pro" 248 + } 235 249 /> 236 250 </FormControl> 237 251 <FormDescription className="flex items-center justify-between">
-37
apps/web/src/components/modals/notification-dialog.tsx
··· 1 - "use client"; 2 - 3 - import { useState } from "react"; 4 - 5 - import { 6 - Button, 7 - Dialog, 8 - DialogContent, 9 - DialogDescription, 10 - DialogHeader, 11 - DialogTitle, 12 - DialogTrigger, 13 - } from "@openstatus/ui"; 14 - 15 - import { NotificationForm } from "@/components/forms/notification-form"; 16 - 17 - export const NotificationDialog = ({}: { workspaceSlug: string }) => { 18 - const [open, setOpen] = useState(false); 19 - return ( 20 - <Dialog open={open} onOpenChange={(val) => setOpen(val)}> 21 - <DialogTrigger asChild> 22 - <Button variant="default" size="sm"> 23 - Add Notifications 24 - </Button> 25 - </DialogTrigger> 26 - <DialogContent className="sm:max-w-2xl"> 27 - <DialogHeader> 28 - <DialogTitle>Add Notification</DialogTitle> 29 - <DialogDescription> 30 - Get alerted when your endpoint is down. 31 - </DialogDescription> 32 - </DialogHeader> 33 - <NotificationForm onSubmit={() => setOpen(false)} /> 34 - </DialogContent> 35 - </Dialog> 36 - ); 37 - };
+6 -1
packages/db/src/schema/notifications/constants.ts
··· 1 - export const notificationProvider = ["email", "discord", "slack"] as const; 1 + export const notificationProvider = [ 2 + "email", 3 + "discord", 4 + "slack", 5 + "sms", 6 + ] as const;
-2
packages/db/src/schema/notifications/notification.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 9 import { monitor } from "../monitors"; 12 10 import { workspace } from "../workspaces";
+2
packages/notifications/twillio-sms/.env.example
··· 1 + TWILLIO_AUTH_TOKEN=your_auth_token 2 + TWILLIO_ACCOUNT_ID=your_account_id
+20
packages/notifications/twillio-sms/package.json
··· 1 + { 2 + "name": "@openstatus/notification-twillio-sms", 3 + "version": "0.0.0", 4 + "main": "src/index.ts", 5 + "dependencies": { 6 + "@openstatus/db": "workspace:*", 7 + "@t3-oss/env-core": "0.7.1", 8 + "@types/validator": "13.11.6", 9 + "validator": "13.11.0", 10 + "zod": "3.22.2" 11 + }, 12 + "devDependencies": { 13 + "@openstatus/tsconfig": "workspace:*", 14 + "@types/node": "20.8.0", 15 + "@types/react": "18.2.24", 16 + "@types/react-dom": "18.2.8", 17 + "next": "13.5.3", 18 + "typescript": "5.2.2" 19 + } 20 + }
+30
packages/notifications/twillio-sms/src/env.ts
··· 1 + import { createEnv } from "@t3-oss/env-core"; 2 + import { z } from "zod"; 3 + 4 + export const env = createEnv({ 5 + server: { 6 + TWILLIO_AUTH_TOKEN: z.string(), 7 + TWILLIO_ACCOUNT_ID: z.string(), 8 + }, 9 + 10 + /** 11 + * What object holds the environment variables at runtime. This is usually 12 + * `process.env` or `import.meta.env`. 13 + */ 14 + runtimeEnv: process.env, 15 + 16 + /** 17 + * By default, this library will feed the environment variables directly to 18 + * the Zod validator. 19 + * 20 + * This means that if you have an empty string for a value that is supposed 21 + * to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag 22 + * it as a type mismatch violation. Additionally, if you have an empty string 23 + * for a value that is supposed to be a string with a default value (e.g. 24 + * `DOMAIN=` in an ".env" file), the default value will never be applied. 25 + * 26 + * In order to solve these issues, we recommend that all new projects 27 + * explicitly specify this option as true. 28 + */ 29 + skipValidation: true, 30 + });
+47
packages/notifications/twillio-sms/src/index.ts
··· 1 + import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 + 3 + import { env } from "./env"; 4 + import { SmsConfigurationSchema } from "./schema/config"; 5 + 6 + export const sendTextMessage = async ({ 7 + monitor, 8 + notification, 9 + region, 10 + statusCode, 11 + }: { 12 + monitor: Monitor; 13 + notification: Notification; 14 + statusCode: number; 15 + region: string; 16 + }) => { 17 + const notificationData = SmsConfigurationSchema.parse( 18 + JSON.parse(notification.data), 19 + ); 20 + const { name } = monitor; 21 + 22 + const body = new FormData(); 23 + body.set("To", notificationData.phoneNumber); 24 + body.set("From", "+14807252613"); 25 + body.set( 26 + "Body", 27 + `Your monitor ${name} / ${monitor.url} is down in ${region} with status code ${statusCode}`, 28 + ); 29 + 30 + try { 31 + fetch( 32 + `https://api.twilio.com/2010-04-01/Accounts/${env.TWILLIO_ACCOUNT_ID}/Messages.json`, 33 + { 34 + method: "post", 35 + body, 36 + headers: { 37 + Authorization: `Basic ${btoa( 38 + `${env.TWILLIO_ACCOUNT_ID}:${env.TWILLIO_AUTH_TOKEN}`, 39 + )}`, 40 + }, 41 + }, 42 + ); 43 + } catch (err) { 44 + console.log(err); 45 + // Do something 46 + } 47 + };
+6
packages/notifications/twillio-sms/src/schema/config.ts
··· 1 + import isMobilephone from "validator/lib/isMobilePhone"; 2 + import { z } from "zod"; 3 + 4 + export const SmsConfigurationSchema = z.object({ 5 + phoneNumber: z.string().refine(isMobilephone), 6 + });
+4
packages/notifications/twillio-sms/tsconfig.json
··· 1 + { 2 + "extends": "@openstatus/tsconfig/nextjs.json", 3 + "include": ["src", "*.ts"] 4 + }
+49
pnpm-lock.yaml
··· 55 55 '@openstatus/notification-slack': 56 56 specifier: workspace:* 57 57 version: link:../../packages/notifications/slack 58 + '@openstatus/notification-twillio-sms': 59 + specifier: workspace:* 60 + version: link:../../packages/notifications/twillio-sms 58 61 '@openstatus/plans': 59 62 specifier: workspace:* 60 63 version: link:../../packages/plans ··· 641 644 '@openstatus/db': 642 645 specifier: workspace:* 643 646 version: link:../../db 647 + devDependencies: 648 + '@openstatus/tsconfig': 649 + specifier: workspace:* 650 + version: link:../../tsconfig 651 + '@types/node': 652 + specifier: 20.8.0 653 + version: 20.8.0 654 + '@types/react': 655 + specifier: 18.2.24 656 + version: 18.2.24 657 + '@types/react-dom': 658 + specifier: 18.2.8 659 + version: 18.2.8 660 + next: 661 + specifier: 13.5.3 662 + version: 13.5.3(@babel/core@7.23.2)(@opentelemetry/api@1.4.1)(react-dom@18.2.0)(react@18.2.0) 663 + typescript: 664 + specifier: 5.2.2 665 + version: 5.2.2 666 + 667 + packages/notifications/twillio-sms: 668 + dependencies: 669 + '@openstatus/db': 670 + specifier: workspace:* 671 + version: link:../../db 672 + '@t3-oss/env-core': 673 + specifier: 0.7.1 674 + version: 0.7.1(typescript@5.2.2)(zod@3.22.2) 675 + '@types/validator': 676 + specifier: 13.11.6 677 + version: 13.11.6 678 + validator: 679 + specifier: 13.11.0 680 + version: 13.11.0 681 + zod: 682 + specifier: 3.22.2 683 + version: 3.22.2 644 684 devDependencies: 645 685 '@openstatus/tsconfig': 646 686 specifier: workspace:* ··· 5222 5262 5223 5263 /@types/unist@2.0.9: 5224 5264 resolution: {integrity: sha512-zC0iXxAv1C1ERURduJueYzkzZ2zaGyc+P2c95hgkikHPr3z8EdUZOlgEQ5X0DRmwDZn+hekycQnoeiiRVrmilQ==} 5265 + 5266 + /@types/validator@13.11.6: 5267 + resolution: {integrity: sha512-HUgHujPhKuNzgNXBRZKYexwoG+gHKU+tnfPqjWXFghZAnn73JElicMkuSKJyLGr9JgyA8IgK7fj88IyA9rwYeQ==} 5268 + dev: false 5225 5269 5226 5270 /@types/ws@8.5.8: 5227 5271 resolution: {integrity: sha512-flUksGIQCnJd6sZ1l5dqCEG/ksaoAg/eUwiLAGTJQcfgvZJKF++Ta4bJA6A5aPSJmsr+xlseHn4KLgVlNnvPTg==} ··· 12865 12909 dependencies: 12866 12910 builtins: 5.0.1 12867 12911 dev: true 12912 + 12913 + /validator@13.11.0: 12914 + resolution: {integrity: sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==} 12915 + engines: {node: '>= 0.10'} 12916 + dev: false 12868 12917 12869 12918 /vfile-location@4.1.0: 12870 12919 resolution: {integrity: sha512-YF23YMyASIIJXpktBa4vIGLJ5Gs88UB/XePgqPmTa7cDA+JeO3yclbpheQYCHjVHBn/yePzrXuygIL+xbvRYHw==}
-1
vitest.workspace.ts
··· 1 - export default ["packages/*"];