Openstatus www.openstatus.dev

refactor: notification messages (#1774)

* feat: add @openstatus/notification-base package

Create shared types and utilities for notification providers:
- NotificationContext, FormattedMessageData types
- formatDuration() for human-readable duration formatting
- formatTimestamp() using date-fns for consistent time display
- getIncidentDuration() for calculating resolved incident durations
- buildCommonMessageData() for centralized message formatting
- formatStatusCode() with HTTP status descriptions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: update alerting to fetch and pass incident data to notification providers

- Add logic to fetch incident by ID for recovery/degraded notifications in alerting.ts
- Update SendNotification type signature to use incident?: Incident instead of incidentId?: string
- Update all 11 notification providers to accept incident?: Incident parameter
- Providers using incidentId for dedup keys (opsgenie, pagerduty) now use incident?.id
- Incident is only fetched when notifType is recovery/degraded and incidentId is present
- Add error handling for failed incident fetches (logs warning, doesn't block notification)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add Slack block builder utilities for enhanced notifications

Create block builder functions for Slack notifications with rich formatting:
- escapeSlackText() for safe mrkdwn escaping
- buildAlertBlocks() with header, monitor link, 4-field grid, error code block, dashboard button
- buildRecoveryBlocks() with optional downtime duration display
- buildDegradedBlocks() with optional previous incident duration

Add @openstatus/notification-base dependency to Slack package.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: update Slack provider to use enhanced blocks and incident data

- Import buildCommonMessageData from @openstatus/notification-base
- Import block builders (buildAlertBlocks, buildRecoveryBlocks, buildDegradedBlocks)
- Update sendAlert to use buildCommonMessageData and buildAlertBlocks
- Update sendRecovery to pass incident for duration calculation
- Update sendDegraded to pass incident for duration calculation
- Rich Slack notifications now include formatted fields, error blocks, and dashboard buttons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add Discord embed builder utilities for enhanced notifications

Add Discord embed builders that create rich embed messages with proper
formatting including colored embeds, inline fields, markdown links, and
incident duration tracking.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: update Discord provider to use enhanced embeds and incident data

- Import buildCommonMessageData from @openstatus/notification-base
- Import embed builders from ./embeds
- Update postToWebhook to accept embeds array instead of plain content
- Update sendAlert to build context, call buildCommonMessageData and buildAlertEmbed
- Update sendRecovery to pass incident for duration calculation
- Update sendDegraded to pass incident for duration calculation
- Update sendTestDiscordMessage to use embed format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: mark duration formatting tests task as complete

The unit tests for duration formatting already exist in
packages/notification-base/src/utils/duration.test.ts with
complete coverage of all required test cases.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add unit tests for incident duration calculation

Add comprehensive unit tests for getIncidentDuration() utility function,
covering null cases, resolved incidents, different time formats, and
edge cases.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: verify package dependencies and workspace configuration

Verified that the @openstatus/notification-base package is properly
integrated into the monorepo workspace. All workspace dependencies
are correctly linked and imports work as expected.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: verify backward compatibility for all notification providers

- Applied biome formatting fixes to all 11 notification provider files
- Applied biome formatting fixes to notification-base utility files
- Verified all providers have incident?: Incident optional parameter
- Confirmed all providers compile and lint successfully
- Updated PRD to mark backward compatibility task as complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: verify all notification providers have the newly created utils/types

- Verified Slack and Discord providers fully integrate with @openstatus/notification-base
- Confirmed all 11 providers have consistent function signatures with incident?: Incident
- Documented which providers use notification-base and rationale for those that don't
- Fixed biome formatting issue in types.ts (import sorting)
- Updated progress.txt with comprehensive verification summary

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: verify implementation follows official Slack and Discord documentation

- Verified Slack Block Kit best practices (mrkdwn/plain_text types, emoji, escaping)
- Verified Discord embed guidelines (ISO 8601 timestamps, decimal colors, inline fields)
- Fixed trailing comma formatting issues in blocks.ts and embeds.ts (biome)
- Updated PRD task to passes: true

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* perf: verify database query performance for incident fetching

Verified that the incident fetching in alerting.ts:
- Uses primary key lookup on incidentTable.id (O(log n), <1ms)
- Only runs for recovery/degraded notifications, not alerts
- Gracefully handles missing incidents (continues with undefined)
- Catches and logs errors without blocking notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: format and pnpm lock

* chore: examples

* wip:

* refactor: package location

* fix: incident id

* fix: dockerfile

* fix: remove ralph files

* fix: packages

* fix: opsgenie test

* fix: dofigen

* fix: test alerts

* fix: test

* fix: review

* fix: remove action

* fix: send dashboard test

* fix: review

* chore: dedupe key

* fix: color grading

* fix: review

* chore: docs logo asset

* fix: color test

* fix: degraded

* chore: minor stuff

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by

Maximilian Kaske
Claude Opus 4.5
and committed by
GitHub
bdba117e 9dccd46e

+1811 -804
+5
apps/docs/src/content/docs/reference/notification.mdx
··· 17 18 **Configuration:** 19 - **Incoming Webhook URL:** (Required) A [Slack incoming webhook URL](https://api.slack.com/incoming-webhooks) where notifications will be posted. 20 21 ### Email 22 ··· 32 **Configuration:** 33 - **Webhook URL:** (Required) A [Discord webhook URL](https://support.discord.com/hc/en-us/articles/228383668) for the target channel. 34 **Example:** `https://discordapp.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz1234567890` 35 36 ### Google Chat 37
··· 17 18 **Configuration:** 19 - **Incoming Webhook URL:** (Required) A [Slack incoming webhook URL](https://api.slack.com/incoming-webhooks) where notifications will be posted. 20 + **Example**: `https://hooks.slack.com/services/XXX/YYY/ZZZ` 21 + 22 + You can [download the openstatus logo](https://www.openstatus.dev/assets/logos/openstatus.jpeg) to add a custom logo. 23 24 ### Email 25 ··· 35 **Configuration:** 36 - **Webhook URL:** (Required) A [Discord webhook URL](https://support.discord.com/hc/en-us/articles/228383668) for the target channel. 37 **Example:** `https://discordapp.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz1234567890` 38 + 39 + You can [download the openstatus logo](https://www.openstatus.dev/assets/logos/openstatus.jpeg) to add a custom logo. 40 41 ### Google Chat 42
apps/web/public/assets/logos/openstatus.jpeg

This is a binary file and will not be displayed.

+1 -1
apps/workflows/.dockerignore
··· 1 - # This file is generated by Dofigen v2.6.0 2 # See https://github.com/lenra-io/dofigen 3 4 node_modules
··· 1 + # This file is generated by Dofigen v2.5.0 2 # See https://github.com/lenra-io/dofigen 3 4 node_modules
+11 -10
apps/workflows/Dockerfile
··· 1 - # syntax=docker/dockerfile:1.19.0 2 - # This file is generated by Dofigen v2.6.0 3 # See https://github.com/lenra-io/dofigen 4 - 5 - # ca-certs 6 - FROM debian@sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 AS ca-certs 7 - LABEL \ 8 - org.opencontainers.image.base.digest="sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734" \ 9 - org.opencontainers.image.base.name="docker.io/debian:bullseye-slim" 10 - RUN apt update && apt install -y ca-certificates curl && update-ca-certificates 11 12 # docker 13 FROM oven/bun@sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a AS docker ··· 34 --mount=type=bind,target=packages/assertions/package.json,source=packages/assertions/package.json \ 35 --mount=type=bind,target=packages/db/package.json,source=packages/db/package.json \ 36 --mount=type=bind,target=packages/emails/package.json,source=packages/emails/package.json \ 37 --mount=type=bind,target=packages/notifications/discord/package.json,source=packages/notifications/discord/package.json \ 38 --mount=type=bind,target=packages/notifications/email/package.json,source=packages/notifications/email/package.json \ 39 --mount=type=bind,target=packages/notifications/google-chat/package.json,source=packages/notifications/google-chat/package.json \ ··· 71 "/app/node_modules" "/app/node_modules" 72 RUN bun build --compile --target bun --sourcemap src/index.ts --outfile=app 73 74 # libsql 75 FROM oven/bun@sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a AS libsql 76 LABEL \ ··· 86 # runtime 87 FROM debian@sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 AS runtime 88 LABEL \ 89 - io.dofigen.version="2.6.0" \ 90 org.opencontainers.image.authors="OpenStatus Team" \ 91 org.opencontainers.image.base.digest="sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734" \ 92 org.opencontainers.image.base.name="docker.io/debian:bullseye-slim" \
··· 1 + # syntax=docker/dockerfile:1.11 2 + # This file is generated by Dofigen v2.5.0 3 # See https://github.com/lenra-io/dofigen 4 5 # docker 6 FROM oven/bun@sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a AS docker ··· 27 --mount=type=bind,target=packages/assertions/package.json,source=packages/assertions/package.json \ 28 --mount=type=bind,target=packages/db/package.json,source=packages/db/package.json \ 29 --mount=type=bind,target=packages/emails/package.json,source=packages/emails/package.json \ 30 + --mount=type=bind,target=packages/notifications/base/package.json,source=packages/notifications/base/package.json \ 31 --mount=type=bind,target=packages/notifications/discord/package.json,source=packages/notifications/discord/package.json \ 32 --mount=type=bind,target=packages/notifications/email/package.json,source=packages/notifications/email/package.json \ 33 --mount=type=bind,target=packages/notifications/google-chat/package.json,source=packages/notifications/google-chat/package.json \ ··· 65 "/app/node_modules" "/app/node_modules" 66 RUN bun build --compile --target bun --sourcemap src/index.ts --outfile=app 67 68 + # ca-certs 69 + FROM debian@sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 AS ca-certs 70 + LABEL \ 71 + org.opencontainers.image.base.digest="sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734" \ 72 + org.opencontainers.image.base.name="docker.io/debian:bullseye-slim" 73 + RUN apt update && apt install -y ca-certificates curl && update-ca-certificates 74 + 75 # libsql 76 FROM oven/bun@sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a AS libsql 77 LABEL \ ··· 87 # runtime 88 FROM debian@sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 AS runtime 89 LABEL \ 90 + io.dofigen.version="2.5.0" \ 91 org.opencontainers.image.authors="OpenStatus Team" \ 92 org.opencontainers.image.base.digest="sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734" \ 93 org.opencontainers.image.base.name="docker.io/debian:bullseye-slim" \
+3 -1
apps/workflows/README.md
··· 30 To generate the Dockerfile, run the following command from the `apps/workflows` directory: 31 32 ```bash 33 # Update the dependent image versions 34 dofigen update 35 # Generate the Dockerfile 36 dofigen gen 37 - ```
··· 30 To generate the Dockerfile, run the following command from the `apps/workflows` directory: 31 32 ```bash 33 + # Install Dofigen 34 + cargo install dofigen 35 # Update the dependent image versions 36 dofigen update 37 # Generate the Dockerfile 38 dofigen gen 39 + ```
+38 -35
apps/workflows/dofigen.lock
··· 12 - /packages/error 13 - /packages/tracker 14 builders: 15 install: 16 fromImage: 17 path: oven/bun 18 digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 19 label: 20 org.opencontainers.image.stage: install 21 - org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 22 org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 23 workdir: /app/ 24 run: 25 - bun install --production --frozen-lockfile --verbose ··· 38 source: packages/db/package.json 39 - target: packages/emails/package.json 40 source: packages/emails/package.json 41 - target: packages/notifications/discord/package.json 42 source: packages/notifications/discord/package.json 43 - target: packages/notifications/email/package.json ··· 72 source: packages/upstash/package.json 73 - target: packages/theme-store/package.json 74 source: packages/theme-store/package.json 75 - ca-certs: 76 - fromImage: 77 - path: debian 78 - digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 79 - label: 80 - org.opencontainers.image.base.digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 81 - org.opencontainers.image.base.name: docker.io/debian:bullseye-slim 82 - run: 83 - - apt update && apt install -y ca-certificates curl && update-ca-certificates 84 libsql: 85 fromImage: 86 path: oven/bun ··· 96 target: /app/package.json 97 run: 98 - bun install 99 - docker: 100 fromImage: 101 - path: oven/bun 102 - digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 103 label: 104 - org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 105 - org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 106 - workdir: /app/apps/workflows 107 - copy: 108 - - paths: 109 - - . 110 - target: /app/ 111 run: 112 - - bun run src/build-docker.ts 113 - build: 114 fromImage: 115 path: oven/bun 116 digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 117 label: 118 - org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 119 - org.opencontainers.image.stage: build 120 org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 121 workdir: /app/apps/workflows 122 - env: 123 - NODE_ENV: production 124 copy: 125 - paths: 126 - . 127 target: /app/ 128 - - fromBuilder: install 129 - paths: 130 - - /app/node_modules 131 - target: /app/node_modules 132 run: 133 - - bun build --compile --target bun --sourcemap src/index.ts --outfile=app 134 fromImage: 135 path: debian 136 digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 ··· 138 org.opencontainers.image.authors: OpenStatus Team 139 org.opencontainers.image.source: https://github.com/openstatusHQ/openstatus 140 org.opencontainers.image.title: OpenStatus Workflows 141 org.opencontainers.image.base.digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 142 - org.opencontainers.image.description: Background job processing and probe scheduling for OpenStatus 143 - io.dofigen.version: 2.6.0 144 org.opencontainers.image.vendor: OpenStatus 145 - org.opencontainers.image.base.name: docker.io/debian:bullseye-slim 146 workdir: /app/ 147 copy: 148 - fromBuilder: build ··· 182 digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 183 resources: 184 dofigen.yml: 185 - hash: 070adced2c20f3f63d9a803b4494401f9df769d071fca19c29755f75a0127a06 186 content: | 187 ignore: 188 - node_modules ··· 209 - packages/assertions/package.json 210 - packages/db/package.json 211 - packages/emails/package.json 212 - packages/notifications/discord/package.json 213 - packages/notifications/email/package.json 214 - packages/notifications/google-chat/package.json
··· 12 - /packages/error 13 - /packages/tracker 14 builders: 15 + build: 16 + fromImage: 17 + path: oven/bun 18 + digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 19 + label: 20 + org.opencontainers.image.stage: build 21 + org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 22 + org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 23 + workdir: /app/apps/workflows 24 + env: 25 + NODE_ENV: production 26 + copy: 27 + - paths: 28 + - . 29 + target: /app/ 30 + - fromBuilder: install 31 + paths: 32 + - /app/node_modules 33 + target: /app/node_modules 34 + run: 35 + - bun build --compile --target bun --sourcemap src/index.ts --outfile=app 36 install: 37 fromImage: 38 path: oven/bun 39 digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 40 label: 41 org.opencontainers.image.stage: install 42 org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 43 + org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 44 workdir: /app/ 45 run: 46 - bun install --production --frozen-lockfile --verbose ··· 59 source: packages/db/package.json 60 - target: packages/emails/package.json 61 source: packages/emails/package.json 62 + - target: packages/notifications/base/package.json 63 + source: packages/notifications/base/package.json 64 - target: packages/notifications/discord/package.json 65 source: packages/notifications/discord/package.json 66 - target: packages/notifications/email/package.json ··· 95 source: packages/upstash/package.json 96 - target: packages/theme-store/package.json 97 source: packages/theme-store/package.json 98 libsql: 99 fromImage: 100 path: oven/bun ··· 110 target: /app/package.json 111 run: 112 - bun install 113 + ca-certs: 114 fromImage: 115 + path: debian 116 + digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 117 label: 118 + org.opencontainers.image.base.name: docker.io/debian:bullseye-slim 119 + org.opencontainers.image.base.digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 120 run: 121 + - apt update && apt install -y ca-certificates curl && update-ca-certificates 122 + docker: 123 fromImage: 124 path: oven/bun 125 digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 126 label: 127 org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 128 + org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 129 workdir: /app/apps/workflows 130 copy: 131 - paths: 132 - . 133 target: /app/ 134 run: 135 + - bun run src/build-docker.ts 136 fromImage: 137 path: debian 138 digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 ··· 140 org.opencontainers.image.authors: OpenStatus Team 141 org.opencontainers.image.source: https://github.com/openstatusHQ/openstatus 142 org.opencontainers.image.title: OpenStatus Workflows 143 + io.dofigen.version: 2.5.0 144 org.opencontainers.image.base.digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 145 + org.opencontainers.image.base.name: docker.io/debian:bullseye-slim 146 org.opencontainers.image.vendor: OpenStatus 147 + org.opencontainers.image.description: Background job processing and probe scheduling for OpenStatus 148 workdir: /app/ 149 copy: 150 - fromBuilder: build ··· 184 digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 185 resources: 186 dofigen.yml: 187 + hash: 6a13d3715d011de108b8e7710153a84e1c2a05a1aaf9f89daf6ae6a7315490f8 188 content: | 189 ignore: 190 - node_modules ··· 211 - packages/assertions/package.json 212 - packages/db/package.json 213 - packages/emails/package.json 214 + - packages/notifications/base/package.json 215 - packages/notifications/discord/package.json 216 - packages/notifications/email/package.json 217 - packages/notifications/google-chat/package.json
+1
apps/workflows/dofigen.yml
··· 23 - packages/assertions/package.json 24 - packages/db/package.json 25 - packages/emails/package.json 26 - packages/notifications/discord/package.json 27 - packages/notifications/email/package.json 28 - packages/notifications/google-chat/package.json
··· 23 - packages/assertions/package.json 24 - packages/db/package.json 25 - packages/emails/package.json 26 + - packages/notifications/base/package.json 27 - packages/notifications/discord/package.json 28 - packages/notifications/email/package.json 29 - packages/notifications/google-chat/package.json
+1
apps/workflows/package.json
··· 14 "@logtape/sentry": "2.0.1", 15 "@openstatus/db": "workspace:*", 16 "@openstatus/emails": "workspace:*", 17 "@openstatus/notification-discord": "workspace:*", 18 "@openstatus/notification-emails": "workspace:*", 19 "@openstatus/notification-google-chat": "workspace:*",
··· 14 "@logtape/sentry": "2.0.1", 15 "@openstatus/db": "workspace:*", 16 "@openstatus/emails": "workspace:*", 17 + "@openstatus/notification-base": "workspace:*", 18 "@openstatus/notification-discord": "workspace:*", 19 "@openstatus/notification-emails": "workspace:*", 20 "@openstatus/notification-google-chat": "workspace:*",
+1 -1
apps/workflows/src/checker/alerting.test.ts
··· 16 statusCode: 400, 17 notifType: "alert", 18 cronTimestamp: 123456, 19 - incidentId: "1", 20 }); 21 expect(fn).toHaveBeenCalled(); 22 });
··· 16 statusCode: 400, 17 notifType: "alert", 18 cronTimestamp: 123456, 19 + incidentId: 1, 20 }); 21 expect(fn).toHaveBeenCalled(); 22 });
+25 -10
apps/workflows/src/checker/alerting.ts
··· 1 import { and, count, db, eq, gte, inArray, schema } from "@openstatus/db"; 2 - import type { MonitorStatus } from "@openstatus/db/src/schema"; 3 import { 4 selectMonitorSchema, 5 selectNotificationSchema, ··· 21 notifType, 22 cronTimestamp, 23 incidentId, 24 - region, 25 latency, 26 }: { 27 monitorId: string; ··· 29 message?: string; 30 notifType: "alert" | "recovery" | "degraded"; 31 cronTimestamp: number; 32 - incidentId: string; 33 - region?: Region; 34 latency?: number; 35 }) => { 36 logger.info("Triggering alerting", { 37 monitor_id: monitorId, 38 notification_type: notifType, 39 }); 40 const notifications = await db 41 .select() 42 .from(schema.notificationsToMonitors) ··· 129 notification: selectNotificationSchema.parse(notif.notification), 130 statusCode, 131 message, 132 - incidentId, 133 cronTimestamp, 134 - region, 135 latency, 136 }), 137 ··· 161 notification: selectNotificationSchema.parse(notif.notification), 162 statusCode, 163 message, 164 - incidentId, 165 cronTimestamp, 166 - region, 167 latency, 168 }), 169 catch: (_unknown) => ··· 192 notification: selectNotificationSchema.parse(notif.notification), 193 statusCode, 194 message, 195 - incidentId, 196 cronTimestamp, 197 - region, 198 latency, 199 }), 200 catch: (_unknown) =>
··· 1 import { and, count, db, eq, gte, inArray, schema } from "@openstatus/db"; 2 + import type { Incident, MonitorStatus } from "@openstatus/db/src/schema"; 3 import { 4 selectMonitorSchema, 5 selectNotificationSchema, ··· 21 notifType, 22 cronTimestamp, 23 incidentId, 24 + regions, 25 latency, 26 }: { 27 monitorId: string; ··· 29 message?: string; 30 notifType: "alert" | "recovery" | "degraded"; 31 cronTimestamp: number; 32 + incidentId?: number; 33 + regions?: string[]; 34 latency?: number; 35 }) => { 36 logger.info("Triggering alerting", { 37 monitor_id: monitorId, 38 notification_type: notifType, 39 }); 40 + 41 + let incident: Incident | undefined; 42 + if (incidentId) { 43 + try { 44 + incident = await db.query.incidentTable.findFirst({ 45 + where: eq(schema.incidentTable.id, incidentId), 46 + }); 47 + } catch (err) { 48 + logger.warn("Failed to fetch incident data", { 49 + incident_id: incidentId, 50 + error_message: err instanceof Error ? err.message : String(err), 51 + }); 52 + } 53 + } 54 + 55 const notifications = await db 56 .select() 57 .from(schema.notificationsToMonitors) ··· 144 notification: selectNotificationSchema.parse(notif.notification), 145 statusCode, 146 message, 147 + incident, 148 cronTimestamp, 149 + regions, 150 latency, 151 }), 152 ··· 176 notification: selectNotificationSchema.parse(notif.notification), 177 statusCode, 178 message, 179 + incident, 180 cronTimestamp, 181 + regions, 182 latency, 183 }), 184 catch: (_unknown) => ··· 207 notification: selectNotificationSchema.parse(notif.notification), 208 statusCode, 209 message, 210 + incident, 211 cronTimestamp, 212 + regions, 213 latency, 214 }), 215 catch: (_unknown) =>
+26 -16
apps/workflows/src/checker/index.ts
··· 1 import { Hono } from "hono"; 2 import { z } from "zod"; 3 4 - import { and, count, db, eq, inArray, isNull, schema } from "@openstatus/db"; 5 import { incidentTable } from "@openstatus/db/src/schema"; 6 import { 7 monitorStatusSchema, ··· 142 const monitor = selectMonitorSchema.parse(currentMonitor); 143 const numberOfRegions = monitor.regions.length; 144 145 - const affectedRegion = await db 146 - .select({ count: count() }) 147 .from(schema.monitorStatusTable) 148 .where( 149 and( ··· 152 inArray(schema.monitorStatusTable.region, monitor.regions), 153 ), 154 ) 155 - .get(); 156 157 - if (!affectedRegion?.count) { 158 return c.json({ success: true }, 200); 159 } 160 ··· 203 break; 204 } 205 206 - if (affectedRegion.count >= numberOfRegions / 2 || numberOfRegions === 1) { 207 switch (status) { 208 case "active": { 209 if (monitor.status === "active") { ··· 219 .set({ status: "active" }) 220 .where(eq(schema.monitor.id, monitor.id)); 221 222 if (monitor.status === "error") { 223 - await resolveIncident({ monitorId, cronTimestamp }); 224 } 225 226 await triggerNotifications({ ··· 229 message, 230 notifType: "recovery", 231 cronTimestamp, 232 - region, 233 latency, 234 - incidentId: `${cronTimestamp}`, 235 }); 236 237 break; ··· 251 .set({ status: "degraded" }) 252 .where(eq(schema.monitor.id, monitor.id)); 253 254 await triggerNotifications({ 255 monitorId, 256 statusCode, ··· 258 notifType: "degraded", 259 cronTimestamp, 260 latency, 261 - region, 262 - incidentId: `${cronTimestamp}`, 263 }); 264 265 - if (monitor.status === "error") { 266 - await resolveIncident({ monitorId, cronTimestamp }); 267 - } 268 break; 269 case "error": 270 if (monitor.status === "error") { ··· 317 notifType: "alert", 318 cronTimestamp, 319 latency, 320 - region, 321 - incidentId: String(newIncident.id), 322 }); 323 } catch (error) { 324 logger.warning("Failed to create incident", { error });
··· 1 import { Hono } from "hono"; 2 import { z } from "zod"; 3 4 + import { and, db, eq, inArray, isNull, schema } from "@openstatus/db"; 5 import { incidentTable } from "@openstatus/db/src/schema"; 6 import { 7 monitorStatusSchema, ··· 142 const monitor = selectMonitorSchema.parse(currentMonitor); 143 const numberOfRegions = monitor.regions.length; 144 145 + // Fetch all affected regions for notifications (single query) 146 + const affectedRegions = await db 147 + .select({ region: schema.monitorStatusTable.region }) 148 .from(schema.monitorStatusTable) 149 .where( 150 and( ··· 153 inArray(schema.monitorStatusTable.region, monitor.regions), 154 ), 155 ) 156 + .all(); 157 158 + const affectedRegionsList = affectedRegions.map((r) => r.region); 159 + const affectedRegionCount = affectedRegionsList.length; 160 + 161 + if (affectedRegionCount === 0) { 162 return c.json({ success: true }, 200); 163 } 164 ··· 207 break; 208 } 209 210 + if (affectedRegionCount >= numberOfRegions / 2 || numberOfRegions === 1) { 211 switch (status) { 212 case "active": { 213 if (monitor.status === "active") { ··· 223 .set({ status: "active" }) 224 .where(eq(schema.monitor.id, monitor.id)); 225 226 + let incident = null; 227 if (monitor.status === "error") { 228 + incident = await resolveIncident({ monitorId, cronTimestamp }); 229 } 230 231 await triggerNotifications({ ··· 234 message, 235 notifType: "recovery", 236 cronTimestamp, 237 + regions: affectedRegionsList, 238 latency, 239 + incidentId: incident?.id, 240 }); 241 242 break; ··· 256 .set({ status: "degraded" }) 257 .where(eq(schema.monitor.id, monitor.id)); 258 259 + let incident = null; 260 + if (monitor.status === "error") { 261 + incident = await resolveIncident({ 262 + monitorId, 263 + cronTimestamp, 264 + }); 265 + } 266 + 267 await triggerNotifications({ 268 monitorId, 269 statusCode, ··· 271 notifType: "degraded", 272 cronTimestamp, 273 latency, 274 + regions: affectedRegionsList, 275 + incidentId: incident?.id, 276 }); 277 278 break; 279 case "error": 280 if (monitor.status === "error") { ··· 327 notifType: "alert", 328 cronTimestamp, 329 latency, 330 + regions: affectedRegionsList, 331 + incidentId: newIncident.id, 332 }); 333 } catch (error) { 334 logger.warning("Failed to create incident", { error });
+5 -27
apps/workflows/src/checker/utils.ts
··· 1 - import type { 2 - Monitor, 3 - Notification, 4 - NotificationProvider, 5 - } from "@openstatus/db/src/schema"; 6 - import type { Region } from "@openstatus/db/src/schema/constants"; 7 import { 8 sendAlert as sendDiscordAlert, 9 sendDegraded as sendDiscordDegraded, ··· 60 sendRecovery as sendWebhookRecovery, 61 } from "@openstatus/notification-webhook"; 62 63 - type SendNotification = ({ 64 - monitor, 65 - notification, 66 - statusCode, 67 - message, 68 - incidentId, 69 - cronTimestamp, 70 - latency, 71 - region, 72 - }: { 73 - monitor: Monitor; 74 - notification: Notification; 75 - statusCode?: number; 76 - message?: string; 77 - incidentId?: string; 78 - cronTimestamp: number; 79 - latency?: number; 80 - region?: Region; 81 - }) => Promise<void>; 82 83 type Notif = { 84 sendAlert: SendNotification; ··· 86 sendDegraded: SendNotification; 87 }; 88 89 - export const providerToFunction = { 90 discord: { 91 sendAlert: sendDiscordAlert, 92 sendRecovery: sendDiscordRecovery, ··· 142 sendRecovery: sendTelegramRecovery, 143 sendDegraded: sendTelegramDegraded, 144 }, 145 - } satisfies Record<NotificationProvider, Notif>;
··· 1 + import type { NotificationProvider } from "@openstatus/db/src/schema"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 3 import { 4 sendAlert as sendDiscordAlert, 5 sendDegraded as sendDiscordDegraded, ··· 56 sendRecovery as sendWebhookRecovery, 57 } from "@openstatus/notification-webhook"; 58 59 + type SendNotification = (props: NotificationContext) => Promise<void>; 60 61 type Notif = { 62 sendAlert: SendNotification; ··· 64 sendDegraded: SendNotification; 65 }; 66 67 + export const providerToFunction: Record<NotificationProvider, Notif> = { 68 discord: { 69 sendAlert: sendDiscordAlert, 70 sendRecovery: sendDiscordRecovery, ··· 120 sendRecovery: sendTelegramRecovery, 121 sendDegraded: sendTelegramDegraded, 122 }, 123 + };
+1 -1
apps/workflows/src/incident/index.ts
··· 19 const unresolvedIncidentMonitorIds = db 20 .select({ monitorId: schema.incidentTable.monitorId }) 21 .from(schema.incidentTable) 22 - .where(and(isNull(schema.incidentTable.resolvedAt))); 23 24 const activeMonitorsWithUnresolvedIncidents = await db 25 .select({ id: schema.monitor.id })
··· 19 const unresolvedIncidentMonitorIds = db 20 .select({ monitorId: schema.incidentTable.monitorId }) 21 .from(schema.incidentTable) 22 + .where(isNull(schema.incidentTable.resolvedAt)); 23 24 const activeMonitorsWithUnresolvedIncidents = await db 25 .select({ id: schema.monitor.id })
+23
packages/notifications/base/package.json
···
··· 1 + { 2 + "name": "@openstatus/notification-base", 3 + "version": "0.0.1", 4 + "description": "Shared types and utilities for OpenStatus notification providers", 5 + "main": "src/index.ts", 6 + "scripts": { 7 + "test": "bun test", 8 + "tsc": "tsc" 9 + }, 10 + "dependencies": { 11 + "@openstatus/db": "workspace:*", 12 + "@openstatus/regions": "workspace:*" 13 + }, 14 + "devDependencies": { 15 + "@openstatus/tsconfig": "workspace:*", 16 + "@types/node": "24.0.8", 17 + "bun-types": "1.3.1", 18 + "typescript": "5.9.3" 19 + }, 20 + "keywords": [], 21 + "author": "", 22 + "license": "ISC" 23 + }
+13
packages/notifications/base/src/index.ts
···
··· 1 + // Types 2 + export type { 3 + NotificationContext, 4 + FormattedMessageData, 5 + NotificationType, 6 + } from "./types"; 7 + 8 + // Utilities 9 + export { formatDuration, calculateDuration } from "./utils/duration"; 10 + export { formatTimestamp } from "./utils/timestamp"; 11 + export { getIncidentDuration } from "./utils/incident"; 12 + export { formatStatusCode, buildCommonMessageData } from "./utils/message"; 13 + export { COLORS, COLOR_DECIMALS } from "./utils/colors";
+41
packages/notifications/base/src/types.ts
···
··· 1 + import type { 2 + Incident, 3 + Monitor, 4 + Notification, 5 + } from "@openstatus/db/src/schema"; 6 + 7 + /** 8 + * Common context passed to all notification providers 9 + */ 10 + export interface NotificationContext { 11 + monitor: Monitor; 12 + notification: Notification; 13 + statusCode?: number; 14 + message?: string; 15 + cronTimestamp: number; 16 + regions?: string[]; 17 + latency?: number; 18 + incident?: Incident; 19 + } 20 + 21 + /** 22 + * Formatted common message data ready for rendering 23 + */ 24 + export interface FormattedMessageData { 25 + monitorName: string; 26 + monitorUrl: string; 27 + monitorMethod?: string; 28 + monitorJobType: string; 29 + statusCodeFormatted: string; 30 + errorMessage: string; 31 + timestampFormatted: string; 32 + regionsDisplay: string; 33 + latencyDisplay: string; 34 + dashboardUrl: string; 35 + incidentDuration?: string; 36 + } 37 + 38 + /** 39 + * Notification type discriminator 40 + */ 41 + export type NotificationType = "alert" | "recovery" | "degraded";
+15
packages/notifications/base/src/utils/colors.ts
···
··· 1 + type Color = "red" | "yellow" | "green" | "blue"; 2 + 3 + export const COLORS = { 4 + red: "#e7000b", // Alert/Error - red left border 5 + yellow: "#f49f1e", // Degraded/Warning - yellow/orange left border 6 + green: "#20c45f", // Recovery/Success - green left border 7 + blue: "#3a81f6", // Monitoring - blue left border 8 + } as const satisfies Record<Color, string>; 9 + 10 + export const COLOR_DECIMALS = { 11 + red: 15138827, // Alert/Error - red left border 12 + yellow: 16031518, // Degraded/Warning - yellow/orange left border 13 + green: 2147423, // Recovery/Success - green left border 14 + blue: 3834358, // Monitoring - blue left border 15 + } as const satisfies Record<Color, number>;
+56
packages/notifications/base/src/utils/duration.test.ts
···
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { calculateDuration, formatDuration } from "./duration"; 3 + 4 + describe("formatDuration", () => { 5 + it("formats short durations (seconds only)", () => { 6 + expect(formatDuration(30000)).toBe("30s"); 7 + }); 8 + 9 + it("formats medium durations (minutes and seconds)", () => { 10 + expect(formatDuration(135000)).toBe("2m 15s"); 11 + }); 12 + 13 + it("formats long durations (hours, minutes, seconds)", () => { 14 + expect(formatDuration(8130000)).toBe("2h 15m 30s"); 15 + }); 16 + 17 + it("formats very long durations (days and hours)", () => { 18 + expect(formatDuration(90000000)).toBe("1d 1h"); 19 + }); 20 + 21 + it("handles 0ms", () => { 22 + expect(formatDuration(0)).toBe("0s"); 23 + }); 24 + 25 + it("handles negative values", () => { 26 + expect(formatDuration(-1000)).toBe("0s"); 27 + }); 28 + 29 + it("handles sub-second durations", () => { 30 + expect(formatDuration(500)).toBe("0s"); 31 + }); 32 + 33 + it("respects maxUnits option", () => { 34 + expect(formatDuration(90061000, { maxUnits: 2 })).toBe("1d 1h"); 35 + expect(formatDuration(90061000, { maxUnits: 1 })).toBe("1d"); 36 + }); 37 + 38 + it("only shows non-zero units", () => { 39 + expect(formatDuration(3600000)).toBe("1h"); 40 + expect(formatDuration(60000)).toBe("1m"); 41 + expect(formatDuration(86400000)).toBe("1d"); 42 + }); 43 + }); 44 + 45 + describe("calculateDuration", () => { 46 + it("calculates duration between two dates", () => { 47 + const start = new Date("2026-01-22T10:00:00Z"); 48 + const end = new Date("2026-01-22T12:15:30Z"); 49 + expect(calculateDuration(start, end)).toBe(8130000); 50 + }); 51 + 52 + it("handles same start and end", () => { 53 + const date = new Date("2026-01-22T10:00:00Z"); 54 + expect(calculateDuration(date, date)).toBe(0); 55 + }); 56 + });
+66
packages/notifications/base/src/utils/duration.ts
···
··· 1 + /** 2 + * Format milliseconds to human-readable duration 3 + * 4 + * @example 5 + * formatDuration(30000) // "30s" 6 + * formatDuration(135000) // "2m 15s" 7 + * formatDuration(8130000) // "2h 15m 30s" 8 + * formatDuration(90000000) // "1d 1h" 9 + * 10 + * @param durationMs - Duration in milliseconds 11 + * @param options - Formatting options 12 + * @param options.maxUnits - Max number of units to show (default: 3) 13 + * @returns Formatted duration string 14 + */ 15 + export function formatDuration( 16 + durationMs: number, 17 + options?: { 18 + maxUnits?: number; 19 + }, 20 + ): string { 21 + const maxUnits = options?.maxUnits ?? 3; 22 + 23 + if (durationMs < 0) { 24 + return "0s"; 25 + } 26 + 27 + const totalSeconds = Math.floor(durationMs / 1000); 28 + 29 + if (totalSeconds === 0) { 30 + return "0s"; 31 + } 32 + 33 + const days = Math.floor(totalSeconds / 86400); 34 + const hours = Math.floor((totalSeconds % 86400) / 3600); 35 + const minutes = Math.floor((totalSeconds % 3600) / 60); 36 + const seconds = totalSeconds % 60; 37 + 38 + const parts: string[] = []; 39 + 40 + if (days > 0) { 41 + parts.push(`${days}d`); 42 + } 43 + if (hours > 0) { 44 + parts.push(`${hours}h`); 45 + } 46 + if (minutes > 0) { 47 + parts.push(`${minutes}m`); 48 + } 49 + if (seconds > 0) { 50 + parts.push(`${seconds}s`); 51 + } 52 + 53 + // Take only the first maxUnits parts 54 + return parts.slice(0, maxUnits).join(" "); 55 + } 56 + 57 + /** 58 + * Calculate duration between two timestamps 59 + * 60 + * @param start - Start date 61 + * @param end - End date 62 + * @returns Duration in milliseconds 63 + */ 64 + export function calculateDuration(start: Date, end: Date): number { 65 + return end.getTime() - start.getTime(); 66 + }
+98
packages/notifications/base/src/utils/incident.test.ts
···
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import type { Incident } from "@openstatus/db/src/schema"; 3 + import { getIncidentDuration } from "./incident"; 4 + 5 + // Helper to create a partial incident object for testing 6 + function createIncident(overrides: Partial<Incident>): Incident { 7 + return { 8 + id: 1, 9 + monitorId: 1, 10 + workspaceId: 1, 11 + startedAt: null, 12 + resolvedAt: null, 13 + autoResolved: false, 14 + acknowledgedAt: null, 15 + acknowledgedBy: null, 16 + ...overrides, 17 + } as Incident; 18 + } 19 + 20 + describe("getIncidentDuration", () => { 21 + it("returns null when incident.startedAt is missing", () => { 22 + const incident = createIncident({ 23 + startedAt: null, 24 + resolvedAt: new Date("2026-01-22T12:00:00Z"), 25 + }); 26 + expect(getIncidentDuration(incident)).toBe(null); 27 + }); 28 + 29 + it("returns null when incident.resolvedAt is null (ongoing incident)", () => { 30 + const incident = createIncident({ 31 + startedAt: new Date("2026-01-22T10:00:00Z"), 32 + resolvedAt: null, 33 + }); 34 + expect(getIncidentDuration(incident)).toBe(null); 35 + }); 36 + 37 + it("calculates correct duration for resolved incident", () => { 38 + // 2h 15m 30s = 8130000ms 39 + const incident = createIncident({ 40 + startedAt: new Date("2026-01-22T10:00:00Z"), 41 + resolvedAt: new Date("2026-01-22T12:15:30Z"), 42 + }); 43 + expect(getIncidentDuration(incident)).toBe("2h 15m 30s"); 44 + }); 45 + 46 + it("calculates duration with Date objects", () => { 47 + const incident = createIncident({ 48 + startedAt: new Date("2026-01-22T10:00:00Z"), 49 + resolvedAt: new Date("2026-01-22T10:30:00Z"), 50 + }); 51 + expect(getIncidentDuration(incident)).toBe("30m"); 52 + }); 53 + 54 + it("calculates duration with timestamp numbers", () => { 55 + // 5 minutes = 300000ms 56 + const startTime = new Date("2026-01-22T10:00:00Z").getTime(); 57 + const endTime = new Date("2026-01-22T10:05:00Z").getTime(); 58 + const incident = createIncident({ 59 + startedAt: startTime as unknown as Date, 60 + resolvedAt: endTime as unknown as Date, 61 + }); 62 + expect(getIncidentDuration(incident)).toBe("5m"); 63 + }); 64 + 65 + it("handles very short durations (less than a minute)", () => { 66 + const incident = createIncident({ 67 + startedAt: new Date("2026-01-22T10:00:00Z"), 68 + resolvedAt: new Date("2026-01-22T10:00:45Z"), 69 + }); 70 + expect(getIncidentDuration(incident)).toBe("45s"); 71 + }); 72 + 73 + it("handles very long durations (over a day)", () => { 74 + // 1 day, 2 hours = 26 hours = 93600000ms 75 + const incident = createIncident({ 76 + startedAt: new Date("2026-01-21T10:00:00Z"), 77 + resolvedAt: new Date("2026-01-22T12:00:00Z"), 78 + }); 79 + expect(getIncidentDuration(incident)).toBe("1d 2h"); 80 + }); 81 + 82 + it("handles same start and end time (0 duration)", () => { 83 + const timestamp = new Date("2026-01-22T10:00:00Z"); 84 + const incident = createIncident({ 85 + startedAt: timestamp, 86 + resolvedAt: timestamp, 87 + }); 88 + expect(getIncidentDuration(incident)).toBe("0s"); 89 + }); 90 + 91 + it("returns null when resolvedAt is before startedAt (negative duration)", () => { 92 + const incident = createIncident({ 93 + startedAt: new Date("2026-01-22T12:00:00Z"), 94 + resolvedAt: new Date("2026-01-22T10:00:00Z"), 95 + }); 96 + expect(getIncidentDuration(incident)).toBe(null); 97 + }); 98 + });
+53
packages/notifications/base/src/utils/incident.ts
···
··· 1 + import type { Incident } from "@openstatus/db/src/schema"; 2 + import { formatDuration } from "./duration"; 3 + 4 + /** 5 + * Get formatted incident duration 6 + * 7 + * Returns duration string only if incident is resolved (has resolvedAt timestamp). 8 + * Returns null for ongoing incidents or if startedAt is missing. 9 + * 10 + * @example 11 + * // Resolved incident 12 + * getIncidentDuration({ 13 + * startedAt: new Date('2026-01-22T10:00:00Z'), 14 + * resolvedAt: new Date('2026-01-22T12:15:30Z') 15 + * }) // "2h 15m 30s" 16 + * 17 + * // Ongoing incident 18 + * getIncidentDuration({ 19 + * startedAt: new Date('2026-01-22T10:00:00Z'), 20 + * resolvedAt: null 21 + * }) // null 22 + * 23 + * @param incident - The incident object 24 + * @returns Formatted duration string or null if incident is not resolved 25 + */ 26 + export function getIncidentDuration(incident: Incident): string | null { 27 + if (!incident.startedAt) { 28 + return null; 29 + } 30 + 31 + // Only calculate duration for resolved incidents 32 + if (!incident.resolvedAt) { 33 + return null; 34 + } 35 + 36 + const startTime = 37 + incident.startedAt instanceof Date 38 + ? incident.startedAt.getTime() 39 + : incident.startedAt; 40 + 41 + const endTime = 42 + incident.resolvedAt instanceof Date 43 + ? incident.resolvedAt.getTime() 44 + : incident.resolvedAt; 45 + 46 + const durationMs = endTime - startTime; 47 + 48 + if (durationMs < 0) { 49 + return null; 50 + } 51 + 52 + return formatDuration(durationMs); 53 + }
+122
packages/notifications/base/src/utils/message.ts
···
··· 1 + import type { Incident } from "@openstatus/db/src/schema"; 2 + import { getRegionInfo } from "@openstatus/regions"; 3 + import type { FormattedMessageData, NotificationContext } from "../types"; 4 + import { getIncidentDuration } from "./incident"; 5 + import { formatTimestamp } from "./timestamp"; 6 + 7 + /** 8 + * Common HTTP status descriptions 9 + */ 10 + const statusDescriptions: Record<number, string> = { 11 + 200: "OK", 12 + 201: "Created", 13 + 204: "No Content", 14 + 301: "Moved Permanently", 15 + 302: "Found", 16 + 304: "Not Modified", 17 + 400: "Bad Request", 18 + 401: "Unauthorized", 19 + 403: "Forbidden", 20 + 404: "Not Found", 21 + 405: "Method Not Allowed", 22 + 408: "Request Timeout", 23 + 429: "Too Many Requests", 24 + 500: "Internal Server Error", 25 + 502: "Bad Gateway", 26 + 503: "Service Unavailable", 27 + 504: "Gateway Timeout", 28 + }; 29 + 30 + /** 31 + * Format status code for display with human-readable description 32 + * 33 + * @example 34 + * formatStatusCode(503) // "503 Service Unavailable" 35 + * formatStatusCode(404) // "404 Not Found" 36 + * formatStatusCode(418) // "418" (no description available) 37 + * formatStatusCode(undefined) // "Unknown" 38 + * 39 + * @param statusCode - HTTP status code 40 + * @returns Formatted status code string 41 + */ 42 + export function formatStatusCode(statusCode?: number): string { 43 + if (!statusCode) { 44 + return "Unknown"; 45 + } 46 + 47 + const description = statusDescriptions[statusCode]; 48 + return description ? `${statusCode} ${description}` : `${statusCode}`; 49 + } 50 + 51 + /** 52 + * Build common formatted message data from notification context 53 + * 54 + * Centralizes formatting logic used by all providers to ensure consistency. 55 + * 56 + * @example 57 + * const data = buildCommonMessageData(context); 58 + * // Returns: { 59 + * // monitorName: "My API", 60 + * // monitorUrl: "https://api.example.com", 61 + * // monitorMethod: "GET", 62 + * // monitorJobType: "http", 63 + * // statusCodeFormatted: "503 Service Unavailable", 64 + * // errorMessage: "Connection timeout", 65 + * // timestampFormatted: "Jan 22, 2026 at 14:30 UTC", 66 + * // regionsDisplay: "ams, fra, syd", 67 + * // latencyDisplay: "2,450ms", 68 + * // dashboardUrl: "https://app.openstatus.dev/monitors/123", 69 + * // incidentDuration: undefined 70 + * // } 71 + * 72 + * @param context - Notification context with monitor and event data 73 + * @param options - Optional configuration 74 + * @param options.incident - Include incident data for duration calculation 75 + * @returns Formatted message data ready for rendering 76 + */ 77 + export function buildCommonMessageData( 78 + context: NotificationContext, 79 + options?: { 80 + incident?: Incident; 81 + }, 82 + ): FormattedMessageData { 83 + const { monitor, statusCode, message, cronTimestamp, regions, latency } = 84 + context; 85 + 86 + // Format multiple regions as comma-separated list 87 + let regionsDisplay = "Unknown"; 88 + if (regions && regions.length > 0) { 89 + if (regions.length === 1) { 90 + // Single region: show code and location 91 + const regionInfo = getRegionInfo(regions[0]); 92 + regionsDisplay = regionInfo 93 + ? `${regionInfo.code} (${regionInfo.location})` 94 + : regions[0]; 95 + } else { 96 + // Multiple regions: show comma-separated codes 97 + regionsDisplay = regions.join(", "); 98 + } 99 + } 100 + 101 + // Calculate incident duration only if incident is resolved 102 + let incidentDuration: string | undefined; 103 + if (options?.incident?.resolvedAt) { 104 + const duration = getIncidentDuration(options.incident); 105 + incidentDuration = duration ?? undefined; 106 + } 107 + 108 + return { 109 + monitorName: monitor.name, 110 + monitorUrl: monitor.url, 111 + monitorMethod: monitor.method ?? undefined, 112 + monitorJobType: monitor.jobType, 113 + statusCodeFormatted: formatStatusCode(statusCode), 114 + errorMessage: message || "No error message available", 115 + timestampFormatted: formatTimestamp(cronTimestamp), 116 + regionsDisplay, 117 + latencyDisplay: 118 + typeof latency === "number" ? `${latency.toLocaleString()}ms` : "N/A", 119 + dashboardUrl: `https://app.openstatus.dev/monitors/${monitor.id}`, 120 + incidentDuration, 121 + }; 122 + }
+22
packages/notifications/base/src/utils/timestamp.ts
···
··· 1 + /** 2 + * Format cron timestamp (epoch ms) to ISO string 3 + * 4 + * @example 5 + * formatTimestamp(1737553800000) // "2026-01-22T14:30:00.000Z" 6 + * 7 + * @param cronTimestamp - Epoch timestamp in milliseconds 8 + * @returns Formatted timestamp string to ISO string 9 + */ 10 + export function formatTimestamp(cronTimestamp: number): string { 11 + if (!cronTimestamp || !Number.isFinite(cronTimestamp)) { 12 + return "Unknown"; 13 + } 14 + 15 + const date = new Date(cronTimestamp); 16 + 17 + if (Number.isNaN(date.getTime())) { 18 + return "Unknown"; 19 + } 20 + 21 + return date.toISOString(); 22 + }
+5
packages/notifications/base/tsconfig.json
···
··· 1 + { 2 + "extends": "@openstatus/tsconfig/base.json", 3 + "include": ["src", "*.ts"], 4 + "exclude": ["**/*.test.ts"] 5 + }
+3 -1
packages/notifications/discord/package.json
··· 3 "version": "1.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 - "test": "bun test" 7 }, 8 "dependencies": { 9 "@openstatus/db": "workspace:*", 10 "zod": "4.1.13" 11 }, 12 "devDependencies": {
··· 3 "version": "1.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 + "test": "bun test", 7 + "tsc": "tsc" 8 }, 9 "dependencies": { 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 12 "zod": "4.1.13" 13 }, 14 "devDependencies": {
+268
packages/notifications/discord/src/embeds.ts
···
··· 1 + import { 2 + COLOR_DECIMALS, 3 + type FormattedMessageData, 4 + } from "@openstatus/notification-base"; 5 + 6 + /** 7 + * Discord Embed structure for webhook messages 8 + * Reference: https://birdie0.github.io/discord-webhooks-guide/structure/embeds.html 9 + */ 10 + 11 + interface DiscordEmbedField { 12 + name: string; 13 + value: string; 14 + inline?: boolean; 15 + } 16 + 17 + interface DiscordEmbedFooter { 18 + text: string; 19 + } 20 + 21 + export interface DiscordEmbed { 22 + title: string; 23 + description: string; 24 + color: number; 25 + fields: DiscordEmbedField[]; 26 + timestamp: string; 27 + footer: DiscordEmbedFooter; 28 + url: string; 29 + } 30 + 31 + /** 32 + * Builds Discord embed for alert notifications 33 + * 34 + * Layout: 35 + * - Title: "{monitor.name} is failing" 36 + * - Description: "METHOD URL" in code format (e.g., `GET https://api.example.com`) 37 + * - Color: Red (#ED4245 / 15548997) 38 + * - Fields: Status Code, Regions, Latency, Cron Timestamp (inline), Error Message in code block (full width) 39 + * - Timestamp: ISO 8601 format 40 + * - Footer: "openstatus" 41 + * - URL: Dashboard link 42 + * 43 + * @param data - Formatted message data from buildCommonMessageData 44 + * @returns DiscordEmbed object ready for webhook payload 45 + * 46 + * @example 47 + * const embed = buildAlertEmbed({ 48 + * monitorName: "API Health", 49 + * monitorUrl: "https://api.example.com", 50 + * statusCodeFormatted: "503 Service Unavailable", 51 + * errorMessage: "Connection timeout", 52 + * timestampFormatted: "Jan 22, 2026 at 14:30 UTC", 53 + * regionDisplay: "iad (Virginia)", 54 + * latencyDisplay: "1,234 ms", 55 + * dashboardUrl: "https://app.openstatus.dev/monitors/123" 56 + * }); 57 + */ 58 + export function buildAlertEmbed(data: FormattedMessageData): DiscordEmbed { 59 + // Format description as "METHOD URL" or just "URL" for non-HTTP 60 + const description = 61 + data.monitorMethod && data.monitorJobType === "http" 62 + ? `${data.monitorMethod} ${data.monitorUrl}` 63 + : data.monitorUrl; 64 + 65 + return { 66 + title: `${data.monitorName} is failing`, 67 + description: `\`${description}\``, 68 + color: COLOR_DECIMALS.red, 69 + fields: [ 70 + { 71 + name: "Status Code", 72 + value: data.statusCodeFormatted, 73 + inline: true, 74 + }, 75 + { 76 + name: "Regions", 77 + value: data.regionsDisplay, 78 + inline: true, 79 + }, 80 + { 81 + name: "Latency", 82 + value: data.latencyDisplay, 83 + inline: true, 84 + }, 85 + { 86 + name: "Cron Timestamp", 87 + value: data.timestampFormatted, 88 + inline: true, 89 + }, 90 + { 91 + name: "Error Message", 92 + value: `\`\`\`${data.errorMessage}\`\`\``, 93 + inline: false, 94 + }, 95 + ], 96 + timestamp: new Date().toISOString(), 97 + footer: { 98 + text: "openstatus", 99 + }, 100 + url: data.dashboardUrl, 101 + }; 102 + } 103 + 104 + /** 105 + * Builds Discord embed for recovery notifications 106 + * 107 + * Layout: 108 + * - Title: "{monitor.name} is recovered" 109 + * - Description: "METHOD URL" in code format (e.g., `GET https://api.example.com`) 110 + * - Color: Green (#57F287 / 5763719) 111 + * - Fields: Optional Downtime field (if incidentDuration exists), Status Code, Regions, Latency, Cron Timestamp (inline) 112 + * - Timestamp: ISO 8601 format 113 + * - Footer: "openstatus" 114 + * - URL: Dashboard link 115 + * 116 + * @param data - Formatted message data from buildCommonMessageData 117 + * @returns DiscordEmbed object ready for webhook payload 118 + * 119 + * @example 120 + * const embed = buildRecoveryEmbed({ 121 + * monitorName: "API Health", 122 + * monitorUrl: "https://api.example.com", 123 + * statusCodeFormatted: "200 OK", 124 + * errorMessage: "", 125 + * timestampFormatted: "Jan 22, 2026 at 14:35 UTC", 126 + * regionDisplay: "iad (Virginia)", 127 + * latencyDisplay: "156 ms", 128 + * dashboardUrl: "https://app.openstatus.dev/monitors/123", 129 + * incidentDuration: "5m 30s" 130 + * }); 131 + */ 132 + export function buildRecoveryEmbed(data: FormattedMessageData): DiscordEmbed { 133 + const fields: DiscordEmbedField[] = []; 134 + 135 + // Format description as "METHOD URL" or just "URL" for non-HTTP 136 + const description = 137 + data.monitorMethod && data.monitorJobType === "http" 138 + ? `${data.monitorMethod} ${data.monitorUrl}` 139 + : data.monitorUrl; 140 + 141 + // Add downtime field if incident duration is available 142 + if (data.incidentDuration) { 143 + fields.push({ 144 + name: "⏱️ Downtime", 145 + value: data.incidentDuration, 146 + inline: false, 147 + }); 148 + } 149 + 150 + // Add status fields (inline for 3-column layout) 151 + fields.push( 152 + { 153 + name: "Status Code", 154 + value: data.statusCodeFormatted, 155 + inline: true, 156 + }, 157 + { 158 + name: "Regions", 159 + value: data.regionsDisplay, 160 + inline: true, 161 + }, 162 + { 163 + name: "Latency", 164 + value: data.latencyDisplay, 165 + inline: true, 166 + }, 167 + { 168 + name: "Cron Timestamp", 169 + value: data.timestampFormatted, 170 + inline: true, 171 + }, 172 + ); 173 + 174 + return { 175 + title: `${data.monitorName} is recovered`, 176 + description: `\`${description}\``, 177 + color: COLOR_DECIMALS.green, 178 + fields, 179 + timestamp: new Date().toISOString(), 180 + footer: { 181 + text: "openstatus", 182 + }, 183 + url: data.dashboardUrl, 184 + }; 185 + } 186 + 187 + /** 188 + * Builds Discord embed for degraded notifications 189 + * 190 + * Layout: 191 + * - Title: "{monitor.name} is degraded" 192 + * - Description: "METHOD URL" in code format (e.g., `GET https://api.example.com`) 193 + * - Color: Yellow (#FEE75C / 16705372) 194 + * - Fields: Optional Previous Incident Duration field (if incidentDuration exists), Status Code, Regions, Latency, Cron Timestamp (inline) 195 + * - Timestamp: ISO 8601 format 196 + * - Footer: "openstatus" 197 + * - URL: Dashboard link 198 + * 199 + * @param data - Formatted message data from buildCommonMessageData 200 + * @returns DiscordEmbed object ready for webhook payload 201 + * 202 + * @example 203 + * const embed = buildDegradedEmbed({ 204 + * monitorName: "API Health", 205 + * monitorUrl: "https://api.example.com", 206 + * statusCodeFormatted: "504 Gateway Timeout", 207 + * errorMessage: "Slow response", 208 + * timestampFormatted: "Jan 22, 2026 at 14:40 UTC", 209 + * regionDisplay: "iad (Virginia)", 210 + * latencyDisplay: "5,234 ms", 211 + * dashboardUrl: "https://app.openstatus.dev/monitors/123", 212 + * incidentDuration: "2h 15m" 213 + * }); 214 + */ 215 + export function buildDegradedEmbed(data: FormattedMessageData): DiscordEmbed { 216 + const fields: DiscordEmbedField[] = []; 217 + 218 + // Format description as "METHOD URL" or just "URL" for non-HTTP 219 + const description = 220 + data.monitorMethod && data.monitorJobType === "http" 221 + ? `${data.monitorMethod} ${data.monitorUrl}` 222 + : data.monitorUrl; 223 + 224 + // Add previous incident duration field if available 225 + if (data.incidentDuration) { 226 + fields.push({ 227 + name: "⏱️ Previous Incident Duration", 228 + value: data.incidentDuration, 229 + inline: false, 230 + }); 231 + } 232 + 233 + // Add status fields (inline for 3-column layout) 234 + fields.push( 235 + { 236 + name: "Status Code", 237 + value: data.statusCodeFormatted, 238 + inline: true, 239 + }, 240 + { 241 + name: "Regions", 242 + value: data.regionsDisplay, 243 + inline: true, 244 + }, 245 + { 246 + name: "Latency", 247 + value: data.latencyDisplay, 248 + inline: true, 249 + }, 250 + { 251 + name: "Cron Timestamp", 252 + value: data.timestampFormatted, 253 + inline: true, 254 + }, 255 + ); 256 + 257 + return { 258 + title: `${data.monitorName} is degraded`, 259 + description: `\`${description}\``, 260 + color: COLOR_DECIMALS.yellow, 261 + fields, 262 + timestamp: new Date().toISOString(), 263 + footer: { 264 + text: "openstatus", 265 + }, 266 + url: data.dashboardUrl, 267 + }; 268 + }
+17 -15
packages/notifications/discord/src/index.test.ts
··· 1 import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; 2 import { selectNotificationSchema } from "@openstatus/db/src/schema"; 3 import { 4 sendAlert, 5 sendDegraded, ··· 70 expect(callArgs[1].headers["Content-Type"]).toBe("application/json"); 71 72 const body = JSON.parse(callArgs[1].body); 73 - expect(body.content).toContain("🚨 Alert"); 74 - expect(body.content).toContain("API Health Check"); 75 - expect(body.content).toContain("Something went wrong"); 76 expect(body.username).toBe("OpenStatus Notifications"); 77 expect(body.avatar_url).toBeDefined(); 78 }); ··· 95 expect(fetchMock).toHaveBeenCalledTimes(1); 96 const callArgs = fetchMock.mock.calls[0]; 97 const body = JSON.parse(callArgs[1].body); 98 - expect(body.content).toContain("✅ Recovered"); 99 - expect(body.content).toContain("API Health Check"); 100 }); 101 102 test("Send Degraded", async () => { ··· 117 expect(fetchMock).toHaveBeenCalledTimes(1); 118 const callArgs = fetchMock.mock.calls[0]; 119 const body = JSON.parse(callArgs[1].body); 120 - expect(body.content).toContain("⚠️ Degraded"); 121 - expect(body.content).toContain("API Health Check"); 122 }); 123 124 test("Send Test Discord Message", async () => { 125 const webhookUrl = "https://discord.com/api/webhooks/123456789/abcdefgh"; 126 - 127 - const result = await sendTestDiscordMessage(webhookUrl); 128 129 - expect(result).toBe(true); 130 expect(fetchMock).toHaveBeenCalledTimes(1); 131 const callArgs = fetchMock.mock.calls[0]; 132 expect(callArgs[0]).toBe(webhookUrl); 133 const body = JSON.parse(callArgs[1].body); 134 - expect(body.content).toContain("🧪 Test"); 135 - expect(body.content).toContain("OpenStatus"); 136 }); 137 138 test("Send Test Discord Message with empty webhookUrl", async () => { 139 - const result = await sendTestDiscordMessage(""); 140 - 141 - expect(result).toBe(false); 142 expect(fetchMock).not.toHaveBeenCalled(); 143 }); 144
··· 1 import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; 2 import { selectNotificationSchema } from "@openstatus/db/src/schema"; 3 + import { COLOR_DECIMALS } from "@openstatus/notification-base"; 4 import { 5 sendAlert, 6 sendDegraded, ··· 71 expect(callArgs[1].headers["Content-Type"]).toBe("application/json"); 72 73 const body = JSON.parse(callArgs[1].body); 74 + expect(body.embeds).toBeDefined(); 75 + expect(body.embeds[0].title).toContain("is failing"); 76 + expect(body.embeds[0].title).toContain("API Health Check"); 77 + expect(body.embeds[0].color).toBe(COLOR_DECIMALS.red); 78 expect(body.username).toBe("OpenStatus Notifications"); 79 expect(body.avatar_url).toBeDefined(); 80 }); ··· 97 expect(fetchMock).toHaveBeenCalledTimes(1); 98 const callArgs = fetchMock.mock.calls[0]; 99 const body = JSON.parse(callArgs[1].body); 100 + expect(body.embeds).toBeDefined(); 101 + expect(body.embeds[0].title).toContain("is recovered"); 102 + expect(body.embeds[0].title).toContain("API Health Check"); 103 + expect(body.embeds[0].color).toBe(COLOR_DECIMALS.green); 104 }); 105 106 test("Send Degraded", async () => { ··· 121 expect(fetchMock).toHaveBeenCalledTimes(1); 122 const callArgs = fetchMock.mock.calls[0]; 123 const body = JSON.parse(callArgs[1].body); 124 + expect(body.embeds).toBeDefined(); 125 + expect(body.embeds[0].title).toContain("is degraded"); 126 + expect(body.embeds[0].title).toContain("API Health Check"); 127 + expect(body.embeds[0].color).toBe(COLOR_DECIMALS.yellow); // Yellow color 128 }); 129 130 test("Send Test Discord Message", async () => { 131 const webhookUrl = "https://discord.com/api/webhooks/123456789/abcdefgh"; 132 + await sendTestDiscordMessage(webhookUrl); 133 134 expect(fetchMock).toHaveBeenCalledTimes(1); 135 const callArgs = fetchMock.mock.calls[0]; 136 expect(callArgs[0]).toBe(webhookUrl); 137 const body = JSON.parse(callArgs[1].body); 138 + expect(body.embeds).toBeDefined(); 139 + expect(body.embeds[0].title).toContain("Test Notification"); 140 }); 141 142 test("Send Test Discord Message with empty webhookUrl", async () => { 143 + expect(sendTestDiscordMessage("")).rejects.toThrow(); 144 expect(fetchMock).not.toHaveBeenCalled(); 145 }); 146
+104 -93
packages/notifications/discord/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 import { discordDataSchema } from "@openstatus/db/src/schema"; 3 - import type { Region } from "@openstatus/db/src/schema/constants"; 4 5 - const postToWebhook = async (content: string, webhookUrl: string) => { 6 const res = await fetch(webhookUrl, { 7 method: "POST", 8 headers: { 9 "Content-Type": "application/json", 10 }, 11 body: JSON.stringify({ 12 - content, 13 avatar_url: 14 "https://img.stackshare.io/service/104872/default_dc6948366d9bae553adbb8f51252eafbc5d2043a.jpg", 15 username: "OpenStatus Notifications", ··· 28 statusCode, 29 message, 30 cronTimestamp, 31 - }: { 32 - monitor: Monitor; 33 - notification: Notification; 34 - statusCode?: number; 35 - message?: string; 36 - incidentId?: string; 37 - cronTimestamp: number; 38 - latency?: number; 39 - region?: Region; 40 - }) => { 41 const notificationData = discordDataSchema.parse( 42 JSON.parse(notification.data), 43 ); 44 - const { discord: webhookUrl } = notificationData; // webhook url 45 - const { name } = monitor; 46 47 - try { 48 - await postToWebhook( 49 - `**🚨 Alert [${name}](<${monitor.url}>)**\nStatus Code: ${ 50 - statusCode || "_empty_" 51 - }\nMessage: ${ 52 - message || "_empty_" 53 - }\nCron Timestamp: ${cronTimestamp} (${new Date( 54 - cronTimestamp, 55 - ).toISOString()})\n> Check your [Dashboard](<https://www.openstatus.dev/app/>).\n`, 56 - webhookUrl, 57 - ); 58 - } catch (err) { 59 - console.error(err); 60 - throw err; 61 - } 62 }; 63 64 export const sendRecovery = async ({ 65 monitor, 66 notification, 67 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 68 statusCode, 69 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 70 message, 71 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 72 - incidentId, 73 - }: { 74 - monitor: Monitor; 75 - notification: Notification; 76 - statusCode?: number; 77 - message?: string; 78 - incidentId?: string; 79 - cronTimestamp: number; 80 - latency?: number; 81 - region?: Region; 82 - }) => { 83 const notificationData = discordDataSchema.parse( 84 JSON.parse(notification.data), 85 ); 86 - const { discord: webhookUrl } = notificationData; // webhook url 87 - const { name } = monitor; 88 89 - try { 90 - await postToWebhook( 91 - `**✅ Recovered [${name}](<${monitor.url}>)**\n> Check your [Dashboard](<https://www.openstatus.dev/app/>).\n`, 92 - webhookUrl, 93 - ); 94 - } catch (err) { 95 - console.error(err); 96 - throw err; 97 - } 98 }; 99 100 export const sendDegraded = async ({ 101 monitor, 102 notification, 103 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 104 statusCode, 105 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 106 message, 107 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 108 - incidentId, 109 - }: { 110 - monitor: Monitor; 111 - notification: Notification; 112 - statusCode?: number; 113 - message?: string; 114 - incidentId?: string; 115 - cronTimestamp: number; 116 - latency?: number; 117 - region?: Region; 118 - }) => { 119 const notificationData = discordDataSchema.parse( 120 JSON.parse(notification.data), 121 ); 122 - const { discord: webhookUrl } = notificationData; // webhook url 123 - const { name } = monitor; 124 125 - try { 126 - await postToWebhook( 127 - `**⚠️ Degraded [${name}](<${monitor.url}>)**\n> Check your [Dashboard](<https://www.openstatus.dev/app/>).\n`, 128 - webhookUrl, 129 - ); 130 - } catch (err) { 131 - console.error(err); 132 - throw err; 133 - } 134 }; 135 136 export const sendTestDiscordMessage = async (webhookUrl: string) => { 137 - if (!webhookUrl) { 138 - return false; 139 - } 140 - try { 141 - await postToWebhook( 142 - "**🧪 Test [OpenStatus](<https://www.openstatus.dev/>)**\nIf you can read this, your Discord webhook is functioning correctly!\n> Check your [Dashboard](<https://www.openstatus.dev/app/>).\n", 143 - webhookUrl, 144 - ); 145 - return true; 146 - } catch (_err) { 147 - return false; 148 - } 149 };
··· 1 import { discordDataSchema } from "@openstatus/db/src/schema"; 2 + import { 3 + COLOR_DECIMALS, 4 + type NotificationContext, 5 + buildCommonMessageData, 6 + } from "@openstatus/notification-base"; 7 + import { 8 + type DiscordEmbed, 9 + buildAlertEmbed, 10 + buildDegradedEmbed, 11 + buildRecoveryEmbed, 12 + } from "./embeds"; 13 + 14 + const postToWebhook = async (embeds: DiscordEmbed[], webhookUrl: string) => { 15 + if (!webhookUrl || webhookUrl.trim() === "") { 16 + throw new Error("Discord webhook URL is required"); 17 + } 18 19 const res = await fetch(webhookUrl, { 20 method: "POST", 21 headers: { 22 "Content-Type": "application/json", 23 }, 24 body: JSON.stringify({ 25 + embeds, 26 avatar_url: 27 "https://img.stackshare.io/service/104872/default_dc6948366d9bae553adbb8f51252eafbc5d2043a.jpg", 28 username: "OpenStatus Notifications", ··· 41 statusCode, 42 message, 43 cronTimestamp, 44 + latency, 45 + regions, 46 + }: NotificationContext) => { 47 const notificationData = discordDataSchema.parse( 48 JSON.parse(notification.data), 49 ); 50 + const { discord: webhookUrl } = notificationData; 51 + 52 + const context = { 53 + monitor, 54 + notification, 55 + statusCode, 56 + message, 57 + cronTimestamp, 58 + latency, 59 + regions, 60 + }; 61 + 62 + const data = buildCommonMessageData(context); 63 + const embed = buildAlertEmbed(data); 64 65 + await postToWebhook([embed], webhookUrl); 66 }; 67 68 export const sendRecovery = async ({ 69 monitor, 70 notification, 71 statusCode, 72 message, 73 + incident, 74 + cronTimestamp, 75 + latency, 76 + regions, 77 + }: NotificationContext) => { 78 const notificationData = discordDataSchema.parse( 79 JSON.parse(notification.data), 80 ); 81 + const { discord: webhookUrl } = notificationData; 82 + 83 + const context = { 84 + monitor, 85 + notification, 86 + statusCode, 87 + message, 88 + cronTimestamp, 89 + latency, 90 + regions, 91 + }; 92 + 93 + const data = buildCommonMessageData(context, { incident }); 94 + const embed = buildRecoveryEmbed(data); 95 96 + await postToWebhook([embed], webhookUrl); 97 }; 98 99 export const sendDegraded = async ({ 100 monitor, 101 notification, 102 statusCode, 103 message, 104 + incident, 105 + cronTimestamp, 106 + latency, 107 + regions, 108 + }: NotificationContext) => { 109 const notificationData = discordDataSchema.parse( 110 JSON.parse(notification.data), 111 ); 112 + const { discord: webhookUrl } = notificationData; 113 114 + const context = { 115 + monitor, 116 + notification, 117 + statusCode, 118 + message, 119 + cronTimestamp, 120 + latency, 121 + regions, 122 + }; 123 + 124 + const data = buildCommonMessageData(context, { incident }); 125 + const embed = buildDegradedEmbed(data); 126 + 127 + await postToWebhook([embed], webhookUrl); 128 }; 129 130 export const sendTestDiscordMessage = async (webhookUrl: string) => { 131 + const testEmbed: DiscordEmbed = { 132 + title: "Test Notification", 133 + description: "🧪 Your Discord webhook is configured correctly!", 134 + color: COLOR_DECIMALS.green, 135 + fields: [ 136 + { 137 + name: "Status", 138 + value: "Webhook Connected", 139 + inline: true, 140 + }, 141 + { 142 + name: "Type", 143 + value: "Test Notification", 144 + inline: true, 145 + }, 146 + { 147 + name: "Next Steps", 148 + value: 149 + "You will receive notifications here when your monitors trigger fail, recover, or degrades.", 150 + inline: false, 151 + }, 152 + ], 153 + timestamp: new Date().toISOString(), 154 + footer: { 155 + text: "openstatus", 156 + }, 157 + url: "https://www.openstatus.dev/app/", 158 + }; 159 + await postToWebhook([testEmbed], webhookUrl); 160 };
+5
packages/notifications/discord/src/mock.ts
··· 27 status: "active", 28 method: "GET", 29 deletedAt: null, 30 }; 31 32 const notification: Notification = {
··· 27 status: "active", 28 method: "GET", 29 deletedAt: null, 30 + externalName: null, 31 + otelEndpoint: null, 32 + otelHeaders: [], 33 + retry: 3, 34 + followRedirects: false, 35 }; 36 37 const notification: Notification = {
+3 -1
packages/notifications/email/package.json
··· 4 "main": "src/index.ts", 5 "description": "Log drains Vercel integration.", 6 "scripts": { 7 - "test": "bun test" 8 }, 9 "dependencies": { 10 "@openstatus/db": "workspace:*", 11 "@openstatus/emails": "workspace:*", 12 "@openstatus/regions": "workspace:*", 13 "@openstatus/tinybird": "workspace:*", 14 "@openstatus/utils": "workspace:*",
··· 4 "main": "src/index.ts", 5 "description": "Log drains Vercel integration.", 6 "scripts": { 7 + "test": "bun test", 8 + "tsc": "tsc" 9 }, 10 "dependencies": { 11 "@openstatus/db": "workspace:*", 12 "@openstatus/emails": "workspace:*", 13 + "@openstatus/notification-base": "workspace:*", 14 "@openstatus/regions": "workspace:*", 15 "@openstatus/tinybird": "workspace:*", 16 "@openstatus/utils": "workspace:*",
+14 -38
packages/notifications/email/src/index.ts
··· 1 - import { 2 - type Monitor, 3 - type Notification, 4 - emailDataSchema, 5 - } from "@openstatus/db/src/schema"; 6 - 7 import type { Region } from "@openstatus/db/src/schema/constants"; 8 import { EmailClient } from "@openstatus/emails/src/client"; 9 import { regionDict } from "@openstatus/regions"; 10 import { env } from "../env"; 11 ··· 16 message, 17 cronTimestamp, 18 latency, 19 - region, 20 - }: { 21 - monitor: Monitor; 22 - notification: Notification; 23 - statusCode?: number; 24 - message?: string; 25 - incidentId?: string; 26 - cronTimestamp: number; 27 - region?: Region; 28 - latency?: number; 29 - }) => { 30 const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); 31 32 const config = emailDataSchema.safeParse(JSON.parse(notification.data)); ··· 51 notification, 52 statusCode, 53 cronTimestamp, 54 - region, 55 latency, 56 - }: { 57 - monitor: Monitor; 58 - notification: Notification; 59 - statusCode?: number; 60 - message?: string; 61 - incidentId?: string; 62 - cronTimestamp: number; 63 - region?: Region; 64 - latency?: number; 65 - }) => { 66 const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); 67 68 const config = emailDataSchema.safeParse(JSON.parse(notification.data)); ··· 86 notification, 87 statusCode, 88 cronTimestamp, 89 - region, 90 latency, 91 - }: { 92 - monitor: Monitor; 93 - notification: Notification; 94 - statusCode?: number; 95 - message?: string; 96 - cronTimestamp: number; 97 - region?: Region; 98 - latency?: number; 99 - }) => { 100 const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); 101 102 const config = emailDataSchema.safeParse(JSON.parse(notification.data));
··· 1 + import { emailDataSchema } from "@openstatus/db/src/schema"; 2 import type { Region } from "@openstatus/db/src/schema/constants"; 3 import { EmailClient } from "@openstatus/emails/src/client"; 4 + import type { NotificationContext } from "@openstatus/notification-base"; 5 import { regionDict } from "@openstatus/regions"; 6 import { env } from "../env"; 7 ··· 12 message, 13 cronTimestamp, 14 latency, 15 + regions, 16 + }: NotificationContext) => { 17 + // Convert regions array to single region for backwards compatibility 18 + const region = regions?.[0] as Region | undefined; 19 const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); 20 21 const config = emailDataSchema.safeParse(JSON.parse(notification.data)); ··· 40 notification, 41 statusCode, 42 cronTimestamp, 43 + regions, 44 latency, 45 + }: NotificationContext) => { 46 + // Convert regions array to single region for backwards compatibility 47 + const region = regions?.[0] as Region | undefined; 48 const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); 49 50 const config = emailDataSchema.safeParse(JSON.parse(notification.data)); ··· 68 notification, 69 statusCode, 70 cronTimestamp, 71 + regions, 72 latency, 73 + }: NotificationContext) => { 74 + // Convert regions array to single region for backwards compatibility 75 + const region = regions?.[0] as Region | undefined; 76 const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); 77 78 const config = emailDataSchema.safeParse(JSON.parse(notification.data));
+1
packages/notifications/email/src/mock.ts
··· 26 otelHeaders: [], 27 followRedirects: false, 28 retry: 3, 29 }; 30 31 const notification: Notification = {
··· 26 otelHeaders: [], 27 followRedirects: false, 28 retry: 3, 29 + externalName: null, 30 }; 31 32 const notification: Notification = {
+3 -1
packages/notifications/google-chat/package.json
··· 3 "version": "1.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 - "test": "bun test" 7 }, 8 "dependencies": { 9 "@openstatus/db": "workspace:*", 10 "zod": "4.1.13" 11 }, 12 "devDependencies": {
··· 3 "version": "1.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 + "test": "bun test", 7 + "tsc": "tsc" 8 }, 9 "dependencies": { 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 12 "zod": "4.1.13" 13 }, 14 "devDependencies": {
+4 -44
packages/notifications/google-chat/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 import { googleChatDataSchema } from "@openstatus/db/src/schema"; 3 - import type { Region } from "@openstatus/db/src/schema/constants"; 4 5 const postToWebhook = async (content: string, webhookUrl: string) => { 6 const res = await fetch(webhookUrl, { ··· 25 statusCode, 26 message, 27 cronTimestamp, 28 - }: { 29 - monitor: Monitor; 30 - notification: Notification; 31 - statusCode?: number; 32 - message?: string; 33 - incidentId?: string; 34 - cronTimestamp: number; 35 - latency?: number; 36 - region?: Region; 37 - }) => { 38 const notificationData = googleChatDataSchema.parse( 39 JSON.parse(notification.data), 40 ); ··· 61 export const sendRecovery = async ({ 62 monitor, 63 notification, 64 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 65 - statusCode, 66 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 67 - message, 68 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 69 - incidentId, 70 - }: { 71 - monitor: Monitor; 72 - notification: Notification; 73 - statusCode?: number; 74 - message?: string; 75 - incidentId?: string; 76 - cronTimestamp: number; 77 - latency?: number; 78 - region?: Region; 79 - }) => { 80 const notificationData = googleChatDataSchema.parse( 81 JSON.parse(notification.data), 82 ); ··· 97 export const sendDegraded = async ({ 98 monitor, 99 notification, 100 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 101 - statusCode, 102 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 103 - message, 104 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 105 - incidentId, 106 - }: { 107 - monitor: Monitor; 108 - notification: Notification; 109 - statusCode?: number; 110 - message?: string; 111 - incidentId?: string; 112 - cronTimestamp: number; 113 - latency?: number; 114 - region?: Region; 115 - }) => { 116 const notificationData = googleChatDataSchema.parse( 117 JSON.parse(notification.data), 118 );
··· 1 import { googleChatDataSchema } from "@openstatus/db/src/schema"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 3 4 const postToWebhook = async (content: string, webhookUrl: string) => { 5 const res = await fetch(webhookUrl, { ··· 24 statusCode, 25 message, 26 cronTimestamp, 27 + }: NotificationContext) => { 28 const notificationData = googleChatDataSchema.parse( 29 JSON.parse(notification.data), 30 ); ··· 51 export const sendRecovery = async ({ 52 monitor, 53 notification, 54 + }: NotificationContext) => { 55 const notificationData = googleChatDataSchema.parse( 56 JSON.parse(notification.data), 57 ); ··· 72 export const sendDegraded = async ({ 73 monitor, 74 notification, 75 + }: NotificationContext) => { 76 const notificationData = googleChatDataSchema.parse( 77 JSON.parse(notification.data), 78 );
+1
packages/notifications/google-chat/src/mock.ts
··· 26 otelHeaders: [], 27 followRedirects: true, 28 retry: 3, 29 }; 30 31 const notification: Notification = {
··· 26 otelHeaders: [], 27 followRedirects: true, 28 retry: 3, 29 + externalName: null, 30 }; 31 32 const notification: Notification = {
+3 -1
packages/notifications/ntfy/package.json
··· 3 "version": "1.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 - "test": "bun test" 7 }, 8 "dependencies": { 9 "@openstatus/db": "workspace:*", 10 "zod": "4.1.13" 11 }, 12 "devDependencies": {
··· 3 "version": "1.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 + "test": "bun test", 7 + "tsc": "tsc" 8 }, 9 "dependencies": { 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 12 "zod": "4.1.13" 13 }, 14 "devDependencies": {
+4 -45
packages/notifications/ntfy/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 - 3 import { ntfyDataSchema } from "@openstatus/db/src/schema"; 4 - import type { Region } from "@openstatus/db/src/schema/constants"; 5 6 export const sendAlert = async ({ 7 monitor, 8 notification, 9 statusCode, 10 message, 11 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 12 - incidentId, 13 - }: { 14 - monitor: Monitor; 15 - notification: Notification; 16 - statusCode?: number; 17 - message?: string; 18 - incidentId?: string; 19 - cronTimestamp: number; 20 - latency?: number; 21 - region?: Region; 22 - }) => { 23 const notificationData = ntfyDataSchema.parse(JSON.parse(notification.data)); 24 const { name } = monitor; 25 ··· 50 export const sendRecovery = async ({ 51 monitor, 52 notification, 53 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 54 - statusCode, 55 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 56 - message, 57 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 58 - incidentId, 59 - }: { 60 - monitor: Monitor; 61 - notification: Notification; 62 - statusCode?: number; 63 - message?: string; 64 - incidentId?: string; 65 - cronTimestamp: number; 66 - latency?: number; 67 - region?: Region; 68 - }) => { 69 const notificationData = ntfyDataSchema.parse(JSON.parse(notification.data)); 70 const { name } = monitor; 71 ··· 93 export const sendDegraded = async ({ 94 monitor, 95 notification, 96 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 97 - statusCode, 98 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 99 - message, 100 - }: { 101 - monitor: Monitor; 102 - notification: Notification; 103 - statusCode?: number; 104 - message?: string; 105 - incidentId?: string; 106 - cronTimestamp: number; 107 - latency?: number; 108 - region?: Region; 109 - }) => { 110 const notificationData = ntfyDataSchema.parse(JSON.parse(notification.data)); 111 const { name } = monitor; 112
··· 1 import { ntfyDataSchema } from "@openstatus/db/src/schema"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 3 4 export const sendAlert = async ({ 5 monitor, 6 notification, 7 statusCode, 8 message, 9 + }: NotificationContext) => { 10 const notificationData = ntfyDataSchema.parse(JSON.parse(notification.data)); 11 const { name } = monitor; 12 ··· 37 export const sendRecovery = async ({ 38 monitor, 39 notification, 40 + }: NotificationContext) => { 41 const notificationData = ntfyDataSchema.parse(JSON.parse(notification.data)); 42 const { name } = monitor; 43 ··· 65 export const sendDegraded = async ({ 66 monitor, 67 notification, 68 + }: NotificationContext) => { 69 const notificationData = ntfyDataSchema.parse(JSON.parse(notification.data)); 70 const { name } = monitor; 71
+3 -1
packages/notifications/opsgenie/package.json
··· 3 "version": "0.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 - "test": "bun test" 7 }, 8 "dependencies": { 9 "@openstatus/db": "workspace:*", 10 "@t3-oss/env-core": "0.13.10", 11 "@types/validator": "13.12.0", 12 "validator": "13.12.0",
··· 3 "version": "0.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 + "test": "bun test", 7 + "tsc": "tsc" 8 }, 9 "dependencies": { 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 12 "@t3-oss/env-core": "0.13.10", 13 "@types/validator": "13.12.0", 14 "validator": "13.12.0",
+24 -8
packages/notifications/opsgenie/src/index.test.ts
··· 46 }), 47 }); 48 49 test("Send Alert with US region", async () => { 50 const monitor = createMockMonitor(); 51 const notification = selectNotificationSchema.parse( 52 createMockNotification("us"), 53 ); 54 55 await sendAlert({ 56 // @ts-expect-error ··· 58 notification, 59 statusCode: 500, 60 message: "Something went wrong", 61 - incidentId: "incident-123", 62 cronTimestamp: Date.now(), 63 }); 64 ··· 71 72 const body = JSON.parse(callArgs[1].body); 73 expect(body.message).toBe("API Health Check is down"); 74 - expect(body.alias).toBe("monitor-1}-incident-123"); 75 expect(body.details.severity).toBe("down"); 76 expect(body.details.status).toBe(500); 77 expect(body.details.message).toBe("Something went wrong"); ··· 82 const notification = selectNotificationSchema.parse( 83 createMockNotification("eu"), 84 ); 85 - 86 await sendAlert({ 87 // @ts-expect-error 88 monitor, 89 notification, 90 statusCode: 500, 91 message: "Error", 92 - incidentId: "incident-456", 93 cronTimestamp: Date.now(), 94 }); 95 ··· 103 const notification = selectNotificationSchema.parse( 104 createMockNotification(), 105 ); 106 - 107 await sendDegraded({ 108 // @ts-expect-error 109 monitor, 110 notification, 111 statusCode: 503, 112 message: "Service degraded", 113 - incidentId: "incident-789", 114 cronTimestamp: Date.now(), 115 }); 116 ··· 118 const callArgs = fetchMock.mock.calls[0]; 119 const body = JSON.parse(callArgs[1].body); 120 expect(body.details.severity).toBe("degraded"); 121 - expect(body.message).toBe("API Health Check is down"); 122 }); 123 124 test("Handle fetch error gracefully", async () => { ··· 130 const notification = selectNotificationSchema.parse( 131 createMockNotification(), 132 ); 133 - 134 expect( 135 sendAlert({ 136 // @ts-expect-error ··· 138 notification, 139 statusCode: 500, 140 message: "Error", 141 cronTimestamp: Date.now(), 142 }), 143 ).rejects.toThrow();
··· 46 }), 47 }); 48 49 + const createMockIncident = () => ({ 50 + id: 1, 51 + title: "API Health Check is down", 52 + summary: "API Health Check is down", 53 + status: "triage" as const, 54 + monitorId: "monitor-1", 55 + workspaceId: 1, 56 + startedAt: Date.now(), 57 + }); 58 + 59 test("Send Alert with US region", async () => { 60 const monitor = createMockMonitor(); 61 const notification = selectNotificationSchema.parse( 62 createMockNotification("us"), 63 ); 64 + const incident = createMockIncident(); 65 66 await sendAlert({ 67 // @ts-expect-error ··· 69 notification, 70 statusCode: 500, 71 message: "Something went wrong", 72 + // @ts-expect-error 73 + incident, 74 cronTimestamp: Date.now(), 75 }); 76 ··· 83 84 const body = JSON.parse(callArgs[1].body); 85 expect(body.message).toBe("API Health Check is down"); 86 + expect(body.alias).toBe("monitor-1"); 87 expect(body.details.severity).toBe("down"); 88 expect(body.details.status).toBe(500); 89 expect(body.details.message).toBe("Something went wrong"); ··· 94 const notification = selectNotificationSchema.parse( 95 createMockNotification("eu"), 96 ); 97 + const incident = createMockIncident(); 98 await sendAlert({ 99 // @ts-expect-error 100 monitor, 101 notification, 102 statusCode: 500, 103 message: "Error", 104 + // @ts-expect-error 105 + incident, 106 cronTimestamp: Date.now(), 107 }); 108 ··· 116 const notification = selectNotificationSchema.parse( 117 createMockNotification(), 118 ); 119 + const incident = createMockIncident(); 120 await sendDegraded({ 121 // @ts-expect-error 122 monitor, 123 notification, 124 statusCode: 503, 125 message: "Service degraded", 126 + // @ts-expect-error 127 + incident, 128 cronTimestamp: Date.now(), 129 }); 130 ··· 132 const callArgs = fetchMock.mock.calls[0]; 133 const body = JSON.parse(callArgs[1].body); 134 expect(body.details.severity).toBe("degraded"); 135 + expect(body.message).toBe("API Health Check is degraded"); 136 }); 137 138 test("Handle fetch error gracefully", async () => { ··· 144 const notification = selectNotificationSchema.parse( 145 createMockNotification(), 146 ); 147 + const incident = createMockIncident(); 148 expect( 149 sendAlert({ 150 // @ts-expect-error ··· 152 notification, 153 statusCode: 500, 154 message: "Error", 155 + // @ts-expect-error 156 + incident, 157 cronTimestamp: Date.now(), 158 }), 159 ).rejects.toThrow();
+10 -42
packages/notifications/opsgenie/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 - import type { Region } from "@openstatus/db/src/schema/constants"; 3 import { OpsGeniePayloadAlert, OpsGenieSchema } from "./schema"; 4 5 export const sendAlert = async ({ ··· 7 notification, 8 statusCode, 9 message, 10 - incidentId, 11 - cronTimestamp, 12 - }: { 13 - monitor: Monitor; 14 - notification: Notification; 15 - statusCode?: number; 16 - message?: string; 17 - incidentId?: string; 18 - cronTimestamp: number; 19 - latency?: number; 20 - region?: Region; 21 - }) => { 22 const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); 23 const { name } = monitor; 24 25 const event = OpsGeniePayloadAlert.parse({ 26 - alias: `${monitor.id}}-${incidentId}`, 27 message: `${name} is down`, 28 description: message, 29 details: { ··· 58 notification, 59 statusCode, 60 message, 61 - incidentId, 62 - }: { 63 - monitor: Monitor; 64 - notification: Notification; 65 - statusCode?: number; 66 - message?: string; 67 - incidentId?: string; 68 - cronTimestamp: number; 69 - latency?: number; 70 - region?: Region; 71 - }) => { 72 const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); 73 const { name } = monitor; 74 75 const event = OpsGeniePayloadAlert.parse({ 76 - alias: `${monitor.id}}-${incidentId}`, 77 - message: `${name} is down`, 78 description: message, 79 details: { 80 message, ··· 107 notification, 108 statusCode, 109 message, 110 - incidentId, 111 - }: { 112 - monitor: Monitor; 113 - notification: Notification; 114 - statusCode?: number; 115 - message?: string; 116 - incidentId?: string; 117 - cronTimestamp: number; 118 - latency?: number; 119 - region?: Region; 120 - }) => { 121 const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); 122 123 const url = 124 opsgenie.region === "eu" 125 - ? `https://api.eu.opsgenie.com/v2/alerts/${monitor.id}}-${incidentId}/close` 126 - : `https://api.opsgenie.com/v2/alerts/${monitor.id}}-${incidentId}/close`; 127 128 const event = OpsGeniePayloadAlert.parse({ 129 - alias: `${monitor.id}}-${incidentId}`, 130 message: `${monitor.name} has recovered`, 131 description: message, 132 details: {
··· 1 + import type { NotificationContext } from "@openstatus/notification-base"; 2 import { OpsGeniePayloadAlert, OpsGenieSchema } from "./schema"; 3 4 export const sendAlert = async ({ ··· 6 notification, 7 statusCode, 8 message, 9 + }: NotificationContext) => { 10 const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); 11 const { name } = monitor; 12 13 const event = OpsGeniePayloadAlert.parse({ 14 + alias: `${monitor.id}`, 15 message: `${name} is down`, 16 description: message, 17 details: { ··· 46 notification, 47 statusCode, 48 message, 49 + }: NotificationContext) => { 50 const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); 51 const { name } = monitor; 52 53 const event = OpsGeniePayloadAlert.parse({ 54 + alias: `${monitor.id}`, 55 + message: `${name} is degraded`, 56 description: message, 57 details: { 58 message, ··· 85 notification, 86 statusCode, 87 message, 88 + }: NotificationContext) => { 89 const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); 90 91 const url = 92 opsgenie.region === "eu" 93 + ? `https://api.eu.opsgenie.com/v2/alerts/${monitor.id}/close` 94 + : `https://api.opsgenie.com/v2/alerts/${monitor.id}/close`; 95 96 const event = OpsGeniePayloadAlert.parse({ 97 + alias: `${monitor.id}`, 98 message: `${monitor.name} has recovered`, 99 description: message, 100 details: {
+3 -1
packages/notifications/pagerduty/package.json
··· 3 "version": "0.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 - "test": "bun test" 7 }, 8 "dependencies": { 9 "@openstatus/db": "workspace:*", 10 "@t3-oss/env-core": "0.13.10", 11 "@types/validator": "13.12.0", 12 "validator": "13.12.0",
··· 3 "version": "0.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 + "test": "bun test", 7 + "tsc": "tsc" 8 }, 9 "dependencies": { 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 12 "@t3-oss/env-core": "0.13.10", 13 "@types/validator": "13.12.0", 14 "validator": "13.12.0",
+28 -6
packages/notifications/pagerduty/src/index.test.ts
··· 31 region: "us-east-1", 32 }); 33 34 const createMockNotification = () => ({ 35 id: 1, 36 name: "PagerDuty Notification", ··· 46 const notification = selectNotificationSchema.parse( 47 createMockNotification(), 48 ); 49 50 await sendAlert({ 51 // @ts-expect-error 52 monitor, 53 notification, 54 statusCode: 500, 55 message: "Something went wrong", 56 - incidentId: "incident-123", 57 cronTimestamp: Date.now(), 58 }); 59 ··· 64 65 const body = JSON.parse(callArgs[1].body); 66 expect(body.routing_key).toBe("my_key"); 67 - expect(body.dedup_key).toBe("monitor-1}-incident-123"); 68 expect(body.event_action).toBe("trigger"); 69 expect(body.payload.summary).toBe("API Health Check is down"); 70 expect(body.payload.severity).toBe("error"); ··· 83 updatedAt: new Date(), 84 data: '{"pagerduty":"{\\"integration_keys\\":[{\\"integration_key\\":\\"key1\\",\\"name\\":\\"Service 1\\",\\"id\\":\\"ABCD\\",\\"type\\":\\"service\\"},{\\"integration_key\\":\\"key2\\",\\"name\\":\\"Service 2\\",\\"id\\":\\"EFGH\\",\\"type\\":\\"service\\"}],\\"account\\":{\\"subdomain\\":\\"test\\",\\"name\\":\\"test\\"}}"}', 85 }); 86 87 await sendAlert({ 88 // @ts-expect-error ··· 90 notification, 91 statusCode: 500, 92 message: "Error", 93 - incidentId: "incident-456", 94 cronTimestamp: Date.now(), 95 }); 96 ··· 104 const notification = selectNotificationSchema.parse( 105 createMockNotification(), 106 ); 107 108 await sendDegraded({ 109 // @ts-expect-error ··· 111 notification, 112 statusCode: 503, 113 message: "Service degraded", 114 cronTimestamp: Date.now(), 115 }); 116 ··· 119 const body = JSON.parse(callArgs[1].body); 120 expect(body.payload.summary).toBe("API Health Check is degraded"); 121 expect(body.payload.severity).toBe("warning"); 122 - expect(body.dedup_key).toBe("monitor-1}"); 123 }); 124 125 test("Send Recovery", async () => { ··· 127 const notification = selectNotificationSchema.parse( 128 createMockNotification(), 129 ); 130 131 await sendRecovery({ 132 // @ts-expect-error ··· 134 notification, 135 statusCode: 200, 136 message: "Service recovered", 137 - incidentId: "incident-123", 138 cronTimestamp: Date.now(), 139 }); 140 ··· 143 expect(callArgs[0]).toBe("https://events.pagerduty.com/v2/enqueue"); 144 const body = JSON.parse(callArgs[1].body); 145 expect(body.routing_key).toBe("my_key"); 146 - expect(body.dedup_key).toBe("monitor-1}-incident-123"); 147 expect(body.event_action).toBe("resolve"); 148 }); 149 ··· 189 const notification = selectNotificationSchema.parse( 190 createMockNotification(), 191 ); 192 193 expect( 194 sendAlert({ ··· 197 notification, 198 statusCode: 500, 199 message: "Error", 200 cronTimestamp: Date.now(), 201 }), 202 ).rejects.toThrow();
··· 31 region: "us-east-1", 32 }); 33 34 + const createMockIncident = () => ({ 35 + id: 1, 36 + title: "API Health Check is down", 37 + summary: "API Health Check is down", 38 + status: "triage" as const, 39 + monitorId: "monitor-1", 40 + workspaceId: 1, 41 + startedAt: Date.now(), 42 + }); 43 + 44 const createMockNotification = () => ({ 45 id: 1, 46 name: "PagerDuty Notification", ··· 56 const notification = selectNotificationSchema.parse( 57 createMockNotification(), 58 ); 59 + const incident = createMockIncident(); 60 61 await sendAlert({ 62 // @ts-expect-error 63 monitor, 64 notification, 65 + // @ts-expect-error 66 + incident, 67 statusCode: 500, 68 message: "Something went wrong", 69 cronTimestamp: Date.now(), 70 }); 71 ··· 76 77 const body = JSON.parse(callArgs[1].body); 78 expect(body.routing_key).toBe("my_key"); 79 + expect(body.dedup_key).toBe("monitor-1"); 80 expect(body.event_action).toBe("trigger"); 81 expect(body.payload.summary).toBe("API Health Check is down"); 82 expect(body.payload.severity).toBe("error"); ··· 95 updatedAt: new Date(), 96 data: '{"pagerduty":"{\\"integration_keys\\":[{\\"integration_key\\":\\"key1\\",\\"name\\":\\"Service 1\\",\\"id\\":\\"ABCD\\",\\"type\\":\\"service\\"},{\\"integration_key\\":\\"key2\\",\\"name\\":\\"Service 2\\",\\"id\\":\\"EFGH\\",\\"type\\":\\"service\\"}],\\"account\\":{\\"subdomain\\":\\"test\\",\\"name\\":\\"test\\"}}"}', 97 }); 98 + const incident = createMockIncident(); 99 100 await sendAlert({ 101 // @ts-expect-error ··· 103 notification, 104 statusCode: 500, 105 message: "Error", 106 + // @ts-expect-error 107 + incident, 108 cronTimestamp: Date.now(), 109 }); 110 ··· 118 const notification = selectNotificationSchema.parse( 119 createMockNotification(), 120 ); 121 + const incident = createMockIncident(); 122 123 await sendDegraded({ 124 // @ts-expect-error ··· 126 notification, 127 statusCode: 503, 128 message: "Service degraded", 129 + // @ts-expect-error 130 + incident, 131 cronTimestamp: Date.now(), 132 }); 133 ··· 136 const body = JSON.parse(callArgs[1].body); 137 expect(body.payload.summary).toBe("API Health Check is degraded"); 138 expect(body.payload.severity).toBe("warning"); 139 + expect(body.dedup_key).toBe("monitor-1"); 140 }); 141 142 test("Send Recovery", async () => { ··· 144 const notification = selectNotificationSchema.parse( 145 createMockNotification(), 146 ); 147 + const incident = createMockIncident(); 148 149 await sendRecovery({ 150 // @ts-expect-error ··· 152 notification, 153 statusCode: 200, 154 message: "Service recovered", 155 + // @ts-expect-error 156 + incident, 157 cronTimestamp: Date.now(), 158 }); 159 ··· 162 expect(callArgs[0]).toBe("https://events.pagerduty.com/v2/enqueue"); 163 const body = JSON.parse(callArgs[1].body); 164 expect(body.routing_key).toBe("my_key"); 165 + expect(body.dedup_key).toBe("monitor-1"); 166 expect(body.event_action).toBe("resolve"); 167 }); 168 ··· 208 const notification = selectNotificationSchema.parse( 209 createMockNotification(), 210 ); 211 + const incident = createMockIncident(); 212 213 expect( 214 sendAlert({ ··· 217 notification, 218 statusCode: 500, 219 message: "Error", 220 + // @ts-expect-error 221 + incident, 222 cronTimestamp: Date.now(), 223 }), 224 ).rejects.toThrow();
+9 -42
packages/notifications/pagerduty/src/index.ts
··· 1 - import { 2 - type Monitor, 3 - type Notification, 4 - pagerdutyDataSchema, 5 - } from "@openstatus/db/src/schema"; 6 - 7 - import type { Region } from "@openstatus/db/src/schema/constants"; 8 import { 9 PagerDutySchema, 10 resolveEventPayloadSchema, ··· 16 notification, 17 statusCode, 18 message, 19 - incidentId, 20 cronTimestamp, 21 - }: { 22 - monitor: Monitor; 23 - notification: Notification; 24 - statusCode?: number; 25 - message?: string; 26 - incidentId?: string; 27 - cronTimestamp: number; 28 - latency?: number; 29 - region?: Region; 30 - }) => { 31 const data = pagerdutyDataSchema.parse(JSON.parse(notification.data)); 32 33 const notificationData = PagerDutySchema.parse(JSON.parse(data.pagerduty)); ··· 38 const { integration_key } = integrationKey; 39 const event = triggerEventPayloadSchema.parse({ 40 routing_key: integration_key, 41 - dedup_key: `${monitor.id}}-${incidentId}`, 42 event_action: "trigger", 43 payload: { 44 summary: `${name} is down`, ··· 67 notification, 68 statusCode, 69 message, 70 - }: { 71 - monitor: Monitor; 72 - notification: Notification; 73 - statusCode?: number; 74 - message?: string; 75 - incidentId?: string; 76 - cronTimestamp: number; 77 - latency?: number; 78 - region?: Region; 79 - }) => { 80 const data = pagerdutyDataSchema.parse(JSON.parse(notification.data)); 81 82 const notificationData = PagerDutySchema.parse(JSON.parse(data.pagerduty)); ··· 87 88 const event = triggerEventPayloadSchema.parse({ 89 routing_key: integration_key, 90 - dedup_key: `${monitor.id}}`, 91 event_action: "trigger", 92 payload: { 93 summary: `${name} is degraded`, ··· 115 export const sendRecovery = async ({ 116 monitor, 117 notification, 118 - incidentId, 119 - }: { 120 - monitor: Monitor; 121 - notification: Notification; 122 - statusCode?: number; 123 - message?: string; 124 - incidentId?: string; 125 - cronTimestamp: number; 126 - latency?: number; 127 - region?: Region; 128 - }) => { 129 const data = pagerdutyDataSchema.parse(JSON.parse(notification.data)); 130 131 const notificationData = PagerDutySchema.parse(JSON.parse(data.pagerduty)); ··· 133 for (const integrationKey of notificationData.integration_keys) { 134 const event = resolveEventPayloadSchema.parse({ 135 routing_key: integrationKey.integration_key, 136 - dedup_key: `${monitor.id}}-${incidentId}`, 137 event_action: "resolve", 138 }); 139 const res = await fetch("https://events.pagerduty.com/v2/enqueue", {
··· 1 + import { pagerdutyDataSchema } from "@openstatus/db/src/schema"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 3 import { 4 PagerDutySchema, 5 resolveEventPayloadSchema, ··· 11 notification, 12 statusCode, 13 message, 14 cronTimestamp, 15 + }: NotificationContext) => { 16 const data = pagerdutyDataSchema.parse(JSON.parse(notification.data)); 17 18 const notificationData = PagerDutySchema.parse(JSON.parse(data.pagerduty)); ··· 23 const { integration_key } = integrationKey; 24 const event = triggerEventPayloadSchema.parse({ 25 routing_key: integration_key, 26 + dedup_key: `${monitor.id}`, 27 event_action: "trigger", 28 payload: { 29 summary: `${name} is down`, ··· 52 notification, 53 statusCode, 54 message, 55 + }: NotificationContext) => { 56 const data = pagerdutyDataSchema.parse(JSON.parse(notification.data)); 57 58 const notificationData = PagerDutySchema.parse(JSON.parse(data.pagerduty)); ··· 63 64 const event = triggerEventPayloadSchema.parse({ 65 routing_key: integration_key, 66 + dedup_key: `${monitor.id}`, 67 event_action: "trigger", 68 payload: { 69 summary: `${name} is degraded`, ··· 91 export const sendRecovery = async ({ 92 monitor, 93 notification, 94 + incident, 95 + }: NotificationContext) => { 96 const data = pagerdutyDataSchema.parse(JSON.parse(notification.data)); 97 98 const notificationData = PagerDutySchema.parse(JSON.parse(data.pagerduty)); ··· 100 for (const integrationKey of notificationData.integration_keys) { 101 const event = resolveEventPayloadSchema.parse({ 102 routing_key: integrationKey.integration_key, 103 + dedup_key: `${monitor.id}`, 104 event_action: "resolve", 105 }); 106 const res = await fetch("https://events.pagerduty.com/v2/enqueue", {
+3 -1
packages/notifications/slack/package.json
··· 3 "version": "0.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 - "test": "bun test" 7 }, 8 "dependencies": { 9 "@openstatus/db": "workspace:*", 10 "zod": "4.1.13" 11 }, 12 "devDependencies": {
··· 3 "version": "0.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 + "test": "bun test", 7 + "tsc": "tsc" 8 }, 9 "dependencies": { 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 12 "zod": "4.1.13" 13 }, 14 "devDependencies": {
+390
packages/notifications/slack/src/blocks.ts
···
··· 1 + import type { FormattedMessageData } from "@openstatus/notification-base"; 2 + 3 + /** 4 + * Slack Block types for rich message formatting 5 + * Reference: https://docs.slack.dev/messaging/formatting-message-text 6 + */ 7 + 8 + interface SlackTextObject { 9 + type: "plain_text" | "mrkdwn"; 10 + text: string; 11 + emoji?: boolean; 12 + } 13 + 14 + interface SlackHeaderBlock { 15 + type: "header"; 16 + text: SlackTextObject; 17 + } 18 + 19 + interface SlackSectionBlock { 20 + type: "section"; 21 + text?: SlackTextObject; 22 + fields?: SlackTextObject[]; 23 + accessory?: SlackButtonElement; 24 + } 25 + 26 + interface SlackDividerBlock { 27 + type: "divider"; 28 + } 29 + 30 + interface SlackActionsBlock { 31 + type: "actions"; 32 + elements: SlackButtonElement[]; 33 + } 34 + 35 + interface SlackButtonElement { 36 + type: "button"; 37 + text: SlackTextObject; 38 + url?: string; 39 + action_id?: string; 40 + } 41 + 42 + type SlackBlock = 43 + | SlackHeaderBlock 44 + | SlackSectionBlock 45 + | SlackDividerBlock 46 + | SlackActionsBlock; 47 + 48 + /** 49 + * Escapes special characters for Slack mrkdwn format 50 + * Reference: https://api.slack.com/reference/surfaces/formatting#escaping 51 + * 52 + * @param text - Text to escape 53 + * @returns Escaped text safe for Slack mrkdwn 54 + * 55 + * @example 56 + * escapeSlackText("Hello & <world>") // "Hello &amp; &lt;world&gt;" 57 + */ 58 + export function escapeSlackText(text: string): string { 59 + return text 60 + .replace(/&/g, "&amp;") 61 + .replace(/</g, "&lt;") 62 + .replace(/>/g, "&gt;"); 63 + } 64 + 65 + /** 66 + * Builds Slack blocks for alert notifications 67 + * 68 + * Layout: 69 + * - Header: "{monitor.name} is failing" 70 + * - Section: "METHOD URL" in code format (e.g., `GET https://api.example.com`) 71 + * - Divider 72 + * - Section: 4 fields in 2x2 grid (Status, Regions, Latency, Cron Timestamp) 73 + * - Section: Error message in code block 74 + * - Actions: Dashboard button 75 + * 76 + * @param data - Formatted message data from buildCommonMessageData 77 + * @returns Array of Slack blocks 78 + * 79 + * @example 80 + * const blocks = buildAlertBlocks({ 81 + * monitorName: "API Health", 82 + * monitorUrl: "https://api.example.com", 83 + * monitorMethod: "GET", 84 + * monitorJobType: "http", 85 + * statusCodeFormatted: "503 Service Unavailable", 86 + * errorMessage: "Connection timeout", 87 + * timestampFormatted: "Jan 22, 2026 at 14:30 UTC", 88 + * regionsDisplay: "iad, fra, syd", 89 + * latencyDisplay: "1,234 ms", 90 + * dashboardUrl: "https://app.openstatus.dev/monitors/123" 91 + * }); 92 + */ 93 + export function buildAlertBlocks(data: FormattedMessageData): SlackBlock[] { 94 + const escapedName = escapeSlackText(data.monitorName); 95 + const escapedError = escapeSlackText(data.errorMessage); 96 + 97 + // Format description as "METHOD URL" or just "URL" for non-HTTP 98 + const description = 99 + data.monitorMethod && data.monitorJobType === "http" 100 + ? `${data.monitorMethod} <${data.monitorUrl}|${data.monitorUrl}>` 101 + : `<${data.monitorUrl}|${data.monitorUrl}>`; 102 + 103 + return [ 104 + { 105 + type: "header", 106 + text: { 107 + type: "plain_text", 108 + text: `${escapedName} is failing`, 109 + emoji: false, 110 + }, 111 + }, 112 + { 113 + type: "section", 114 + text: { 115 + type: "mrkdwn", 116 + text: `\`${description}\``, 117 + }, 118 + }, 119 + { 120 + type: "divider", 121 + }, 122 + { 123 + type: "section", 124 + fields: [ 125 + { 126 + type: "mrkdwn", 127 + text: `*Status*\n${data.statusCodeFormatted}`, 128 + }, 129 + { 130 + type: "mrkdwn", 131 + text: `*Regions*\n${data.regionsDisplay}`, 132 + }, 133 + { 134 + type: "mrkdwn", 135 + text: `*Latency*\n${data.latencyDisplay}`, 136 + }, 137 + { 138 + type: "mrkdwn", 139 + text: `*Cron Timestamp*\n${data.timestampFormatted}`, 140 + }, 141 + ], 142 + }, 143 + { 144 + type: "section", 145 + text: { 146 + type: "mrkdwn", 147 + text: `*Error*\n\`\`\`${escapedError}\`\`\``, 148 + }, 149 + }, 150 + { 151 + type: "actions", 152 + elements: [ 153 + { 154 + type: "button", 155 + text: { 156 + type: "plain_text", 157 + text: "View Dashboard", 158 + emoji: true, 159 + }, 160 + url: data.dashboardUrl, 161 + action_id: "view_dashboard", 162 + }, 163 + ], 164 + }, 165 + ]; 166 + } 167 + 168 + /** 169 + * Builds Slack blocks for recovery notifications 170 + * 171 + * Layout: 172 + * - Header: "{monitor.name} is recovered" 173 + * - Section: "METHOD URL" in code format (e.g., `GET https://api.example.com`) 174 + * - Section: Downtime duration (optional, only if data.incidentDuration exists) 175 + * - Divider 176 + * - Section: 4 fields in 2x2 grid (Status, Regions, Latency, Cron Timestamp) 177 + * - Actions: Dashboard button 178 + * 179 + * @param data - Formatted message data from buildCommonMessageData 180 + * @returns Array of Slack blocks 181 + * 182 + * @example 183 + * const blocks = buildRecoveryBlocks({ 184 + * monitorName: "API Health", 185 + * monitorUrl: "https://api.example.com", 186 + * monitorMethod: "GET", 187 + * monitorJobType: "http", 188 + * statusCodeFormatted: "200 OK", 189 + * errorMessage: "", 190 + * timestampFormatted: "Jan 22, 2026 at 14:35 UTC", 191 + * regionsDisplay: "iad, fra, syd", 192 + * latencyDisplay: "156 ms", 193 + * dashboardUrl: "https://app.openstatus.dev/monitors/123", 194 + * incidentDuration: "5m 30s" 195 + * }); 196 + */ 197 + export function buildRecoveryBlocks(data: FormattedMessageData): SlackBlock[] { 198 + const escapedName = escapeSlackText(data.monitorName); 199 + 200 + // Format description as "METHOD URL" or just "URL" for non-HTTP 201 + const description = 202 + data.monitorMethod && data.monitorJobType === "http" 203 + ? `${data.monitorMethod} <${data.monitorUrl}|${data.monitorUrl}>` 204 + : `<${data.monitorUrl}|${data.monitorUrl}>`; 205 + 206 + const blocks: SlackBlock[] = [ 207 + { 208 + type: "header", 209 + text: { 210 + type: "plain_text", 211 + text: `${escapedName} is recovered`, 212 + emoji: false, 213 + }, 214 + }, 215 + { 216 + type: "section", 217 + text: { 218 + type: "mrkdwn", 219 + text: `\`${description}\``, 220 + }, 221 + }, 222 + ]; 223 + 224 + // Only include downtime if incident duration is available 225 + if (data.incidentDuration) { 226 + blocks.push({ 227 + type: "section", 228 + text: { 229 + type: "mrkdwn", 230 + text: `⏱️ *Downtime:* ${data.incidentDuration}`, 231 + }, 232 + }); 233 + } 234 + 235 + blocks.push( 236 + { 237 + type: "divider", 238 + }, 239 + { 240 + type: "section", 241 + fields: [ 242 + { 243 + type: "mrkdwn", 244 + text: `*Status*\n${data.statusCodeFormatted}`, 245 + }, 246 + { 247 + type: "mrkdwn", 248 + text: `*Regions*\n${data.regionsDisplay}`, 249 + }, 250 + { 251 + type: "mrkdwn", 252 + text: `*Latency*\n${data.latencyDisplay}`, 253 + }, 254 + { 255 + type: "mrkdwn", 256 + text: `*Cron Timestamp*\n${data.timestampFormatted}`, 257 + }, 258 + ], 259 + }, 260 + { 261 + type: "actions", 262 + elements: [ 263 + { 264 + type: "button", 265 + text: { 266 + type: "plain_text", 267 + text: "View Dashboard", 268 + emoji: true, 269 + }, 270 + url: data.dashboardUrl, 271 + action_id: "view_dashboard", 272 + }, 273 + ], 274 + }, 275 + ); 276 + 277 + return blocks; 278 + } 279 + 280 + /** 281 + * Builds Slack blocks for degraded notifications 282 + * 283 + * Layout: 284 + * - Header: "{monitor.name} is degraded" 285 + * - Section: "METHOD URL" in code format (e.g., `GET https://api.example.com`) 286 + * - Section: Previous incident duration (optional, only if data.incidentDuration exists) 287 + * - Divider 288 + * - Section: 4 fields in 2x2 grid (Status, Regions, Latency, Cron Timestamp) 289 + * - Actions: Dashboard button 290 + * 291 + * @param data - Formatted message data from buildCommonMessageData 292 + * @returns Array of Slack blocks 293 + * 294 + * @example 295 + * const blocks = buildDegradedBlocks({ 296 + * monitorName: "API Health", 297 + * monitorUrl: "https://api.example.com", 298 + * monitorMethod: "GET", 299 + * monitorJobType: "http", 300 + * statusCodeFormatted: "504 Gateway Timeout", 301 + * errorMessage: "Slow response", 302 + * timestampFormatted: "Jan 22, 2026 at 14:40 UTC", 303 + * regionsDisplay: "iad, fra, syd", 304 + * latencyDisplay: "5,234 ms", 305 + * dashboardUrl: "https://app.openstatus.dev/monitors/123", 306 + * incidentDuration: "2h 15m" 307 + * }); 308 + */ 309 + export function buildDegradedBlocks(data: FormattedMessageData): SlackBlock[] { 310 + const escapedName = escapeSlackText(data.monitorName); 311 + 312 + // Format description as "METHOD URL" or just "URL" for non-HTTP 313 + const description = 314 + data.monitorMethod && data.monitorJobType === "http" 315 + ? `${data.monitorMethod} <${data.monitorUrl}|${data.monitorUrl}>` 316 + : `<${data.monitorUrl}|${data.monitorUrl}>`; 317 + 318 + const blocks: SlackBlock[] = [ 319 + { 320 + type: "header", 321 + text: { 322 + type: "plain_text", 323 + text: `${escapedName} is degraded`, 324 + emoji: false, 325 + }, 326 + }, 327 + { 328 + type: "section", 329 + text: { 330 + type: "mrkdwn", 331 + text: `\`${description}\``, 332 + }, 333 + }, 334 + ]; 335 + 336 + // Only include previous incident duration if available 337 + if (data.incidentDuration) { 338 + blocks.push({ 339 + type: "section", 340 + text: { 341 + type: "mrkdwn", 342 + text: `⏱️ *Previous Incident Duration:* ${data.incidentDuration}`, 343 + }, 344 + }); 345 + } 346 + 347 + blocks.push( 348 + { 349 + type: "divider", 350 + }, 351 + { 352 + type: "section", 353 + fields: [ 354 + { 355 + type: "mrkdwn", 356 + text: `*Status*\n${data.statusCodeFormatted}`, 357 + }, 358 + { 359 + type: "mrkdwn", 360 + text: `*Regions*\n${data.regionsDisplay}`, 361 + }, 362 + { 363 + type: "mrkdwn", 364 + text: `*Latency*\n${data.latencyDisplay}`, 365 + }, 366 + { 367 + type: "mrkdwn", 368 + text: `*Cron Timestamp*\n${data.timestampFormatted}`, 369 + }, 370 + ], 371 + }, 372 + { 373 + type: "actions", 374 + elements: [ 375 + { 376 + type: "button", 377 + text: { 378 + type: "plain_text", 379 + text: "View Dashboard", 380 + emoji: true, 381 + }, 382 + url: data.dashboardUrl, 383 + action_id: "view_dashboard", 384 + }, 385 + ], 386 + }, 387 + ); 388 + 389 + return blocks; 390 + }
+21 -21
packages/notifications/slack/src/index.test.ts
··· 1 import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; 2 import { selectNotificationSchema } from "@openstatus/db/src/schema"; 3 import { 4 sendAlert, 5 sendDegraded, ··· 67 expect(callArgs[1].method).toBe("POST"); 68 69 const body = JSON.parse(callArgs[1].body); 70 - expect(body.blocks).toBeDefined(); 71 - expect(body.blocks.length).toBeGreaterThan(0); 72 - expect(body.blocks[1].text.text).toContain("🚨 Alert"); 73 - expect(body.blocks[1].text.text).toContain("API Health Check"); 74 - expect(body.blocks[1].text.text).toContain("Something went wrong"); 75 }); 76 77 test("Send Alert without statusCode", async () => { ··· 91 expect(fetchMock).toHaveBeenCalledTimes(1); 92 const callArgs = fetchMock.mock.calls[0]; 93 const body = JSON.parse(callArgs[1].body); 94 - expect(body.blocks[1].text.text).toContain("_empty_"); 95 }); 96 97 test("Send Recovery", async () => { ··· 112 expect(fetchMock).toHaveBeenCalledTimes(1); 113 const callArgs = fetchMock.mock.calls[0]; 114 const body = JSON.parse(callArgs[1].body); 115 - expect(body.blocks[1].text.text).toContain("✅ Recovered"); 116 - expect(body.blocks[1].text.text).toContain("API Health Check"); 117 }); 118 119 test("Send Degraded", async () => { ··· 134 expect(fetchMock).toHaveBeenCalledTimes(1); 135 const callArgs = fetchMock.mock.calls[0]; 136 const body = JSON.parse(callArgs[1].body); 137 - expect(body.blocks[1].text.text).toContain("⚠️ Degraded"); 138 - expect(body.blocks[1].text.text).toContain("API Health Check"); 139 }); 140 141 test("Send Test Slack Message", async () => { 142 const webhookUrl = "https://hooks.slack.com/services/test/url"; 143 144 - const result = await sendTestSlackMessage(webhookUrl); 145 146 - expect(result).toBe(true); 147 expect(fetchMock).toHaveBeenCalledTimes(1); 148 const callArgs = fetchMock.mock.calls[0]; 149 expect(callArgs[0]).toBe(webhookUrl); 150 151 const body = JSON.parse(callArgs[1].body); 152 - expect(body.blocks[1].text.text).toContain("🧪 Test"); 153 - expect(body.blocks[1].text.text).toContain("OpenStatus"); 154 }); 155 156 - test("Send Test Slack Message returns false on error", async () => { 157 fetchMock.mockImplementation(() => 158 Promise.reject(new Error("Network error")), 159 ); 160 161 - const result = await sendTestSlackMessage( 162 - "https://hooks.slack.com/services/test/url", 163 - ); 164 - 165 - expect(result).toBe(false); 166 - expect(fetchMock).toHaveBeenCalledTimes(1); 167 }); 168 169 test("Handle fetch error gracefully", async () => {
··· 1 import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; 2 import { selectNotificationSchema } from "@openstatus/db/src/schema"; 3 + import { COLORS } from "@openstatus/notification-base"; 4 import { 5 sendAlert, 6 sendDegraded, ··· 68 expect(callArgs[1].method).toBe("POST"); 69 70 const body = JSON.parse(callArgs[1].body); 71 + expect(body.attachments).toBeDefined(); 72 + expect(body.attachments[0].color).toBe(COLORS.red); 73 + expect(body.attachments[0].blocks).toBeDefined(); 74 + expect(body.attachments[0].blocks.length).toBeGreaterThan(0); 75 + expect(body.attachments[0].blocks[0].text.text).toContain("is failing"); 76 }); 77 78 test("Send Alert without statusCode", async () => { ··· 92 expect(fetchMock).toHaveBeenCalledTimes(1); 93 const callArgs = fetchMock.mock.calls[0]; 94 const body = JSON.parse(callArgs[1].body); 95 + expect(body.attachments[0].color).toBe(COLORS.red); 96 + expect(body.attachments[0].blocks[3].fields[0].text).toContain("Unknown"); 97 }); 98 99 test("Send Recovery", async () => { ··· 114 expect(fetchMock).toHaveBeenCalledTimes(1); 115 const callArgs = fetchMock.mock.calls[0]; 116 const body = JSON.parse(callArgs[1].body); 117 + expect(body.attachments).toBeDefined(); 118 + expect(body.attachments[0].color).toBe(COLORS.green); 119 + expect(body.attachments[0].blocks[0].text.text).toContain("is recovered"); 120 }); 121 122 test("Send Degraded", async () => { ··· 137 expect(fetchMock).toHaveBeenCalledTimes(1); 138 const callArgs = fetchMock.mock.calls[0]; 139 const body = JSON.parse(callArgs[1].body); 140 + expect(body.attachments).toBeDefined(); 141 + expect(body.attachments[0].color).toBe(COLORS.yellow); 142 + expect(body.attachments[0].blocks[0].text.text).toContain("is degraded"); 143 }); 144 145 test("Send Test Slack Message", async () => { 146 const webhookUrl = "https://hooks.slack.com/services/test/url"; 147 148 + await sendTestSlackMessage(webhookUrl); 149 150 expect(fetchMock).toHaveBeenCalledTimes(1); 151 const callArgs = fetchMock.mock.calls[0]; 152 expect(callArgs[0]).toBe(webhookUrl); 153 154 const body = JSON.parse(callArgs[1].body); 155 + expect(body.attachments[0].blocks[0].text.text).toContain( 156 + "Test Notification", 157 + ); 158 }); 159 160 + test("Send Test Slack Message throws error on empty webhookUrl", async () => { 161 fetchMock.mockImplementation(() => 162 Promise.reject(new Error("Network error")), 163 ); 164 165 + expect(sendTestSlackMessage("")).rejects.toThrow(); 166 + expect(fetchMock).toHaveBeenCalledTimes(0); 167 }); 168 169 test("Handle fetch error gracefully", async () => {
+140 -132
packages/notifications/slack/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 import { slackDataSchema } from "@openstatus/db/src/schema"; 3 - import type { Region } from "@openstatus/db/src/schema/constants"; 4 5 // biome-ignore lint/suspicious/noExplicitAny: <explanation> 6 const postToWebhook = async (body: any, webhookUrl: string) => { 7 const res = await fetch(webhookUrl, { 8 method: "POST", 9 body: JSON.stringify(body), ··· 18 notification, 19 statusCode, 20 message, 21 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 22 - incidentId, 23 cronTimestamp, 24 - }: { 25 - monitor: Monitor; 26 - notification: Notification; 27 - statusCode?: number; 28 - message?: string; 29 - incidentId?: string; 30 - cronTimestamp: number; 31 - latency?: number; 32 - region?: Region; 33 - }) => { 34 const notificationData = slackDataSchema.parse(JSON.parse(notification.data)); 35 - const { slack: webhookUrl } = notificationData; // webhook url 36 - const { name } = monitor; 37 38 await postToWebhook( 39 { 40 - blocks: [ 41 - { 42 - type: "divider", 43 - }, 44 { 45 - type: "section", 46 - text: { 47 - type: "mrkdwn", 48 - text: ` 49 - *🚨 Alert <${monitor.url}|${name}>*\n\n 50 - Status Code: ${statusCode || "_empty_"}\n 51 - Message: ${message || "_empty_"}\n 52 - Cron Timestamp: ${cronTimestamp} (${new Date(cronTimestamp).toISOString()}) 53 - `, 54 - }, 55 - }, 56 - { 57 - type: "context", 58 - elements: [ 59 - { 60 - type: "mrkdwn", 61 - text: "Check your <https://www.openstatus.dev/app|Dashboard>.", 62 - }, 63 - ], 64 }, 65 ], 66 }, ··· 71 export const sendRecovery = async ({ 72 monitor, 73 notification, 74 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 75 statusCode, 76 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 77 message, 78 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 79 - incidentId, 80 - }: { 81 - monitor: Monitor; 82 - notification: Notification; 83 - statusCode?: number; 84 - message?: string; 85 - incidentId?: string; 86 - cronTimestamp: number; 87 - region?: Region; 88 - latency?: number; 89 - }) => { 90 const notificationData = slackDataSchema.parse(JSON.parse(notification.data)); 91 - const { slack: webhookUrl } = notificationData; // webhook url 92 - const { name } = monitor; 93 94 await postToWebhook( 95 { 96 - blocks: [ 97 - { 98 - type: "divider", 99 - }, 100 - { 101 - type: "section", 102 - text: { 103 - type: "mrkdwn", 104 - text: `*✅ Recovered <${monitor.url}/|${name}>*`, 105 - }, 106 - }, 107 { 108 - type: "context", 109 - elements: [ 110 - { 111 - type: "mrkdwn", 112 - text: "Check your <https://www.openstatus.dev/app|Dashboard>.", 113 - }, 114 - ], 115 }, 116 ], 117 }, ··· 122 export const sendDegraded = async ({ 123 monitor, 124 notification, 125 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 126 statusCode, 127 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 128 message, 129 - }: { 130 - monitor: Monitor; 131 - notification: Notification; 132 - statusCode?: number; 133 - message?: string; 134 - cronTimestamp: number; 135 - region?: Region; 136 - latency?: number; 137 - }) => { 138 const notificationData = slackDataSchema.parse(JSON.parse(notification.data)); 139 - const { slack: webhookUrl } = notificationData; // webhook url 140 - const { name } = monitor; 141 142 await postToWebhook( 143 { 144 - blocks: [ 145 - { 146 - type: "divider", 147 - }, 148 - { 149 - type: "section", 150 - text: { 151 - type: "mrkdwn", 152 - text: `*⚠️ Degraded <${monitor.url}/|${name}>*`, 153 - }, 154 - }, 155 { 156 - type: "context", 157 - elements: [ 158 - { 159 - type: "mrkdwn", 160 - text: "Check your <https://www.openstatus.dev/app|Dashboard>.", 161 - }, 162 - ], 163 }, 164 ], 165 }, ··· 168 }; 169 170 export const sendTestSlackMessage = async (webhookUrl: string) => { 171 - try { 172 - await postToWebhook( 173 - { 174 - blocks: [ 175 - { 176 - type: "divider", 177 - }, 178 - { 179 - type: "section", 180 - text: { 181 - type: "mrkdwn", 182 - text: "*🧪 Test <https://www.openstatus.dev/|OpenStatus>*\n\nIf you can read this, your Slack webhook is functioning correctly!", 183 }, 184 - }, 185 - { 186 - type: "context", 187 - elements: [ 188 - { 189 type: "mrkdwn", 190 - text: "Check your <https://www.openstatus.dev/app|Dashboard>.", 191 }, 192 - ], 193 - }, 194 - ], 195 - }, 196 - webhookUrl, 197 - ); 198 - return true; 199 - } catch (_err) { 200 - return false; 201 - } 202 };
··· 1 import { slackDataSchema } from "@openstatus/db/src/schema"; 2 + import { 3 + COLORS, 4 + type NotificationContext, 5 + buildCommonMessageData, 6 + } from "@openstatus/notification-base"; 7 + import { 8 + buildAlertBlocks, 9 + buildDegradedBlocks, 10 + buildRecoveryBlocks, 11 + } from "./blocks"; 12 13 // biome-ignore lint/suspicious/noExplicitAny: <explanation> 14 const postToWebhook = async (body: any, webhookUrl: string) => { 15 + if (!webhookUrl || webhookUrl.trim() === "") { 16 + throw new Error("Slack webhook URL is required"); 17 + } 18 + 19 const res = await fetch(webhookUrl, { 20 method: "POST", 21 body: JSON.stringify(body), ··· 30 notification, 31 statusCode, 32 message, 33 cronTimestamp, 34 + latency, 35 + regions, 36 + }: NotificationContext) => { 37 const notificationData = slackDataSchema.parse(JSON.parse(notification.data)); 38 + const { slack: webhookUrl } = notificationData; 39 + 40 + const context = { 41 + monitor, 42 + notification, 43 + statusCode, 44 + message, 45 + cronTimestamp, 46 + latency, 47 + regions, 48 + }; 49 + 50 + const data = buildCommonMessageData(context); 51 + const blocks = buildAlertBlocks(data); 52 53 await postToWebhook( 54 { 55 + attachments: [ 56 { 57 + color: COLORS.red, 58 + blocks, 59 }, 60 ], 61 }, ··· 66 export const sendRecovery = async ({ 67 monitor, 68 notification, 69 statusCode, 70 message, 71 + incident, 72 + cronTimestamp, 73 + regions, 74 + latency, 75 + }: NotificationContext) => { 76 const notificationData = slackDataSchema.parse(JSON.parse(notification.data)); 77 + const { slack: webhookUrl } = notificationData; 78 + 79 + const context = { 80 + monitor, 81 + notification, 82 + statusCode, 83 + message, 84 + cronTimestamp, 85 + latency, 86 + regions, 87 + }; 88 + 89 + const data = buildCommonMessageData(context, { incident }); 90 + const blocks = buildRecoveryBlocks(data); 91 92 await postToWebhook( 93 { 94 + attachments: [ 95 { 96 + color: COLORS.green, 97 + blocks, 98 }, 99 ], 100 }, ··· 105 export const sendDegraded = async ({ 106 monitor, 107 notification, 108 statusCode, 109 message, 110 + incident, 111 + cronTimestamp, 112 + regions, 113 + latency, 114 + }: NotificationContext) => { 115 const notificationData = slackDataSchema.parse(JSON.parse(notification.data)); 116 + const { slack: webhookUrl } = notificationData; 117 + 118 + const context = { 119 + monitor, 120 + notification, 121 + statusCode, 122 + message, 123 + cronTimestamp, 124 + latency, 125 + regions, 126 + }; 127 + 128 + const data = buildCommonMessageData(context, { incident }); 129 + const blocks = buildDegradedBlocks(data); 130 131 await postToWebhook( 132 { 133 + attachments: [ 134 { 135 + color: COLORS.yellow, 136 + blocks, 137 }, 138 ], 139 }, ··· 142 }; 143 144 export const sendTestSlackMessage = async (webhookUrl: string) => { 145 + await postToWebhook( 146 + { 147 + attachments: [ 148 + { 149 + color: COLORS.green, 150 + blocks: [ 151 + { 152 + type: "header", 153 + text: { 154 + type: "plain_text", 155 + text: "Test Notification", 156 + emoji: false, 157 + }, 158 }, 159 + { 160 + type: "section", 161 + text: { 162 type: "mrkdwn", 163 + text: "`🧪 Your Slack webhook is configured correctly!`", 164 }, 165 + }, 166 + { 167 + type: "divider", 168 + }, 169 + { 170 + type: "section", 171 + fields: [ 172 + { 173 + type: "mrkdwn", 174 + text: "*Status*\nWebhook Connected", 175 + }, 176 + { 177 + type: "mrkdwn", 178 + text: "*Type*\nTest Notification", 179 + }, 180 + ], 181 + }, 182 + { 183 + type: "section", 184 + text: { 185 + type: "mrkdwn", 186 + text: "*Next Steps*\nYou will receive notifications here when your monitors trigger fail, recover, or degrades.", 187 + }, 188 + }, 189 + { 190 + type: "actions", 191 + elements: [ 192 + { 193 + type: "button", 194 + text: { 195 + type: "plain_text", 196 + text: "View Dashboard", 197 + emoji: true, 198 + }, 199 + url: "https://app.openstatus.dev", 200 + action_id: "view_dashboard", 201 + }, 202 + ], 203 + }, 204 + ], 205 + }, 206 + ], 207 + }, 208 + webhookUrl, 209 + ); 210 };
+3 -2
packages/notifications/slack/src/mock.ts
··· 29 deletedAt: null, 30 otelEndpoint: null, 31 otelHeaders: [], 32 - retry: null, 33 - followRedirects: null, 34 }; 35 36 const notification: Notification = {
··· 29 deletedAt: null, 30 otelEndpoint: null, 31 otelHeaders: [], 32 + retry: 3, 33 + followRedirects: false, 34 + externalName: null, 35 }; 36 37 const notification: Notification = {
+3 -1
packages/notifications/telegram/package.json
··· 3 "version": "1.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 - "test": "bun test" 7 }, 8 "dependencies": { 9 "@openstatus/db": "workspace:*", 10 "zod": "4.1.13" 11 }, 12 "devDependencies": {
··· 3 "version": "1.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 + "test": "bun test", 7 + "tsc": "tsc" 8 }, 9 "dependencies": { 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 12 "zod": "4.1.13" 13 }, 14 "devDependencies": {
+4 -45
packages/notifications/telegram/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 import { telegramDataSchema } from "@openstatus/db/src/schema"; 3 - 4 - import type { Region } from "@openstatus/db/src/schema/constants"; 5 6 export const sendAlert = async ({ 7 monitor, 8 notification, 9 statusCode, 10 message, 11 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 12 - incidentId, 13 - }: { 14 - monitor: Monitor; 15 - notification: Notification; 16 - statusCode?: number; 17 - message?: string; 18 - incidentId?: string; 19 - cronTimestamp: number; 20 - latency?: number; 21 - region?: Region; 22 - }) => { 23 const notificationData = telegramDataSchema.parse( 24 JSON.parse(notification.data), 25 ); ··· 38 export const sendRecovery = async ({ 39 monitor, 40 notification, 41 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 42 - statusCode, 43 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 44 - message, 45 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 46 - incidentId, 47 - }: { 48 - monitor: Monitor; 49 - notification: Notification; 50 - statusCode?: number; 51 - message?: string; 52 - incidentId?: string; 53 - cronTimestamp: number; 54 - latency?: number; 55 - region?: Region; 56 - }) => { 57 const notificationData = telegramDataSchema.parse( 58 JSON.parse(notification.data), 59 ); ··· 69 export const sendDegraded = async ({ 70 monitor, 71 notification, 72 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 73 - statusCode, 74 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 75 - message, 76 - }: { 77 - monitor: Monitor; 78 - notification: Notification; 79 - statusCode?: number; 80 - message?: string; 81 - incidentId?: string; 82 - cronTimestamp: number; 83 - latency?: number; 84 - region?: Region; 85 - }) => { 86 const notificationData = telegramDataSchema.parse( 87 JSON.parse(notification.data), 88 );
··· 1 import { telegramDataSchema } from "@openstatus/db/src/schema"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 3 4 export const sendAlert = async ({ 5 monitor, 6 notification, 7 statusCode, 8 message, 9 + }: NotificationContext) => { 10 const notificationData = telegramDataSchema.parse( 11 JSON.parse(notification.data), 12 ); ··· 25 export const sendRecovery = async ({ 26 monitor, 27 notification, 28 + }: NotificationContext) => { 29 const notificationData = telegramDataSchema.parse( 30 JSON.parse(notification.data), 31 ); ··· 41 export const sendDegraded = async ({ 42 monitor, 43 notification, 44 + }: NotificationContext) => { 45 const notificationData = telegramDataSchema.parse( 46 JSON.parse(notification.data), 47 );
+3 -1
packages/notifications/twillio-sms/package.json
··· 3 "version": "0.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 - "test": "bun test" 7 }, 8 "dependencies": { 9 "@openstatus/db": "workspace:*", 10 "@t3-oss/env-core": "0.13.10", 11 "validator": "13.12.0", 12 "zod": "4.1.13"
··· 3 "version": "0.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 + "test": "bun test", 7 + "tsc": "tsc" 8 }, 9 "dependencies": { 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 12 "@t3-oss/env-core": "0.13.10", 13 "validator": "13.12.0", 14 "zod": "4.1.13"
+4 -45
packages/notifications/twillio-sms/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 - 3 import { phoneDataSchema } from "@openstatus/db/src/schema"; 4 - import type { Region } from "@openstatus/db/src/schema/constants"; 5 import { env } from "./env"; 6 7 export const sendAlert = async ({ ··· 9 notification, 10 statusCode, 11 message, 12 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 13 - incidentId, 14 - }: { 15 - monitor: Monitor; 16 - notification: Notification; 17 - statusCode?: number; 18 - message?: string; 19 - incidentId?: string; 20 - cronTimestamp: number; 21 - latency?: number; 22 - region?: Region; 23 - }) => { 24 const notificationData = phoneDataSchema.parse(JSON.parse(notification.data)); 25 const { name } = monitor; 26 ··· 54 export const sendRecovery = async ({ 55 monitor, 56 notification, 57 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 58 - statusCode, 59 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 60 - message, 61 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 62 - incidentId, 63 - }: { 64 - monitor: Monitor; 65 - notification: Notification; 66 - statusCode?: number; 67 - message?: string; 68 - incidentId?: string; 69 - cronTimestamp: number; 70 - latency?: number; 71 - region?: Region; 72 - }) => { 73 const notificationData = phoneDataSchema.parse(JSON.parse(notification.data)); 74 const { name } = monitor; 75 ··· 98 export const sendDegraded = async ({ 99 monitor, 100 notification, 101 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 102 - statusCode, 103 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 104 - message, 105 - }: { 106 - monitor: Monitor; 107 - notification: Notification; 108 - statusCode?: number; 109 - message?: string; 110 - incidentId?: string; 111 - cronTimestamp: number; 112 - latency?: number; 113 - region?: Region; 114 - }) => { 115 const notificationData = phoneDataSchema.parse(JSON.parse(notification.data)); 116 const { name } = monitor; 117
··· 1 import { phoneDataSchema } from "@openstatus/db/src/schema"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 3 import { env } from "./env"; 4 5 export const sendAlert = async ({ ··· 7 notification, 8 statusCode, 9 message, 10 + }: NotificationContext) => { 11 const notificationData = phoneDataSchema.parse(JSON.parse(notification.data)); 12 const { name } = monitor; 13 ··· 41 export const sendRecovery = async ({ 42 monitor, 43 notification, 44 + }: NotificationContext) => { 45 const notificationData = phoneDataSchema.parse(JSON.parse(notification.data)); 46 const { name } = monitor; 47 ··· 70 export const sendDegraded = async ({ 71 monitor, 72 notification, 73 + }: NotificationContext) => { 74 const notificationData = phoneDataSchema.parse(JSON.parse(notification.data)); 75 const { name } = monitor; 76
+3 -1
packages/notifications/twillio-whatsapp/package.json
··· 3 "version": "0.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 - "test": "bun test" 7 }, 8 "dependencies": { 9 "@openstatus/db": "workspace:*", 10 "@t3-oss/env-core": "0.13.10", 11 "validator": "13.12.0", 12 "zod": "4.1.13"
··· 3 "version": "0.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 + "test": "bun test", 7 + "tsc": "tsc" 8 }, 9 "dependencies": { 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 12 "@t3-oss/env-core": "0.13.10", 13 "validator": "13.12.0", 14 "zod": "4.1.13"
+4 -46
packages/notifications/twillio-whatsapp/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 import { whatsappDataSchema } from "@openstatus/db/src/schema"; 3 - import type { Region } from "@openstatus/db/src/schema/constants"; 4 import { env } from "./env"; 5 6 export const sendAlert = async ({ 7 monitor, 8 notification, 9 - statusCode, 10 - message, 11 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 12 - incidentId, 13 - }: { 14 - monitor: Monitor; 15 - notification: Notification; 16 - statusCode?: number; 17 - message?: string; 18 - incidentId?: string; 19 - cronTimestamp: number; 20 - latency?: number; 21 - region?: Region; 22 - }) => { 23 const notificationData = whatsappDataSchema.parse( 24 JSON.parse(notification.data), 25 ); ··· 51 export const sendRecovery = async ({ 52 monitor, 53 notification, 54 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 55 - statusCode, 56 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 57 - message, 58 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 59 - incidentId, 60 - }: { 61 - monitor: Monitor; 62 - notification: Notification; 63 - statusCode?: number; 64 - message?: string; 65 - incidentId?: string; 66 - cronTimestamp: number; 67 - latency?: number; 68 - region?: Region; 69 - }) => { 70 const notificationData = whatsappDataSchema.parse( 71 JSON.parse(notification.data), 72 ); ··· 98 export const sendDegraded = async ({ 99 monitor, 100 notification, 101 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 102 - statusCode, 103 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 104 - message, 105 - }: { 106 - monitor: Monitor; 107 - notification: Notification; 108 - statusCode?: number; 109 - message?: string; 110 - incidentId?: string; 111 - cronTimestamp: number; 112 - latency?: number; 113 - region?: Region; 114 - }) => { 115 const notificationData = whatsappDataSchema.parse( 116 JSON.parse(notification.data), 117 );
··· 1 import { whatsappDataSchema } from "@openstatus/db/src/schema"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 3 import { env } from "./env"; 4 5 export const sendAlert = async ({ 6 monitor, 7 notification, 8 + }: NotificationContext) => { 9 const notificationData = whatsappDataSchema.parse( 10 JSON.parse(notification.data), 11 ); ··· 37 export const sendRecovery = async ({ 38 monitor, 39 notification, 40 + }: NotificationContext) => { 41 const notificationData = whatsappDataSchema.parse( 42 JSON.parse(notification.data), 43 ); ··· 69 export const sendDegraded = async ({ 70 monitor, 71 notification, 72 + }: NotificationContext) => { 73 const notificationData = whatsappDataSchema.parse( 74 JSON.parse(notification.data), 75 );
+3 -1
packages/notifications/webhook/package.json
··· 3 "version": "1.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 - "test": "bun test" 7 }, 8 "dependencies": { 9 "@openstatus/db": "workspace:*", 10 "@openstatus/utils": "workspace:*", 11 "zod": "4.1.13" 12 },
··· 3 "version": "1.0.0", 4 "main": "src/index.ts", 5 "scripts": { 6 + "test": "bun test", 7 + "tsc": "tsc" 8 }, 9 "dependencies": { 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 12 "@openstatus/utils": "workspace:*", 13 "zod": "4.1.13" 14 },
+6 -39
packages/notifications/webhook/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 - 3 - import type { Region } from "@openstatus/db/src/schema/constants"; 4 import { transformHeaders } from "@openstatus/utils"; 5 import { PayloadSchema, WebhookSchema } from "./schema"; 6 ··· 11 statusCode, 12 latency, 13 message, 14 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 15 - incidentId, 16 - }: { 17 - monitor: Monitor; 18 - notification: Notification; 19 - statusCode?: number; 20 - message?: string; 21 - incidentId?: string; 22 - cronTimestamp: number; 23 - latency?: number; 24 - region?: Region; 25 - }) => { 26 const notificationData = WebhookSchema.parse(JSON.parse(notification.data)); 27 28 const body = PayloadSchema.parse({ ··· 55 latency, 56 statusCode, 57 message, 58 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 59 - incidentId, 60 - }: { 61 - monitor: Monitor; 62 - notification: Notification; 63 - statusCode?: number; 64 - message?: string; 65 - incidentId?: string; 66 - cronTimestamp: number; 67 - latency?: number; 68 - region?: Region; 69 - }) => { 70 const notificationData = WebhookSchema.parse(JSON.parse(notification.data)); 71 72 const body = PayloadSchema.parse({ ··· 88 }, 89 }); 90 if (!res.ok) { 91 - throw new Error(`Failed to send SMS: ${res.statusText}`); 92 } 93 }; 94 ··· 99 latency, 100 statusCode, 101 message, 102 - }: { 103 - monitor: Monitor; 104 - notification: Notification; 105 - statusCode?: number; 106 - message?: string; 107 - incidentId?: string; 108 - cronTimestamp: number; 109 - latency?: number; 110 - region?: Region; 111 - }) => { 112 const notificationData = WebhookSchema.parse(JSON.parse(notification.data)); 113 114 const body = PayloadSchema.parse({ ··· 130 }, 131 }); 132 if (!res.ok) { 133 - throw new Error(`Failed to send SMS: ${res.statusText}`); 134 } 135 }; 136
··· 1 + import type { NotificationContext } from "@openstatus/notification-base"; 2 import { transformHeaders } from "@openstatus/utils"; 3 import { PayloadSchema, WebhookSchema } from "./schema"; 4 ··· 9 statusCode, 10 latency, 11 message, 12 + }: NotificationContext) => { 13 const notificationData = WebhookSchema.parse(JSON.parse(notification.data)); 14 15 const body = PayloadSchema.parse({ ··· 42 latency, 43 statusCode, 44 message, 45 + }: NotificationContext) => { 46 const notificationData = WebhookSchema.parse(JSON.parse(notification.data)); 47 48 const body = PayloadSchema.parse({ ··· 64 }, 65 }); 66 if (!res.ok) { 67 + throw new Error(`Failed to send webhook notification: ${res.statusText}`); 68 } 69 }; 70 ··· 75 latency, 76 statusCode, 77 message, 78 + }: NotificationContext) => { 79 const notificationData = WebhookSchema.parse(JSON.parse(notification.data)); 80 81 const body = PayloadSchema.parse({ ··· 97 }, 98 }); 99 if (!res.ok) { 100 + throw new Error(`Failed to send webhook notification: ${res.statusText}`); 101 } 102 }; 103
+3 -2
packages/notifications/webhook/tsconfig.json
··· 1 { 2 - "extends": "@openstatus/tsconfig/nextjs.json", 3 - "include": ["src", "*.ts"] 4 }
··· 1 { 2 + "extends": "@openstatus/tsconfig/base.json", 3 + "include": ["src", "*.ts"], 4 + "exclude": ["**/*.test.ts", "**/*.spec.ts"] 5 }
+82 -26
pnpm-lock.yaml
··· 73 version: 0.15.15 74 '@openpanel/nextjs': 75 specifier: 1.0.8 76 - version: 1.0.8(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 77 '@openstatus/analytics': 78 specifier: workspace:* 79 version: link:../../packages/analytics ··· 208 version: 1.2.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 209 '@sentry/nextjs': 210 specifier: 10.31.0 211 - version: 10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0) 212 '@stripe/stripe-js': 213 specifier: 2.1.6 214 version: 2.1.6 ··· 223 version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) 224 '@trpc/next': 225 specifier: 11.4.4 226 - version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) 227 '@trpc/react-query': 228 specifier: 11.4.4 229 version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) ··· 256 version: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 257 next-auth: 258 specifier: 5.0.0-beta.29 259 - version: 5.0.0-beta.29(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 260 next-themes: 261 specifier: 0.4.6 262 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 263 nuqs: 264 specifier: 2.8.5 265 - version: 2.8.5(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 266 random-word-slugs: 267 specifier: 0.1.7 268 version: 0.1.7 ··· 575 version: 0.15.15 576 '@openpanel/nextjs': 577 specifier: 1.0.8 578 - version: 1.0.8(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 579 '@openstatus/analytics': 580 specifier: workspace:* 581 version: link:../../packages/analytics ··· 665 version: 1.2.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 666 '@sentry/nextjs': 667 specifier: 10.31.0 668 - version: 10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0) 669 '@stripe/stripe-js': 670 specifier: 2.1.6 671 version: 2.1.6 ··· 680 version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) 681 '@trpc/next': 682 specifier: 11.4.4 683 - version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) 684 '@trpc/react-query': 685 specifier: 11.4.4 686 version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) ··· 713 version: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 714 next-auth: 715 specifier: 5.0.0-beta.29 716 - version: 5.0.0-beta.29(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 717 next-plausible: 718 specifier: 3.12.5 719 - version: 3.12.5(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 720 next-themes: 721 specifier: 0.4.6 722 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 723 nuqs: 724 specifier: 2.8.5 725 - version: 2.8.5(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 726 react: 727 specifier: 19.2.3 728 version: 19.2.3 ··· 819 version: 0.15.15 820 '@openpanel/nextjs': 821 specifier: 1.0.8 822 - version: 1.0.8(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 823 '@openstatus/analytics': 824 specifier: workspace:* 825 version: link:../../packages/analytics ··· 894 version: 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 895 '@sentry/nextjs': 896 specifier: 10.31.0 897 - version: 10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0) 898 '@stripe/stripe-js': 899 specifier: 2.1.6 900 version: 2.1.6 ··· 921 version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) 922 '@trpc/next': 923 specifier: 11.4.4 924 - version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) 925 '@trpc/react-query': 926 specifier: 11.4.4 927 version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) ··· 969 version: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 970 next-auth: 971 specifier: 5.0.0-beta.29 972 - version: 5.0.0-beta.29(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 973 next-mdx-remote: 974 specifier: 5.0.0 975 version: 5.0.0(@types/react@19.2.2)(react@19.2.3) 976 next-plausible: 977 specifier: 3.12.5 978 - version: 3.12.5(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 979 next-themes: 980 specifier: 0.4.6 981 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 982 nuqs: 983 specifier: 2.8.5 984 - version: 2.8.5(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 985 random-word-slugs: 986 specifier: 0.1.7 987 version: 0.1.7 ··· 1109 '@openstatus/emails': 1110 specifier: workspace:* 1111 version: link:../../packages/emails 1112 '@openstatus/notification-discord': 1113 specifier: workspace:* 1114 version: link:../../packages/notifications/discord ··· 1359 version: 0.31.4 1360 next-auth: 1361 specifier: 5.0.0-beta.29 1362 - version: 5.0.0-beta.29(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 1363 typescript: 1364 specifier: 5.9.3 1365 version: 5.9.3 ··· 1463 specifier: 5.9.3 1464 version: 5.9.3 1465 1466 packages/notifications/discord: 1467 dependencies: 1468 '@openstatus/db': 1469 specifier: workspace:* 1470 version: link:../../db 1471 zod: 1472 specifier: 4.1.13 1473 version: 4.1.13 ··· 1493 '@openstatus/emails': 1494 specifier: workspace:* 1495 version: link:../../emails 1496 '@openstatus/regions': 1497 specifier: workspace:* 1498 version: link:../../regions ··· 1542 '@openstatus/db': 1543 specifier: workspace:* 1544 version: link:../../db 1545 zod: 1546 specifier: 4.1.13 1547 version: 4.1.13 ··· 1564 '@openstatus/db': 1565 specifier: workspace:* 1566 version: link:../../db 1567 zod: 1568 specifier: 4.1.13 1569 version: 4.1.13 ··· 1586 '@openstatus/db': 1587 specifier: workspace:* 1588 version: link:../../db 1589 '@t3-oss/env-core': 1590 specifier: 0.13.10 1591 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) ··· 1623 '@openstatus/db': 1624 specifier: workspace:* 1625 version: link:../../db 1626 '@t3-oss/env-core': 1627 specifier: 0.13.10 1628 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) ··· 1660 '@openstatus/db': 1661 specifier: workspace:* 1662 version: link:../../db 1663 zod: 1664 specifier: 4.1.13 1665 version: 4.1.13 ··· 1682 '@openstatus/db': 1683 specifier: workspace:* 1684 version: link:../../db 1685 zod: 1686 specifier: 4.1.13 1687 version: 4.1.13 ··· 1704 '@openstatus/db': 1705 specifier: workspace:* 1706 version: link:../../db 1707 '@t3-oss/env-core': 1708 specifier: 0.13.10 1709 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) ··· 1735 '@openstatus/db': 1736 specifier: workspace:* 1737 version: link:../../db 1738 '@t3-oss/env-core': 1739 specifier: 0.13.10 1740 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) ··· 1766 '@openstatus/db': 1767 specifier: workspace:* 1768 version: link:../../db 1769 '@openstatus/utils': 1770 specifier: workspace:* 1771 version: link:../../utils ··· 1819 typescript: 1820 specifier: 5.9.3 1821 version: 5.9.3 1822 - 1823 - packages/react/dist: {} 1824 1825 packages/regions: 1826 dependencies: ··· 13420 '@openpanel/web': 1.0.1 13421 astro: 5.16.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.53.3)(terser@5.44.1)(typescript@5.9.3)(yaml@2.8.1) 13422 13423 - '@openpanel/nextjs@1.0.8(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': 13424 dependencies: 13425 '@openpanel/web': 1.0.1 13426 next: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) ··· 15447 '@sentry/types': 8.9.2 15448 '@sentry/utils': 8.9.2 15449 15450 - '@sentry/nextjs@10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0)': 15451 dependencies: 15452 '@opentelemetry/api': 1.9.0 15453 '@opentelemetry/semantic-conventions': 1.38.0 ··· 16149 '@trpc/server': 11.4.4(typescript@5.9.3) 16150 typescript: 5.9.3 16151 16152 - '@trpc/next@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)': 16153 dependencies: 16154 '@trpc/client': 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) 16155 '@trpc/server': 11.4.4(typescript@5.9.3) ··· 19619 19620 netmask@2.0.2: {} 19621 19622 - next-auth@5.0.0-beta.29(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): 19623 dependencies: 19624 '@auth/core': 0.40.0 19625 next: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) ··· 19638 - '@types/react' 19639 - supports-color 19640 19641 - next-plausible@3.12.5(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): 19642 dependencies: 19643 next: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 19644 react: 19.2.3 ··· 19735 dependencies: 19736 boolbase: 1.0.0 19737 19738 - nuqs@2.8.5(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): 19739 dependencies: 19740 '@standard-schema/spec': 1.0.0 19741 react: 19.2.3
··· 73 version: 0.15.15 74 '@openpanel/nextjs': 75 specifier: 1.0.8 76 + version: 1.0.8(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 77 '@openstatus/analytics': 78 specifier: workspace:* 79 version: link:../../packages/analytics ··· 208 version: 1.2.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 209 '@sentry/nextjs': 210 specifier: 10.31.0 211 + version: 10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0) 212 '@stripe/stripe-js': 213 specifier: 2.1.6 214 version: 2.1.6 ··· 223 version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) 224 '@trpc/next': 225 specifier: 11.4.4 226 + version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) 227 '@trpc/react-query': 228 specifier: 11.4.4 229 version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) ··· 256 version: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 257 next-auth: 258 specifier: 5.0.0-beta.29 259 + version: 5.0.0-beta.29(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 260 next-themes: 261 specifier: 0.4.6 262 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 263 nuqs: 264 specifier: 2.8.5 265 + version: 2.8.5(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 266 random-word-slugs: 267 specifier: 0.1.7 268 version: 0.1.7 ··· 575 version: 0.15.15 576 '@openpanel/nextjs': 577 specifier: 1.0.8 578 + version: 1.0.8(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 579 '@openstatus/analytics': 580 specifier: workspace:* 581 version: link:../../packages/analytics ··· 665 version: 1.2.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 666 '@sentry/nextjs': 667 specifier: 10.31.0 668 + version: 10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0) 669 '@stripe/stripe-js': 670 specifier: 2.1.6 671 version: 2.1.6 ··· 680 version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) 681 '@trpc/next': 682 specifier: 11.4.4 683 + version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) 684 '@trpc/react-query': 685 specifier: 11.4.4 686 version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) ··· 713 version: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 714 next-auth: 715 specifier: 5.0.0-beta.29 716 + version: 5.0.0-beta.29(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 717 next-plausible: 718 specifier: 3.12.5 719 + version: 3.12.5(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 720 next-themes: 721 specifier: 0.4.6 722 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 723 nuqs: 724 specifier: 2.8.5 725 + version: 2.8.5(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 726 react: 727 specifier: 19.2.3 728 version: 19.2.3 ··· 819 version: 0.15.15 820 '@openpanel/nextjs': 821 specifier: 1.0.8 822 + version: 1.0.8(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 823 '@openstatus/analytics': 824 specifier: workspace:* 825 version: link:../../packages/analytics ··· 894 version: 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 895 '@sentry/nextjs': 896 specifier: 10.31.0 897 + version: 10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0) 898 '@stripe/stripe-js': 899 specifier: 2.1.6 900 version: 2.1.6 ··· 921 version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) 922 '@trpc/next': 923 specifier: 11.4.4 924 + version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) 925 '@trpc/react-query': 926 specifier: 11.4.4 927 version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) ··· 969 version: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 970 next-auth: 971 specifier: 5.0.0-beta.29 972 + version: 5.0.0-beta.29(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 973 next-mdx-remote: 974 specifier: 5.0.0 975 version: 5.0.0(@types/react@19.2.2)(react@19.2.3) 976 next-plausible: 977 specifier: 3.12.5 978 + version: 3.12.5(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 979 next-themes: 980 specifier: 0.4.6 981 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 982 nuqs: 983 specifier: 2.8.5 984 + version: 2.8.5(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 985 random-word-slugs: 986 specifier: 0.1.7 987 version: 0.1.7 ··· 1109 '@openstatus/emails': 1110 specifier: workspace:* 1111 version: link:../../packages/emails 1112 + '@openstatus/notification-base': 1113 + specifier: workspace:* 1114 + version: link:../../packages/notifications/base 1115 '@openstatus/notification-discord': 1116 specifier: workspace:* 1117 version: link:../../packages/notifications/discord ··· 1362 version: 0.31.4 1363 next-auth: 1364 specifier: 5.0.0-beta.29 1365 + version: 5.0.0-beta.29(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) 1366 typescript: 1367 specifier: 5.9.3 1368 version: 5.9.3 ··· 1466 specifier: 5.9.3 1467 version: 5.9.3 1468 1469 + packages/notifications/base: 1470 + dependencies: 1471 + '@openstatus/db': 1472 + specifier: workspace:* 1473 + version: link:../../db 1474 + '@openstatus/regions': 1475 + specifier: workspace:* 1476 + version: link:../../regions 1477 + devDependencies: 1478 + '@openstatus/tsconfig': 1479 + specifier: workspace:* 1480 + version: link:../../tsconfig 1481 + '@types/node': 1482 + specifier: 24.0.8 1483 + version: 24.0.8 1484 + bun-types: 1485 + specifier: 1.3.1 1486 + version: 1.3.1(@types/react@19.2.2) 1487 + typescript: 1488 + specifier: 5.9.3 1489 + version: 5.9.3 1490 + 1491 packages/notifications/discord: 1492 dependencies: 1493 '@openstatus/db': 1494 specifier: workspace:* 1495 version: link:../../db 1496 + '@openstatus/notification-base': 1497 + specifier: workspace:* 1498 + version: link:../base 1499 zod: 1500 specifier: 4.1.13 1501 version: 4.1.13 ··· 1521 '@openstatus/emails': 1522 specifier: workspace:* 1523 version: link:../../emails 1524 + '@openstatus/notification-base': 1525 + specifier: workspace:* 1526 + version: link:../base 1527 '@openstatus/regions': 1528 specifier: workspace:* 1529 version: link:../../regions ··· 1573 '@openstatus/db': 1574 specifier: workspace:* 1575 version: link:../../db 1576 + '@openstatus/notification-base': 1577 + specifier: workspace:* 1578 + version: link:../base 1579 zod: 1580 specifier: 4.1.13 1581 version: 4.1.13 ··· 1598 '@openstatus/db': 1599 specifier: workspace:* 1600 version: link:../../db 1601 + '@openstatus/notification-base': 1602 + specifier: workspace:* 1603 + version: link:../base 1604 zod: 1605 specifier: 4.1.13 1606 version: 4.1.13 ··· 1623 '@openstatus/db': 1624 specifier: workspace:* 1625 version: link:../../db 1626 + '@openstatus/notification-base': 1627 + specifier: workspace:* 1628 + version: link:../base 1629 '@t3-oss/env-core': 1630 specifier: 0.13.10 1631 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) ··· 1663 '@openstatus/db': 1664 specifier: workspace:* 1665 version: link:../../db 1666 + '@openstatus/notification-base': 1667 + specifier: workspace:* 1668 + version: link:../base 1669 '@t3-oss/env-core': 1670 specifier: 0.13.10 1671 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) ··· 1703 '@openstatus/db': 1704 specifier: workspace:* 1705 version: link:../../db 1706 + '@openstatus/notification-base': 1707 + specifier: workspace:* 1708 + version: link:../base 1709 zod: 1710 specifier: 4.1.13 1711 version: 4.1.13 ··· 1728 '@openstatus/db': 1729 specifier: workspace:* 1730 version: link:../../db 1731 + '@openstatus/notification-base': 1732 + specifier: workspace:* 1733 + version: link:../base 1734 zod: 1735 specifier: 4.1.13 1736 version: 4.1.13 ··· 1753 '@openstatus/db': 1754 specifier: workspace:* 1755 version: link:../../db 1756 + '@openstatus/notification-base': 1757 + specifier: workspace:* 1758 + version: link:../base 1759 '@t3-oss/env-core': 1760 specifier: 0.13.10 1761 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) ··· 1787 '@openstatus/db': 1788 specifier: workspace:* 1789 version: link:../../db 1790 + '@openstatus/notification-base': 1791 + specifier: workspace:* 1792 + version: link:../base 1793 '@t3-oss/env-core': 1794 specifier: 0.13.10 1795 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) ··· 1821 '@openstatus/db': 1822 specifier: workspace:* 1823 version: link:../../db 1824 + '@openstatus/notification-base': 1825 + specifier: workspace:* 1826 + version: link:../base 1827 '@openstatus/utils': 1828 specifier: workspace:* 1829 version: link:../../utils ··· 1877 typescript: 1878 specifier: 5.9.3 1879 version: 5.9.3 1880 1881 packages/regions: 1882 dependencies: ··· 13476 '@openpanel/web': 1.0.1 13477 astro: 5.16.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.53.3)(terser@5.44.1)(typescript@5.9.3)(yaml@2.8.1) 13478 13479 + '@openpanel/nextjs@1.0.8(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': 13480 dependencies: 13481 '@openpanel/web': 1.0.1 13482 next: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) ··· 15503 '@sentry/types': 8.9.2 15504 '@sentry/utils': 8.9.2 15505 15506 + '@sentry/nextjs@10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0)': 15507 dependencies: 15508 '@opentelemetry/api': 1.9.0 15509 '@opentelemetry/semantic-conventions': 1.38.0 ··· 16205 '@trpc/server': 11.4.4(typescript@5.9.3) 16206 typescript: 5.9.3 16207 16208 + '@trpc/next@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)': 16209 dependencies: 16210 '@trpc/client': 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) 16211 '@trpc/server': 11.4.4(typescript@5.9.3) ··· 19675 19676 netmask@2.0.2: {} 19677 19678 + next-auth@5.0.0-beta.29(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): 19679 dependencies: 19680 '@auth/core': 0.40.0 19681 next: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) ··· 19694 - '@types/react' 19695 - supports-color 19696 19697 + next-plausible@3.12.5(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): 19698 dependencies: 19699 next: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 19700 react: 19.2.3 ··· 19791 dependencies: 19792 boolbase: 1.0.0 19793 19794 + nuqs@2.8.5(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): 19795 dependencies: 19796 '@standard-schema/spec': 1.0.0 19797 react: 19.2.3