diff --git a/apps/docs/images/notification/pagerduty/pagerduty-1.png b/apps/docs/images/notification/pagerduty/pagerduty-1.png new file mode 100644 index 0000000000..f91f8f5319 Binary files /dev/null and b/apps/docs/images/notification/pagerduty/pagerduty-1.png differ diff --git a/apps/docs/images/notification/pagerduty/pagerduty-2.png b/apps/docs/images/notification/pagerduty/pagerduty-2.png new file mode 100644 index 0000000000..ac2f113717 Binary files /dev/null and b/apps/docs/images/notification/pagerduty/pagerduty-2.png differ diff --git a/apps/docs/images/notification/pagerduty/pagerduty-3.png b/apps/docs/images/notification/pagerduty/pagerduty-3.png new file mode 100644 index 0000000000..3b00e96fe4 Binary files /dev/null and b/apps/docs/images/notification/pagerduty/pagerduty-3.png differ diff --git a/apps/docs/mint.json b/apps/docs/mint.json index ddac9e143e..8af1ed9aed 100644 --- a/apps/docs/mint.json +++ b/apps/docs/mint.json @@ -93,6 +93,7 @@ "group": "Notification Channels", "pages": [ "synthetic/features/notification/discord", + "synthetic/features/notification/pagerduty", "synthetic/features/notification/phone-call", "synthetic/features/notification/slack", "synthetic/features/notification/sms", diff --git a/apps/docs/synthetic/features/notification/pagerduty.mdx b/apps/docs/synthetic/features/notification/pagerduty.mdx new file mode 100644 index 0000000000..ff28b52f3e --- /dev/null +++ b/apps/docs/synthetic/features/notification/pagerduty.mdx @@ -0,0 +1,41 @@ +--- +title: PagerDuty +--- + +Get Notified on PagerDuty when we create an incident. + +## How to connect PagerDuty + +Go to the Alerts Page . Select `PagerDuty` from the list of available integrations. + + + + + Connect to PagerDuty + + +You will be redirected to the PagerDuty website to authorize OpenStatus to send notifications to your account. + + + Connect to PagerDuty + + + +Select the service you want to use to send notifications. You can create a new service if you don't have one. + + + Connect to PagerDuty + + +You are now connected to PagerDuty. Give your integration a name and save it. + +You will receive some notifications if we detect an incident \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index e541a1e122..76a0032711 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -19,6 +19,7 @@ "@openstatus/error": "workspace:*", "@openstatus/notification-discord": "workspace:*", "@openstatus/notification-emails": "workspace:*", + "@openstatus/notification-pagerduty": "workspace:*", "@openstatus/notification-slack": "workspace:*", "@openstatus/notification-twillio-sms": "workspace:*", "@openstatus/plans": "workspace:*", diff --git a/apps/server/src/checker/alerting.ts b/apps/server/src/checker/alerting.ts index ce597a4223..a67fc172af 100644 --- a/apps/server/src/checker/alerting.ts +++ b/apps/server/src/checker/alerting.ts @@ -16,11 +16,13 @@ export const triggerNotifications = async ({ statusCode, message, notifType, + incidentId, }: { monitorId: string; statusCode?: number; message?: string; notifType: "alert" | "recovery"; + incidentId?: string; }) => { console.log(`💌 triggerAlerting for ${monitorId}`); const notifications = await db @@ -28,17 +30,17 @@ export const triggerNotifications = async ({ .from(schema.notificationsToMonitors) .innerJoin( schema.notification, - eq(schema.notification.id, schema.notificationsToMonitors.notificationId), + eq(schema.notification.id, schema.notificationsToMonitors.notificationId) ) .innerJoin( schema.monitor, - eq(schema.monitor.id, schema.notificationsToMonitors.monitorId), + eq(schema.monitor.id, schema.notificationsToMonitors.monitorId) ) .where(eq(schema.monitor.id, Number(monitorId))) .all(); for (const notif of notifications) { console.log( - `💌 sending notification for ${monitorId} and chanel ${notif.notification.provider} for ${notifType}`, + `💌 sending notification for ${monitorId} and chanel ${notif.notification.provider} for ${notifType}` ); const monitor = selectMonitorSchema.parse(notif.monitor); switch (notifType) { @@ -48,6 +50,7 @@ export const triggerNotifications = async ({ notification: selectNotificationSchema.parse(notif.notification), statusCode, message, + incidentId, }); break; case "recovery": @@ -56,6 +59,7 @@ export const triggerNotifications = async ({ notification: selectNotificationSchema.parse(notif.notification), statusCode, message, + incidentId, }); break; } diff --git a/apps/server/src/checker/utils.ts b/apps/server/src/checker/utils.ts index e568f4c977..b9d3de05d5 100644 --- a/apps/server/src/checker/utils.ts +++ b/apps/server/src/checker/utils.ts @@ -20,16 +20,23 @@ import { sendRecovery as sendSmsRecovery, } from "@openstatus/notification-twillio-sms"; +import { + sendAlert as sendPagerdutyAlert, + sendRecovery as sendPagerDutyRecovery, +} from "@openstatus/notification-pagerduty"; + type SendNotification = ({ monitor, notification, statusCode, message, + incidentId, }: { monitor: Monitor; notification: Notification; statusCode?: number; message?: string; + incidentId?: string; }) => Promise; type Notif = { @@ -47,4 +54,8 @@ export const providerToFunction = { }, discord: { sendAlert: sendDiscordAlert, sendRecovery: sendDiscordRecovery }, sms: { sendAlert: sendSmsAlert, sendRecovery: sendSmsRecovery }, + pagerduty: { + sendAlert: sendPagerdutyAlert, + sendRecovery: sendPagerDutyRecovery, + }, } satisfies Record; diff --git a/apps/web/.env.example b/apps/web/.env.example index d2c8cd5c14..0d92f6b843 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -19,8 +19,8 @@ DATABASE_URL=file:./../../openstatus-dev.db DATABASE_AUTH_TOKEN=any-token # JITSU - no need to touch on local development -JITSU_HOST="https://your-jitsu-domain.com" -JITSU_WRITE_KEY="jitsu-key:jitsu-secret" +# JITSU_HOST="https://your-jitsu-domain.com" +# JITSU_WRITE_KEY="jitsu-key:jitsu-secret" # Solves 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', see https://github.com/nextauthjs/next-auth/issues/3580 # NODE_TLS_REJECT_UNAUTHORIZED="0" @@ -72,4 +72,6 @@ AUTH_GITHUB_SECRET= AUTH_GOOGLE_ID= AUTH_GOOGLE_SECRET= -PLAIN_API_KEY= \ No newline at end of file +PLAIN_API_KEY= + +PAGERDUTY_APP_ID= diff --git a/apps/web/package.json b/apps/web/package.json index 741dd455e5..890ab852bb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@openstatus/notification-discord": "workspace:*", "@openstatus/notification-emails": "workspace:*", "@openstatus/notification-slack": "workspace:*", + "@openstatus/notification-pagerduty": "workspace:*", "@openstatus/plans": "workspace:*", "@openstatus/react": "workspace:*", "@openstatus/rum": "workspace:*", diff --git a/apps/web/public/assets/changelog/pagerduty-integration.png b/apps/web/public/assets/changelog/pagerduty-integration.png new file mode 100644 index 0000000000..f91f8f5319 Binary files /dev/null and b/apps/web/public/assets/changelog/pagerduty-integration.png differ diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/_components/channel-table.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/_components/channel-table.tsx new file mode 100644 index 0000000000..482f44f49a --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/_components/channel-table.tsx @@ -0,0 +1,85 @@ +import type { Workspace } from "@openstatus/db/src/schema"; +import { getLimit } from "@openstatus/plans"; +import { Button, Separator } from "@openstatus/ui"; +import Link from "next/link"; + +// FIXME: create a Channel Component within the file to avoid code duplication + +interface ChannelTable { + workspace: Workspace; + disabled?: boolean; +} + +export default function ChannelTable({ workspace, disabled }: ChannelTable) { + const isPagerDutyAllowed = getLimit(workspace.plan, "pagerduty"); + const isSMSAllowed = getLimit(workspace.plan, "sms"); + return ( +
+

Channels

+

Connect all your channels

+
+ + + + + + + + + +
+
+ ); +} + +interface ChannelProps { + title: string; + description: string; + href: string; + disabled?: boolean; +} + +function Channel({ title, description, href, disabled }: ChannelProps) { + return ( +
+
+

{title}

+

{description}

+
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/layout.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/layout.tsx index 1dee24c143..88f8516bea 100644 --- a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/layout.tsx +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/layout.tsx @@ -11,22 +11,11 @@ export default async function Layout({ }: { children: React.ReactNode; }) { - const isLimitReached = - await api.notification.isNotificationLimitReached.query(); return (
- Create - - } /> {children} diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/page.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/page.tsx index 1a90187b2c..16a343f0d7 100644 --- a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/page.tsx +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/page.tsx @@ -8,8 +8,10 @@ import { Limit } from "@/components/dashboard/limit"; import { columns } from "@/components/data-table/notification/columns"; import { DataTable } from "@/components/data-table/notification/data-table"; import { api } from "@/trpc/server"; +import ChannelTable from "./_components/channel-table"; export default async function NotificationPage() { + const workspace = await api.workspace.getWorkspace.query(); const notifications = await api.notification.getNotificationsByWorkspace.query(); const isLimitReached = @@ -17,16 +19,14 @@ export default async function NotificationPage() { if (notifications.length === 0) { return ( - - Create - - } - /> + <> + + + ); } @@ -34,6 +34,7 @@ export default async function NotificationPage() { <> {isLimitReached ? : null} + ); } diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/[id]/edit/page.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/[id]/edit/page.tsx index dae17eb59c..e370760640 100644 --- a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/[id]/edit/page.tsx +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/[id]/edit/page.tsx @@ -1,4 +1,4 @@ -import { NotificationForm } from "@/components/forms/notification/form"; +import { NotificationForm } from "@/components/forms/notification-form"; import { api } from "@/trpc/server"; export default async function EditPage({ @@ -16,6 +16,7 @@ export default async function EditPage({ ); } diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/[channel]/page.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/[channel]/page.tsx new file mode 100644 index 0000000000..c8461ed0a9 --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/[channel]/page.tsx @@ -0,0 +1,41 @@ +import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; +import { NotificationForm } from "@/components/forms/notification-form"; +import { api } from "@/trpc/server"; +import { notificationProviderSchema } from "@openstatus/db/src/schema"; +import { getLimit } from "@openstatus/plans"; +import { notFound } from "next/navigation"; + +export default async function ChannelPage({ + params, +}: { + params: { channel: string }; +}) { + const validation = notificationProviderSchema + .exclude(["pagerduty"]) + .safeParse(params.channel); + + if (!validation.success) notFound(); + + const workspace = await api.workspace.getWorkspace.query(); + + const provider = validation.data; + + const allowed = + provider === "sms" ? getLimit(workspace.plan, provider) : true; + + if (!allowed) return ; + + const isLimitReached = + await api.notification.isNotificationLimitReached.query(); + + if (isLimitReached) + return ; + + return ( + + ); +} diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/layout.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/layout.tsx index 18fcbe75ad..6de07383db 100644 --- a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/layout.tsx +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/layout.tsx @@ -8,7 +8,10 @@ export default async function Layout({ }) { return ( -
+
{children} ); diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/page.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/page.tsx deleted file mode 100644 index 6dbccb5eb9..0000000000 --- a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { NotificationForm } from "@/components/forms/notification/form"; -import { api } from "@/trpc/server"; - -export default async function EditPage() { - const workspace = await api.workspace.getWorkspace.query(); - - return ; -} diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/pagerduty/page.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/pagerduty/page.tsx new file mode 100644 index 0000000000..42b770c51c --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/pagerduty/page.tsx @@ -0,0 +1,45 @@ +import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; +import { NotificationForm } from "@/components/forms/notification-form"; +import { api } from "@/trpc/server"; +import { PagerDutySchema } from "@openstatus/notification-pagerduty"; +import { getLimit } from "@openstatus/plans"; +import { z } from "zod"; + +// REMINDER: PagerDuty requires a different workflow, thus the separate page + +const searchParamsSchema = z.object({ + config: z.string().optional(), +}); + +export default async function PagerDutyPage({ + searchParams, +}: { + searchParams: { [key: string]: string | string[] | undefined }; +}) { + const workspace = await api.workspace.getWorkspace.query(); + const params = searchParamsSchema.parse(searchParams); + + if (!params.config) { + return
Invalid data
; + } + + const data = PagerDutySchema.parse(JSON.parse(params.config)); + if (!data) { + return
Invalid data
; + } + + const allowed = getLimit(workspace.plan, "pagerduty"); + + if (!allowed) return ; + + return ( + <> + + + ); +} diff --git a/apps/web/src/components/billing/pro-feature-alert.tsx b/apps/web/src/components/billing/pro-feature-alert.tsx index 78ec3cd378..dd489ed64b 100644 --- a/apps/web/src/components/billing/pro-feature-alert.tsx +++ b/apps/web/src/components/billing/pro-feature-alert.tsx @@ -5,21 +5,26 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; +import type { WorkspacePlan } from "@openstatus/plans"; interface Props { feature: string; + workspacePlan?: WorkspacePlan; } -export function ProFeatureAlert({ feature }: Props) { +export function ProFeatureAlert({ feature, workspacePlan = "pro" }: Props) { const params = useParams<{ workspaceSlug: string }>(); return ( - {feature} is a Pro feature. + + {feature} is a {workspacePlan}{" "} + feature. + If you want to use{" "} - {feature}, please - upgrade your plan. Go to{" "} + {feature} + , please upgrade your plan. Go to{" "} - - - Add Notification - - Get alerted when your endpoint is down. - - - setOpenDialog(false)} - workspacePlan={plan} - /> - = notificationLimit - : false; return (
{/*
@@ -86,8 +80,8 @@ export function SectionNotifications({ form, plan, notifications }: Props) { ]) : field.onChange( field.value?.filter( - (value) => value !== item.id, - ), + (value) => value !== item.id + ) ); }} > @@ -106,34 +100,15 @@ export function SectionNotifications({ form, plan, notifications }: Props) { ))}
{!notifications || notifications.length === 0 ? ( - Missing notifications. + + Create some notifications first. + ) : null} ); }} /> - setOpenDialog(val)}> -
- - - -
- - - Add Notification - - Get alerted when your endpoint is down. - - - setOpenDialog(false)} - workspacePlan={plan} - /> - -
); } diff --git a/apps/web/src/components/forms/notification-form.tsx b/apps/web/src/components/forms/notification-form.tsx index 220cf099f5..2b583b6d6a 100644 --- a/apps/web/src/components/forms/notification-form.tsx +++ b/apps/web/src/components/forms/notification-form.tsx @@ -10,11 +10,7 @@ import type { NotificationProvider, WorkspacePlan, } from "@openstatus/db/src/schema"; -import { - insertNotificationSchema, - notificationProvider, - notificationProviderSchema, -} from "@openstatus/db/src/schema"; +import { insertNotificationSchema } from "@openstatus/db/src/schema"; import { sendTestDiscordMessage } from "@openstatus/notification-discord"; import { sendTestSlackMessage } from "@openstatus/notification-slack"; import { @@ -27,11 +23,6 @@ import { FormLabel, FormMessage, Input, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, } from "@openstatus/ui"; import { LoadingAnimation } from "@/components/loading-animation"; @@ -85,6 +76,15 @@ function getProviderMetaData(provider: NotificationProvider) { sendTest: null, plans: ["pro", "team"], }; + case "pagerduty": + return { + dataType: null, + placeholder: "", + setupDocLink: + "https://docs.openstatus.dev/synthetic/features/notification/pagerduty", + sendTest: null, + plans: ["starter", "pro", "team"], + }; default: return { @@ -102,6 +102,8 @@ interface Props { onSubmit?: () => void; workspacePlan: WorkspacePlan; nextUrl?: string; + provider: NotificationProvider; + callbackData?: string; } export function NotificationForm({ @@ -109,6 +111,8 @@ export function NotificationForm({ onSubmit: onExternalSubmit, workspacePlan, nextUrl, + provider, + callbackData, }: Props) { const [isPending, startTransition] = useTransition(); const [isTestPending, startTestTransition] = useTransition(); @@ -117,20 +121,22 @@ export function NotificationForm({ resolver: zodResolver(insertNotificationSchema), defaultValues: { ...defaultValues, + provider, name: defaultValues?.name || "", data: getDefaultProviderData(defaultValues), }, }); - const watchProvider = form.watch("provider"); const watchWebhookUrl = form.watch("data"); - const providerMetaData = useMemo( - () => getProviderMetaData(watchProvider), - [watchProvider], - ); + const providerMetaData = getProviderMetaData(provider); async function onSubmit({ provider, data, ...rest }: InsertNotification) { startTransition(async () => { try { + if (provider === "pagerduty") { + if (callbackData) { + data = callbackData; + } + } if (data === "") { form.setError("data", { message: "This field is required" }); return; @@ -189,49 +195,6 @@ export function NotificationForm({

- ( - - Provider - - - What channel/provider to send a notification. - - - - )} - /> )} /> - {watchProvider && ( + + {providerMetaData.dataType && ( {/* make the first letter capital */}
- {toCapitalize(watchProvider)} + {toCapitalize(provider)}
- How to setup your {toCapitalize(watchProvider)}{" "} - webhook + How to setup your {toCapitalize(provider)} webhook )} diff --git a/apps/web/src/content/changelog/pagerduty-integration.mdx b/apps/web/src/content/changelog/pagerduty-integration.mdx new file mode 100644 index 0000000000..a998bd8943 --- /dev/null +++ b/apps/web/src/content/changelog/pagerduty-integration.mdx @@ -0,0 +1,8 @@ +--- +title: PagerDuty Integration +description: Get notified via PagerDuty if we detect an incident. +image: /assets/changelog/pagerduty-integration.png +publishedAt: 2024-06-25 +--- + +We've added a PagerDuty integration to the notification feature. This allows you to receive incident alerts through PagerDuty. \ No newline at end of file diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 728bccfe75..5a30024238 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -24,6 +24,7 @@ export const env = createEnv({ CLICKHOUSE_USERNAME: z.string(), CLICKHOUSE_PASSWORD: z.string(), PLAIN_API_KEY: z.string().optional(), + PAGERDUTY_APP_ID: z.string().optional(), }, client: { NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string(), @@ -57,6 +58,7 @@ export const env = createEnv({ CLICKHOUSE_USERNAME: process.env.CLICKHOUSE_USERNAME, CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD, PLAIN_API_KEY: process.env.PLAIN_API_KEY, + PAGERDUTY_APP_ID: process.env.PAGERDUTY_APP_ID, }, skipValidation: true, }); diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index 8bf4ba54f9..9766b1296d 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -11,4 +11,7 @@ export const analytics = }) : emptyAnalytics; -export const trackAnalytics = (args: AnalyticsEvents) => analytics.track(args); +export const trackAnalytics = (args: AnalyticsEvents) => + env.JITSU_HOST && env.JITSU_WRITE_KEY + ? analytics.track(args) + : emptyAnalytics.track(args); diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 5ae3f10887..45f1e54d54 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -8,6 +8,8 @@ export const env = createEnv({ TEAM_ID_VERCEL: z.string(), VERCEL_AUTH_BEARER_TOKEN: z.string(), TINY_BIRD_API_KEY: z.string(), + JITSU_HOST: z.string().optional(), + JITSU_WRITE_KEY: z.string().optional(), }, runtimeEnv: { @@ -16,6 +18,8 @@ export const env = createEnv({ TEAM_ID_VERCEL: process.env.TEAM_ID_VERCEL, VERCEL_AUTH_BEARER_TOKEN: process.env.VERCEL_AUTH_BEARER_TOKEN, TINY_BIRD_API_KEY: process.env.TINY_BIRD_API_KEY, + JITSU_HOST: process.env.JITSU_HOST, + JITSU_WRITE_KEY: process.env.JITSU_WRITE_KEY, }, skipValidation: process.env.NODE_ENV === "test", }); diff --git a/packages/api/src/router/monitor.ts b/packages/api/src/router/monitor.ts index 8c8bf9fa7a..19e0460146 100644 --- a/packages/api/src/router/monitor.ts +++ b/packages/api/src/router/monitor.ts @@ -19,7 +19,6 @@ import { notification, notificationsToMonitors, page, - selectMaintenanceSchema, selectMonitorSchema, selectMonitorTagSchema, selectNotificationSchema, diff --git a/packages/api/src/router/notification.ts b/packages/api/src/router/notification.ts index 706a58f7eb..7b0ac45952 100644 --- a/packages/api/src/router/notification.ts +++ b/packages/api/src/router/notification.ts @@ -13,6 +13,7 @@ import { getLimit } from "@openstatus/plans"; import { SchemaError } from "@openstatus/error"; import { trackNewNotification } from "../analytics"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { env } from "../env"; export const notificationRouter = createTRPCRouter({ create: protectedProcedure @@ -22,7 +23,7 @@ export const notificationRouter = createTRPCRouter({ const notificationLimit = getLimit( opts.ctx.workspace.plan, - "notification-channels", + "notification-channels" ); const notificationNumber = ( @@ -40,7 +41,6 @@ export const notificationRouter = createTRPCRouter({ } const _data = NotificationDataSchema.safeParse(JSON.parse(props.data)); - if (!_data.success) { throw new TRPCError({ code: "BAD_REQUEST", @@ -54,9 +54,11 @@ export const notificationRouter = createTRPCRouter({ .returning() .get(); - await trackNewNotification(opts.ctx.user, { - provider: _notification.provider, - }); + if (env.JITSU_HOST !== undefined && env.JITSU_WRITE_KEY !== undefined) { + await trackNewNotification(opts.ctx.user, { + provider: _notification.provider, + }); + } return _notification; }), @@ -83,8 +85,8 @@ export const notificationRouter = createTRPCRouter({ .where( and( eq(notification.id, opts.input.id), - eq(notification.workspaceId, opts.ctx.workspace.id), - ), + eq(notification.workspaceId, opts.ctx.workspace.id) + ) ) .returning() .get(); @@ -98,8 +100,8 @@ export const notificationRouter = createTRPCRouter({ .where( and( eq(notification.id, opts.input.id), - eq(notification.id, opts.input.id), - ), + eq(notification.id, opts.input.id) + ) ) .run(); }), @@ -114,8 +116,8 @@ export const notificationRouter = createTRPCRouter({ and( eq(notification.id, opts.input.id), eq(notification.id, opts.input.id), - eq(notification.workspaceId, opts.ctx.workspace.id), - ), + eq(notification.workspaceId, opts.ctx.workspace.id) + ) ) .get(); @@ -135,7 +137,7 @@ export const notificationRouter = createTRPCRouter({ isNotificationLimitReached: protectedProcedure.query(async (opts) => { const notificationLimit = getLimit( opts.ctx.workspace.plan, - "notification-channels", + "notification-channels" ); const notificationNumbers = ( await opts.ctx.db.query.notification.findMany({ diff --git a/packages/db/src/schema/notifications/constants.ts b/packages/db/src/schema/notifications/constants.ts index 90d0790faa..573d31a710 100644 --- a/packages/db/src/schema/notifications/constants.ts +++ b/packages/db/src/schema/notifications/constants.ts @@ -3,4 +3,5 @@ export const notificationProvider = [ "discord", "slack", "sms", + "pagerduty", ] as const; diff --git a/packages/db/src/schema/notifications/validation.ts b/packages/db/src/schema/notifications/validation.ts index c7facf39c0..0c9d679166 100644 --- a/packages/db/src/schema/notifications/validation.ts +++ b/packages/db/src/schema/notifications/validation.ts @@ -13,14 +13,14 @@ export const selectNotificationSchema = createSelectSchema(notification).extend( return String(val); }, z.string()) .default("{}"), - }, + } ); // we need to extend, otherwise data can be `null` or `undefined` - default is not export const insertNotificationSchema = createInsertSchema(notification).extend( { data: z.string().default("{}"), - }, + } ); export type InsertNotification = z.infer; @@ -28,7 +28,7 @@ export type Notification = z.infer; export type NotificationProvider = z.infer; const phoneRegex = new RegExp( - /^([+]?[\s0-9]+)?(\d{3}|[(]?[0-9]+[)])?([-]?[\s]?[0-9])+$/, + /^([+]?[\s0-9]+)?(\d{3}|[(]?[0-9]+[)])?([-]?[\s]?[0-9])+$/ ); export const phoneSchema = z.string().regex(phoneRegex, "Invalid Number!"); @@ -40,4 +40,7 @@ export const NotificationDataSchema = z.union([ z.object({ email: emailSchema }), z.object({ slack: urlSchema }), z.object({ discord: urlSchema }), + z.object({ + pagerduty: z.string(), + }), ]); diff --git a/packages/notifications/discord/src/index.ts b/packages/notifications/discord/src/index.ts index d5e5a49605..b4184ce638 100644 --- a/packages/notifications/discord/src/index.ts +++ b/packages/notifications/discord/src/index.ts @@ -20,11 +20,13 @@ export const sendAlert = async ({ notification, statusCode, message, + incidentId, }: { monitor: Monitor; notification: Notification; statusCode?: number; message?: string; + incidentId?: string; }) => { const notificationData = JSON.parse(notification.data); const { discord: webhookUrl } = notificationData; // webhook url @@ -37,7 +39,7 @@ export const sendAlert = async ({ Your monitor with url ${monitor.url} is down with ${ statusCode ? `status code ${statusCode}` : `error message ${message}` }.`, - webhookUrl, + webhookUrl ); } catch (err) { console.error(err); @@ -52,11 +54,14 @@ export const sendRecovery = async ({ statusCode, // biome-ignore lint/correctness/noUnusedVariables: message, + // biome-ignore lint/correctness/noUnusedVariables: + incidentId, }: { monitor: Monitor; notification: Notification; statusCode?: number; message?: string; + incidentId?: string; }) => { const notificationData = JSON.parse(notification.data); const { discord: webhookUrl } = notificationData; // webhook url @@ -65,7 +70,7 @@ export const sendRecovery = async ({ try { await postToWebhook( `Your monitor ${name}|${monitor.url} is up again 🎉`, - webhookUrl, + webhookUrl ); } catch (err) { console.error(err); @@ -80,7 +85,7 @@ export const sendTestDiscordMessage = async (webhookUrl: string) => { try { await postToWebhook( "This is a test notification from OpenStatus. \nIf you see this, it means that your webhook is working! 🎉", - webhookUrl, + webhookUrl ); return true; } catch (_err) { diff --git a/packages/notifications/email/src/index.ts b/packages/notifications/email/src/index.ts index b3ffd603d2..82f9b98ebe 100644 --- a/packages/notifications/email/src/index.ts +++ b/packages/notifications/email/src/index.ts @@ -8,11 +8,14 @@ export const sendAlert = async ({ notification, statusCode, message, + // biome-ignore lint/correctness/noUnusedVariables: + incidentId, }: { monitor: Monitor; notification: Notification; statusCode?: number; message?: string; + incidentId?: string; }) => { // FIXME: const config = EmailConfigurationSchema.parse(JSON.parse(notification.data)); @@ -56,11 +59,14 @@ export const sendRecovery = async ({ statusCode, // biome-ignore lint/correctness/noUnusedVariables: message, + // biome-ignore lint/correctness/noUnusedVariables: + incidentId, }: { monitor: Monitor; notification: Notification; statusCode?: number; message?: string; + incidentId?: string; }) => { // FIXME: const config = EmailConfigurationSchema.parse(JSON.parse(notification.data)); diff --git a/packages/notifications/pagerduty/.env.example b/packages/notifications/pagerduty/.env.example new file mode 100644 index 0000000000..cb102e1ef7 --- /dev/null +++ b/packages/notifications/pagerduty/.env.example @@ -0,0 +1 @@ +PAGERDUTY_APP_ID=your_auth_token diff --git a/packages/notifications/pagerduty/package.json b/packages/notifications/pagerduty/package.json new file mode 100644 index 0000000000..0ac5aafd62 --- /dev/null +++ b/packages/notifications/pagerduty/package.json @@ -0,0 +1,19 @@ +{ + "name": "@openstatus/notification-pagerduty", + "version": "0.0.0", + "main": "src/index.ts", + "dependencies": { + "@openstatus/db": "workspace:*", + "@t3-oss/env-core": "0.7.1", + "@types/validator": "13.11.6", + "validator": "13.11.0", + "zod": "3.22.4" + }, + "devDependencies": { + "@openstatus/tsconfig": "workspace:*", + "@types/node": "20.8.0", + "@types/react": "18.2.64", + "@types/react-dom": "18.2.21", + "typescript": "5.4.5" + } +} diff --git a/packages/notifications/pagerduty/src/env.ts b/packages/notifications/pagerduty/src/env.ts new file mode 100644 index 0000000000..a778704b80 --- /dev/null +++ b/packages/notifications/pagerduty/src/env.ts @@ -0,0 +1,29 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + PAGERDUTY_APP_ID: z.string(), + }, + + /** + * What object holds the environment variables at runtime. This is usually + * `process.env` or `import.meta.env`. + */ + runtimeEnv: process.env, + + /** + * By default, this library will feed the environment variables directly to + * the Zod validator. + * + * This means that if you have an empty string for a value that is supposed + * to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag + * it as a type mismatch violation. Additionally, if you have an empty string + * for a value that is supposed to be a string with a default value (e.g. + * `DOMAIN=` in an ".env" file), the default value will never be applied. + * + * In order to solve these issues, we recommend that all new projects + * explicitly specify this option as true. + */ + skipValidation: true, +}); diff --git a/packages/notifications/pagerduty/src/index.ts b/packages/notifications/pagerduty/src/index.ts new file mode 100644 index 0000000000..f47c6bd183 --- /dev/null +++ b/packages/notifications/pagerduty/src/index.ts @@ -0,0 +1,92 @@ +import type { Monitor, Notification } from "@openstatus/db/src/schema"; + +import { + PagerDutySchema, + resolveEventPayloadSchema, + triggerEventPayloadSchema, +} from "./schema/config"; + +export const sendAlert = async ({ + monitor, + notification, + statusCode, + message, + incidentId, +}: { + monitor: Monitor; + notification: Notification; + statusCode?: number; + message?: string; + incidentId?: string; +}) => { + const notificationData = PagerDutySchema.parse(JSON.parse(notification.data)); + const { name } = monitor; + + const event = triggerEventPayloadSchema.parse({ + rounting_key: notificationData.integration_keys[0].integration_key, + dedup_key: `${monitor.id}}-${incidentId}`, + event_action: "trigger", + payload: { + summary: `${name} is down`, + source: "Open Status", + severity: "error", + timestamp: new Date().toISOString(), + custom_details: { + statusCode, + message, + }, + }, + }); + + try { + for await (const integrationKey of notificationData.integration_keys) { + // biome-ignore lint/correctness/noUnusedVariables: + const { integration_key, type } = integrationKey; + + await fetch("https://events.pagerduty.com/v2/enqueue", { + method: "POST", + body: JSON.stringify(event), + }); + } + } catch (err) { + console.log(err); + // Do something + } +}; + +export const sendRecovery = async ({ + monitor, + notification, + // biome-ignore lint/correctness/noUnusedVariables: + statusCode, + // biome-ignore lint/correctness/noUnusedVariables: + message, + incidentId, +}: { + monitor: Monitor; + notification: Notification; + statusCode?: number; + message?: string; + incidentId?: string; +}) => { + const notificationData = PagerDutySchema.parse(JSON.parse(notification.data)); + + try { + for await (const integrationKey of notificationData.integration_keys) { + const event = resolveEventPayloadSchema.parse({ + rounting_key: integrationKey.integration_key, + dedup_key: `${monitor.id}}-${incidentId}`, + event_action: "resolve", + }); + await fetch("https://events.pagerduty.com/v2/enqueue", { + method: "POST", + body: JSON.stringify(event), + }); + } + } catch (err) { + console.log(err); + // Do something + } +}; + +export { PagerDutySchema }; diff --git a/packages/notifications/pagerduty/src/schema/config.ts b/packages/notifications/pagerduty/src/schema/config.ts new file mode 100644 index 0000000000..9ece859d6b --- /dev/null +++ b/packages/notifications/pagerduty/src/schema/config.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; + +export const PagerDutySchema = z.object({ + integration_keys: z.array( + z.object({ + integration_key: z.string(), + name: z.string(), + id: z.string(), + type: z.string(), + }) + ), + account: z.object({ subdomain: z.string(), name: z.string() }), +}); + +export const actionSchema = z.union([ + z.literal("trigger"), + z.literal("acknowledge"), + z.literal("resolve"), +]); + +export const severitySchema = z.union([ + z.literal("critical"), + z.literal("error"), + z.literal("warning"), + z.literal("info"), +]); + +export const imageSchema = z.object({ + src: z.string(), + href: z.string().optional(), + alt: z.string().optional(), +}); + +export const linkSchema = z.object({ + href: z.string(), + text: z.string().optional(), +}); + +const baseEventPayloadSchema = z.object({ + rounting_key: z.string(), + dedup_key: z.string(), +}); + +export const triggerEventPayloadSchema = baseEventPayloadSchema.merge( + z.object({ + event_action: z.literal("trigger"), + payload: z.object({ + summary: z.string(), + source: z.string(), + severity: severitySchema, + timestamp: z.string().optional(), + component: z.string().optional(), + group: z.string().optional(), + class: z.string().optional(), + custom_details: z.any().optional(), + }), + images: z.array(imageSchema).optional(), + links: z.array(linkSchema).optional(), + }) +); + +export const acknowledgeEventPayloadSchema = baseEventPayloadSchema.merge( + z.object({ + event_action: z.literal("acknowledge"), + }) +); + +export const resolveEventPayloadSchema = baseEventPayloadSchema.merge( + z.object({ + event_action: z.literal("resolve"), + }) +); + +export const eventPayloadV2Schema = z.discriminatedUnion("event_action", [ + triggerEventPayloadSchema, + acknowledgeEventPayloadSchema, + resolveEventPayloadSchema, +]); diff --git a/packages/notifications/pagerduty/tsconfig.json b/packages/notifications/pagerduty/tsconfig.json new file mode 100644 index 0000000000..a8a8c93096 --- /dev/null +++ b/packages/notifications/pagerduty/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@openstatus/tsconfig/nextjs.json", + "include": ["src", "*.ts"] +} diff --git a/packages/notifications/slack/src/index.ts b/packages/notifications/slack/src/index.ts index 4909388493..bd2781a4a9 100644 --- a/packages/notifications/slack/src/index.ts +++ b/packages/notifications/slack/src/index.ts @@ -18,11 +18,14 @@ export const sendAlert = async ({ notification, statusCode, message, + // biome-ignore lint/correctness/noUnusedVariables: + incidentId, }: { monitor: Monitor; notification: Notification; statusCode?: number; message?: string; + incidentId?: string; }) => { const notificationData = JSON.parse(notification.data); const { slack: webhookUrl } = notificationData; // webhook url @@ -55,7 +58,7 @@ export const sendAlert = async ({ }, ], }, - webhookUrl, + webhookUrl ); } catch (err) { console.log(err); @@ -70,11 +73,14 @@ export const sendRecovery = async ({ statusCode, // biome-ignore lint/correctness/noUnusedVariables: message, + // biome-ignore lint/correctness/noUnusedVariables: + incidentId, }: { monitor: Monitor; notification: Notification; statusCode?: number; message?: string; + incidentId?: string; }) => { const notificationData = JSON.parse(notification.data); const { slack: webhookUrl } = notificationData; // webhook url @@ -104,7 +110,7 @@ export const sendRecovery = async ({ }, ], }, - webhookUrl, + webhookUrl ); } catch (err) { console.log(err); @@ -126,7 +132,7 @@ export const sendTestSlackMessage = async (webhookUrl: string) => { }, ], }, - webhookUrl, + webhookUrl ); return true; } catch (_err) { diff --git a/packages/notifications/twillio-sms/src/index.ts b/packages/notifications/twillio-sms/src/index.ts index a6cfc53601..a9cb22aad7 100644 --- a/packages/notifications/twillio-sms/src/index.ts +++ b/packages/notifications/twillio-sms/src/index.ts @@ -8,14 +8,17 @@ export const sendAlert = async ({ notification, statusCode, message, + // biome-ignore lint/correctness/noUnusedVariables: + incidentId, }: { monitor: Monitor; notification: Notification; statusCode?: number; message?: string; + incidentId?: string; }) => { const notificationData = SmsConfigurationSchema.parse( - JSON.parse(notification.data), + JSON.parse(notification.data) ); const { name } = monitor; @@ -26,7 +29,7 @@ export const sendAlert = async ({ "Body", `Your monitor ${name} / ${monitor.url} is down with ${ statusCode ? `status code ${statusCode}` : `error: ${message}` - }`, + }` ); try { @@ -37,10 +40,10 @@ export const sendAlert = async ({ body, headers: { Authorization: `Basic ${btoa( - `${env.TWILLIO_ACCOUNT_ID}:${env.TWILLIO_AUTH_TOKEN}`, + `${env.TWILLIO_ACCOUNT_ID}:${env.TWILLIO_AUTH_TOKEN}` )}`, }, - }, + } ); } catch (err) { console.log(err); @@ -55,14 +58,17 @@ export const sendRecovery = async ({ statusCode, // biome-ignore lint/correctness/noUnusedVariables: message, + // biome-ignore lint/correctness/noUnusedVariables: + incidentId, }: { monitor: Monitor; notification: Notification; statusCode?: number; message?: string; + incidentId?: string; }) => { const notificationData = SmsConfigurationSchema.parse( - JSON.parse(notification.data), + JSON.parse(notification.data) ); const { name } = monitor; @@ -79,10 +85,10 @@ export const sendRecovery = async ({ body, headers: { Authorization: `Basic ${btoa( - `${env.TWILLIO_ACCOUNT_ID}:${env.TWILLIO_AUTH_TOKEN}`, + `${env.TWILLIO_ACCOUNT_ID}:${env.TWILLIO_AUTH_TOKEN}` )}`, }, - }, + } ); } catch (err) { console.log(err); diff --git a/packages/plans/src/config.ts b/packages/plans/src/config.ts index 9ed23591ce..decdebdba6 100644 --- a/packages/plans/src/config.ts +++ b/packages/plans/src/config.ts @@ -30,6 +30,7 @@ export const allPlans: Record< "white-label": false, notifications: true, sms: false, + pagerduty: false, "notification-channels": 1, members: 1, "audit-log": false, @@ -53,6 +54,7 @@ export const allPlans: Record< "password-protection": true, "white-label": false, notifications: true, + pagerduty: true, sms: true, "notification-channels": 10, members: "Unlimited", @@ -114,6 +116,7 @@ export const allPlans: Record< "white-label": false, notifications: true, sms: true, + pagerduty: true, "notification-channels": 20, members: "Unlimited", "audit-log": true, @@ -174,6 +177,7 @@ export const allPlans: Record< "white-label": true, notifications: true, sms: true, + pagerduty: true, "notification-channels": 50, members: "Unlimited", "audit-log": true, diff --git a/packages/plans/src/types.ts b/packages/plans/src/types.ts index 3bf835aa43..846e78969b 100644 --- a/packages/plans/src/types.ts +++ b/packages/plans/src/types.ts @@ -19,6 +19,7 @@ export type Limits = { "white-label": boolean; // alerts notifications: boolean; + pagerduty: boolean; sms: boolean; "notification-channels": number; // collaboration diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c262363c6a..8a888813fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,9 @@ importers: '@openstatus/notification-emails': specifier: workspace:* version: link:../../packages/notifications/email + '@openstatus/notification-pagerduty': + specifier: workspace:* + version: link:../../packages/notifications/pagerduty '@openstatus/notification-slack': specifier: workspace:* version: link:../../packages/notifications/slack @@ -278,6 +281,9 @@ importers: '@openstatus/notification-emails': specifier: workspace:* version: link:../../packages/notifications/email + '@openstatus/notification-pagerduty': + specifier: workspace:* + version: link:../../packages/notifications/pagerduty '@openstatus/notification-slack': specifier: workspace:* version: link:../../packages/notifications/slack @@ -806,6 +812,40 @@ importers: specifier: 5.5.2 version: 5.5.2 + packages/notifications/pagerduty: + dependencies: + '@openstatus/db': + specifier: workspace:* + version: link:../../db + '@t3-oss/env-core': + specifier: 0.7.1 + version: 0.7.1(typescript@5.4.5)(zod@3.22.4) + '@types/validator': + specifier: 13.11.6 + version: 13.11.6 + validator: + specifier: 13.11.0 + version: 13.11.0 + zod: + specifier: 3.22.4 + version: 3.22.4 + devDependencies: + '@openstatus/tsconfig': + specifier: workspace:* + version: link:../../tsconfig + '@types/node': + specifier: 20.8.0 + version: 20.8.0 + '@types/react': + specifier: 18.2.64 + version: 18.2.64 + '@types/react-dom': + specifier: 18.2.21 + version: 18.2.21 + typescript: + specifier: 5.4.5 + version: 5.4.5 + packages/notifications/slack: dependencies: '@openstatus/db': @@ -4105,9 +4145,15 @@ packages: '@types/prop-types@15.7.12': resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} + '@types/react-dom@18.2.21': + resolution: {integrity: sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw==} + '@types/react-dom@18.3.0': resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} + '@types/react@18.2.64': + resolution: {integrity: sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg==} + '@types/react@18.3.3': resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==} @@ -4120,6 +4166,9 @@ packages: '@types/rss@0.0.32': resolution: {integrity: sha512-2oKNqKyUY4RSdvl5eZR1n2Q9yvw3XTe3mQHsFPn9alaNBxfPnbXBtGP8R0SV8pK1PrVnLul0zx7izbm5/gF5Qw==} + '@types/scheduler@0.23.0': + resolution: {integrity: sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==} + '@types/through@0.0.32': resolution: {integrity: sha512-7XsfXIsjdfJM2wFDRAtEWp3zb2aVPk5QeyZxGlVK57q4u26DczMHhJmlhr0Jqv0THwxam/L8REXkj8M2I/lcvw==} @@ -7806,6 +7855,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.5.2: resolution: {integrity: sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==} engines: {node: '>=14.17'} @@ -11371,6 +11425,12 @@ snapshots: optionalDependencies: typescript: 5.4.4 + '@t3-oss/env-core@0.7.1(typescript@5.4.5)(zod@3.22.4)': + dependencies: + zod: 3.22.4 + optionalDependencies: + typescript: 5.4.5 + '@t3-oss/env-core@0.7.1(typescript@5.5.2)(zod@3.23.8)': dependencies: zod: 3.23.8 @@ -11613,10 +11673,20 @@ snapshots: '@types/prop-types@15.7.12': {} + '@types/react-dom@18.2.21': + dependencies: + '@types/react': 18.3.3 + '@types/react-dom@18.3.0': dependencies: '@types/react': 18.3.3 + '@types/react@18.2.64': + dependencies: + '@types/prop-types': 15.7.12 + '@types/scheduler': 0.23.0 + csstype: 3.1.3 + '@types/react@18.3.3': dependencies: '@types/prop-types': 15.7.12 @@ -11633,6 +11703,8 @@ snapshots: '@types/rss@0.0.32': {} + '@types/scheduler@0.23.0': {} + '@types/through@0.0.32': dependencies: '@types/node': 20.14.8 @@ -16023,6 +16095,8 @@ snapshots: typescript@5.4.4: {} + typescript@5.4.5: {} + typescript@5.5.2: {} uglify-js@3.17.4: