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 17 18 18 **Configuration:** 19 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. 20 23 21 24 ### Email 22 25 ··· 32 35 **Configuration:** 33 36 - **Webhook URL:** (Required) A [Discord webhook URL](https://support.discord.com/hc/en-us/articles/228383668) for the target channel. 34 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. 35 40 36 41 ### Google Chat 37 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 1 + # This file is generated by Dofigen v2.5.0 2 2 # See https://github.com/lenra-io/dofigen 3 3 4 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 1 + # syntax=docker/dockerfile:1.11 2 + # This file is generated by Dofigen v2.5.0 3 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 4 12 5 # docker 13 6 FROM oven/bun@sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a AS docker ··· 34 27 --mount=type=bind,target=packages/assertions/package.json,source=packages/assertions/package.json \ 35 28 --mount=type=bind,target=packages/db/package.json,source=packages/db/package.json \ 36 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 \ 37 31 --mount=type=bind,target=packages/notifications/discord/package.json,source=packages/notifications/discord/package.json \ 38 32 --mount=type=bind,target=packages/notifications/email/package.json,source=packages/notifications/email/package.json \ 39 33 --mount=type=bind,target=packages/notifications/google-chat/package.json,source=packages/notifications/google-chat/package.json \ ··· 71 65 "/app/node_modules" "/app/node_modules" 72 66 RUN bun build --compile --target bun --sourcemap src/index.ts --outfile=app 73 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 + 74 75 # libsql 75 76 FROM oven/bun@sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a AS libsql 76 77 LABEL \ ··· 86 87 # runtime 87 88 FROM debian@sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 AS runtime 88 89 LABEL \ 89 - io.dofigen.version="2.6.0" \ 90 + io.dofigen.version="2.5.0" \ 90 91 org.opencontainers.image.authors="OpenStatus Team" \ 91 92 org.opencontainers.image.base.digest="sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734" \ 92 93 org.opencontainers.image.base.name="docker.io/debian:bullseye-slim" \
+3 -1
apps/workflows/README.md
··· 30 30 To generate the Dockerfile, run the following command from the `apps/workflows` directory: 31 31 32 32 ```bash 33 + # Install Dofigen 34 + cargo install dofigen 33 35 # Update the dependent image versions 34 36 dofigen update 35 37 # Generate the Dockerfile 36 38 dofigen gen 37 - ``` 39 + ```
+38 -35
apps/workflows/dofigen.lock
··· 12 12 - /packages/error 13 13 - /packages/tracker 14 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 15 36 install: 16 37 fromImage: 17 38 path: oven/bun 18 39 digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 19 40 label: 20 41 org.opencontainers.image.stage: install 21 - org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 22 42 org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 43 + org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 23 44 workdir: /app/ 24 45 run: 25 46 - bun install --production --frozen-lockfile --verbose ··· 38 59 source: packages/db/package.json 39 60 - target: packages/emails/package.json 40 61 source: packages/emails/package.json 62 + - target: packages/notifications/base/package.json 63 + source: packages/notifications/base/package.json 41 64 - target: packages/notifications/discord/package.json 42 65 source: packages/notifications/discord/package.json 43 66 - target: packages/notifications/email/package.json ··· 72 95 source: packages/upstash/package.json 73 96 - target: packages/theme-store/package.json 74 97 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 98 libsql: 85 99 fromImage: 86 100 path: oven/bun ··· 96 110 target: /app/package.json 97 111 run: 98 112 - bun install 99 - docker: 113 + ca-certs: 100 114 fromImage: 101 - path: oven/bun 102 - digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 115 + path: debian 116 + digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 103 117 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/ 118 + org.opencontainers.image.base.name: docker.io/debian:bullseye-slim 119 + org.opencontainers.image.base.digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 111 120 run: 112 - - bun run src/build-docker.ts 113 - build: 121 + - apt update && apt install -y ca-certificates curl && update-ca-certificates 122 + docker: 114 123 fromImage: 115 124 path: oven/bun 116 125 digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 117 126 label: 118 - org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 119 - org.opencontainers.image.stage: build 120 127 org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 128 + org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 121 129 workdir: /app/apps/workflows 122 - env: 123 - NODE_ENV: production 124 130 copy: 125 131 - paths: 126 132 - . 127 133 target: /app/ 128 - - fromBuilder: install 129 - paths: 130 - - /app/node_modules 131 - target: /app/node_modules 132 134 run: 133 - - bun build --compile --target bun --sourcemap src/index.ts --outfile=app 135 + - bun run src/build-docker.ts 134 136 fromImage: 135 137 path: debian 136 138 digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 ··· 138 140 org.opencontainers.image.authors: OpenStatus Team 139 141 org.opencontainers.image.source: https://github.com/openstatusHQ/openstatus 140 142 org.opencontainers.image.title: OpenStatus Workflows 143 + io.dofigen.version: 2.5.0 141 144 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 145 + org.opencontainers.image.base.name: docker.io/debian:bullseye-slim 144 146 org.opencontainers.image.vendor: OpenStatus 145 - org.opencontainers.image.base.name: docker.io/debian:bullseye-slim 147 + org.opencontainers.image.description: Background job processing and probe scheduling for OpenStatus 146 148 workdir: /app/ 147 149 copy: 148 150 - fromBuilder: build ··· 182 184 digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a 183 185 resources: 184 186 dofigen.yml: 185 - hash: 070adced2c20f3f63d9a803b4494401f9df769d071fca19c29755f75a0127a06 187 + hash: 6a13d3715d011de108b8e7710153a84e1c2a05a1aaf9f89daf6ae6a7315490f8 186 188 content: | 187 189 ignore: 188 190 - node_modules ··· 209 211 - packages/assertions/package.json 210 212 - packages/db/package.json 211 213 - packages/emails/package.json 214 + - packages/notifications/base/package.json 212 215 - packages/notifications/discord/package.json 213 216 - packages/notifications/email/package.json 214 217 - packages/notifications/google-chat/package.json
+1
apps/workflows/dofigen.yml
··· 23 23 - packages/assertions/package.json 24 24 - packages/db/package.json 25 25 - packages/emails/package.json 26 + - packages/notifications/base/package.json 26 27 - packages/notifications/discord/package.json 27 28 - packages/notifications/email/package.json 28 29 - packages/notifications/google-chat/package.json
+1
apps/workflows/package.json
··· 14 14 "@logtape/sentry": "2.0.1", 15 15 "@openstatus/db": "workspace:*", 16 16 "@openstatus/emails": "workspace:*", 17 + "@openstatus/notification-base": "workspace:*", 17 18 "@openstatus/notification-discord": "workspace:*", 18 19 "@openstatus/notification-emails": "workspace:*", 19 20 "@openstatus/notification-google-chat": "workspace:*",
+1 -1
apps/workflows/src/checker/alerting.test.ts
··· 16 16 statusCode: 400, 17 17 notifType: "alert", 18 18 cronTimestamp: 123456, 19 - incidentId: "1", 19 + incidentId: 1, 20 20 }); 21 21 expect(fn).toHaveBeenCalled(); 22 22 });
+25 -10
apps/workflows/src/checker/alerting.ts
··· 1 1 import { and, count, db, eq, gte, inArray, schema } from "@openstatus/db"; 2 - import type { MonitorStatus } from "@openstatus/db/src/schema"; 2 + import type { Incident, MonitorStatus } from "@openstatus/db/src/schema"; 3 3 import { 4 4 selectMonitorSchema, 5 5 selectNotificationSchema, ··· 21 21 notifType, 22 22 cronTimestamp, 23 23 incidentId, 24 - region, 24 + regions, 25 25 latency, 26 26 }: { 27 27 monitorId: string; ··· 29 29 message?: string; 30 30 notifType: "alert" | "recovery" | "degraded"; 31 31 cronTimestamp: number; 32 - incidentId: string; 33 - region?: Region; 32 + incidentId?: number; 33 + regions?: string[]; 34 34 latency?: number; 35 35 }) => { 36 36 logger.info("Triggering alerting", { 37 37 monitor_id: monitorId, 38 38 notification_type: notifType, 39 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 + 40 55 const notifications = await db 41 56 .select() 42 57 .from(schema.notificationsToMonitors) ··· 129 144 notification: selectNotificationSchema.parse(notif.notification), 130 145 statusCode, 131 146 message, 132 - incidentId, 147 + incident, 133 148 cronTimestamp, 134 - region, 149 + regions, 135 150 latency, 136 151 }), 137 152 ··· 161 176 notification: selectNotificationSchema.parse(notif.notification), 162 177 statusCode, 163 178 message, 164 - incidentId, 179 + incident, 165 180 cronTimestamp, 166 - region, 181 + regions, 167 182 latency, 168 183 }), 169 184 catch: (_unknown) => ··· 192 207 notification: selectNotificationSchema.parse(notif.notification), 193 208 statusCode, 194 209 message, 195 - incidentId, 210 + incident, 196 211 cronTimestamp, 197 - region, 212 + regions, 198 213 latency, 199 214 }), 200 215 catch: (_unknown) =>
+26 -16
apps/workflows/src/checker/index.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { z } from "zod"; 3 3 4 - import { and, count, db, eq, inArray, isNull, schema } from "@openstatus/db"; 4 + import { and, db, eq, inArray, isNull, schema } from "@openstatus/db"; 5 5 import { incidentTable } from "@openstatus/db/src/schema"; 6 6 import { 7 7 monitorStatusSchema, ··· 142 142 const monitor = selectMonitorSchema.parse(currentMonitor); 143 143 const numberOfRegions = monitor.regions.length; 144 144 145 - const affectedRegion = await db 146 - .select({ count: count() }) 145 + // Fetch all affected regions for notifications (single query) 146 + const affectedRegions = await db 147 + .select({ region: schema.monitorStatusTable.region }) 147 148 .from(schema.monitorStatusTable) 148 149 .where( 149 150 and( ··· 152 153 inArray(schema.monitorStatusTable.region, monitor.regions), 153 154 ), 154 155 ) 155 - .get(); 156 + .all(); 156 157 157 - if (!affectedRegion?.count) { 158 + const affectedRegionsList = affectedRegions.map((r) => r.region); 159 + const affectedRegionCount = affectedRegionsList.length; 160 + 161 + if (affectedRegionCount === 0) { 158 162 return c.json({ success: true }, 200); 159 163 } 160 164 ··· 203 207 break; 204 208 } 205 209 206 - if (affectedRegion.count >= numberOfRegions / 2 || numberOfRegions === 1) { 210 + if (affectedRegionCount >= numberOfRegions / 2 || numberOfRegions === 1) { 207 211 switch (status) { 208 212 case "active": { 209 213 if (monitor.status === "active") { ··· 219 223 .set({ status: "active" }) 220 224 .where(eq(schema.monitor.id, monitor.id)); 221 225 226 + let incident = null; 222 227 if (monitor.status === "error") { 223 - await resolveIncident({ monitorId, cronTimestamp }); 228 + incident = await resolveIncident({ monitorId, cronTimestamp }); 224 229 } 225 230 226 231 await triggerNotifications({ ··· 229 234 message, 230 235 notifType: "recovery", 231 236 cronTimestamp, 232 - region, 237 + regions: affectedRegionsList, 233 238 latency, 234 - incidentId: `${cronTimestamp}`, 239 + incidentId: incident?.id, 235 240 }); 236 241 237 242 break; ··· 251 256 .set({ status: "degraded" }) 252 257 .where(eq(schema.monitor.id, monitor.id)); 253 258 259 + let incident = null; 260 + if (monitor.status === "error") { 261 + incident = await resolveIncident({ 262 + monitorId, 263 + cronTimestamp, 264 + }); 265 + } 266 + 254 267 await triggerNotifications({ 255 268 monitorId, 256 269 statusCode, ··· 258 271 notifType: "degraded", 259 272 cronTimestamp, 260 273 latency, 261 - region, 262 - incidentId: `${cronTimestamp}`, 274 + regions: affectedRegionsList, 275 + incidentId: incident?.id, 263 276 }); 264 277 265 - if (monitor.status === "error") { 266 - await resolveIncident({ monitorId, cronTimestamp }); 267 - } 268 278 break; 269 279 case "error": 270 280 if (monitor.status === "error") { ··· 317 327 notifType: "alert", 318 328 cronTimestamp, 319 329 latency, 320 - region, 321 - incidentId: String(newIncident.id), 330 + regions: affectedRegionsList, 331 + incidentId: newIncident.id, 322 332 }); 323 333 } catch (error) { 324 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"; 1 + import type { NotificationProvider } from "@openstatus/db/src/schema"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 7 3 import { 8 4 sendAlert as sendDiscordAlert, 9 5 sendDegraded as sendDiscordDegraded, ··· 60 56 sendRecovery as sendWebhookRecovery, 61 57 } from "@openstatus/notification-webhook"; 62 58 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>; 59 + type SendNotification = (props: NotificationContext) => Promise<void>; 82 60 83 61 type Notif = { 84 62 sendAlert: SendNotification; ··· 86 64 sendDegraded: SendNotification; 87 65 }; 88 66 89 - export const providerToFunction = { 67 + export const providerToFunction: Record<NotificationProvider, Notif> = { 90 68 discord: { 91 69 sendAlert: sendDiscordAlert, 92 70 sendRecovery: sendDiscordRecovery, ··· 142 120 sendRecovery: sendTelegramRecovery, 143 121 sendDegraded: sendTelegramDegraded, 144 122 }, 145 - } satisfies Record<NotificationProvider, Notif>; 123 + };
+1 -1
apps/workflows/src/incident/index.ts
··· 19 19 const unresolvedIncidentMonitorIds = db 20 20 .select({ monitorId: schema.incidentTable.monitorId }) 21 21 .from(schema.incidentTable) 22 - .where(and(isNull(schema.incidentTable.resolvedAt))); 22 + .where(isNull(schema.incidentTable.resolvedAt)); 23 23 24 24 const activeMonitorsWithUnresolvedIncidents = await db 25 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 3 "version": "1.0.0", 4 4 "main": "src/index.ts", 5 5 "scripts": { 6 - "test": "bun test" 6 + "test": "bun test", 7 + "tsc": "tsc" 7 8 }, 8 9 "dependencies": { 9 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 10 12 "zod": "4.1.13" 11 13 }, 12 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 1 import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; 2 2 import { selectNotificationSchema } from "@openstatus/db/src/schema"; 3 + import { COLOR_DECIMALS } from "@openstatus/notification-base"; 3 4 import { 4 5 sendAlert, 5 6 sendDegraded, ··· 70 71 expect(callArgs[1].headers["Content-Type"]).toBe("application/json"); 71 72 72 73 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"); 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); 76 78 expect(body.username).toBe("OpenStatus Notifications"); 77 79 expect(body.avatar_url).toBeDefined(); 78 80 }); ··· 95 97 expect(fetchMock).toHaveBeenCalledTimes(1); 96 98 const callArgs = fetchMock.mock.calls[0]; 97 99 const body = JSON.parse(callArgs[1].body); 98 - expect(body.content).toContain("✅ Recovered"); 99 - expect(body.content).toContain("API Health Check"); 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); 100 104 }); 101 105 102 106 test("Send Degraded", async () => { ··· 117 121 expect(fetchMock).toHaveBeenCalledTimes(1); 118 122 const callArgs = fetchMock.mock.calls[0]; 119 123 const body = JSON.parse(callArgs[1].body); 120 - expect(body.content).toContain("⚠️ Degraded"); 121 - expect(body.content).toContain("API Health Check"); 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 122 128 }); 123 129 124 130 test("Send Test Discord Message", async () => { 125 131 const webhookUrl = "https://discord.com/api/webhooks/123456789/abcdefgh"; 126 - 127 - const result = await sendTestDiscordMessage(webhookUrl); 132 + await sendTestDiscordMessage(webhookUrl); 128 133 129 - expect(result).toBe(true); 130 134 expect(fetchMock).toHaveBeenCalledTimes(1); 131 135 const callArgs = fetchMock.mock.calls[0]; 132 136 expect(callArgs[0]).toBe(webhookUrl); 133 137 const body = JSON.parse(callArgs[1].body); 134 - expect(body.content).toContain("🧪 Test"); 135 - expect(body.content).toContain("OpenStatus"); 138 + expect(body.embeds).toBeDefined(); 139 + expect(body.embeds[0].title).toContain("Test Notification"); 136 140 }); 137 141 138 142 test("Send Test Discord Message with empty webhookUrl", async () => { 139 - const result = await sendTestDiscordMessage(""); 140 - 141 - expect(result).toBe(false); 143 + expect(sendTestDiscordMessage("")).rejects.toThrow(); 142 144 expect(fetchMock).not.toHaveBeenCalled(); 143 145 }); 144 146
+104 -93
packages/notifications/discord/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 1 import { discordDataSchema } from "@openstatus/db/src/schema"; 3 - import type { Region } from "@openstatus/db/src/schema/constants"; 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 + } 4 18 5 - const postToWebhook = async (content: string, webhookUrl: string) => { 6 19 const res = await fetch(webhookUrl, { 7 20 method: "POST", 8 21 headers: { 9 22 "Content-Type": "application/json", 10 23 }, 11 24 body: JSON.stringify({ 12 - content, 25 + embeds, 13 26 avatar_url: 14 27 "https://img.stackshare.io/service/104872/default_dc6948366d9bae553adbb8f51252eafbc5d2043a.jpg", 15 28 username: "OpenStatus Notifications", ··· 28 41 statusCode, 29 42 message, 30 43 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 - }) => { 44 + latency, 45 + regions, 46 + }: NotificationContext) => { 41 47 const notificationData = discordDataSchema.parse( 42 48 JSON.parse(notification.data), 43 49 ); 44 - const { discord: webhookUrl } = notificationData; // webhook url 45 - const { name } = monitor; 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); 46 64 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 - } 65 + await postToWebhook([embed], webhookUrl); 62 66 }; 63 67 64 68 export const sendRecovery = async ({ 65 69 monitor, 66 70 notification, 67 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 68 71 statusCode, 69 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 70 72 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 - }) => { 73 + incident, 74 + cronTimestamp, 75 + latency, 76 + regions, 77 + }: NotificationContext) => { 83 78 const notificationData = discordDataSchema.parse( 84 79 JSON.parse(notification.data), 85 80 ); 86 - const { discord: webhookUrl } = notificationData; // webhook url 87 - const { name } = monitor; 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); 88 95 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 - } 96 + await postToWebhook([embed], webhookUrl); 98 97 }; 99 98 100 99 export const sendDegraded = async ({ 101 100 monitor, 102 101 notification, 103 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 104 102 statusCode, 105 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 106 103 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 - }) => { 104 + incident, 105 + cronTimestamp, 106 + latency, 107 + regions, 108 + }: NotificationContext) => { 119 109 const notificationData = discordDataSchema.parse( 120 110 JSON.parse(notification.data), 121 111 ); 122 - const { discord: webhookUrl } = notificationData; // webhook url 123 - const { name } = monitor; 112 + const { discord: webhookUrl } = notificationData; 124 113 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 - } 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); 134 128 }; 135 129 136 130 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 - } 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); 149 160 };
+5
packages/notifications/discord/src/mock.ts
··· 27 27 status: "active", 28 28 method: "GET", 29 29 deletedAt: null, 30 + externalName: null, 31 + otelEndpoint: null, 32 + otelHeaders: [], 33 + retry: 3, 34 + followRedirects: false, 30 35 }; 31 36 32 37 const notification: Notification = {
+3 -1
packages/notifications/email/package.json
··· 4 4 "main": "src/index.ts", 5 5 "description": "Log drains Vercel integration.", 6 6 "scripts": { 7 - "test": "bun test" 7 + "test": "bun test", 8 + "tsc": "tsc" 8 9 }, 9 10 "dependencies": { 10 11 "@openstatus/db": "workspace:*", 11 12 "@openstatus/emails": "workspace:*", 13 + "@openstatus/notification-base": "workspace:*", 12 14 "@openstatus/regions": "workspace:*", 13 15 "@openstatus/tinybird": "workspace:*", 14 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 - 1 + import { emailDataSchema } from "@openstatus/db/src/schema"; 7 2 import type { Region } from "@openstatus/db/src/schema/constants"; 8 3 import { EmailClient } from "@openstatus/emails/src/client"; 4 + import type { NotificationContext } from "@openstatus/notification-base"; 9 5 import { regionDict } from "@openstatus/regions"; 10 6 import { env } from "../env"; 11 7 ··· 16 12 message, 17 13 cronTimestamp, 18 14 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 - }) => { 15 + regions, 16 + }: NotificationContext) => { 17 + // Convert regions array to single region for backwards compatibility 18 + const region = regions?.[0] as Region | undefined; 30 19 const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); 31 20 32 21 const config = emailDataSchema.safeParse(JSON.parse(notification.data)); ··· 51 40 notification, 52 41 statusCode, 53 42 cronTimestamp, 54 - region, 43 + regions, 55 44 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 - }) => { 45 + }: NotificationContext) => { 46 + // Convert regions array to single region for backwards compatibility 47 + const region = regions?.[0] as Region | undefined; 66 48 const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); 67 49 68 50 const config = emailDataSchema.safeParse(JSON.parse(notification.data)); ··· 86 68 notification, 87 69 statusCode, 88 70 cronTimestamp, 89 - region, 71 + regions, 90 72 latency, 91 - }: { 92 - monitor: Monitor; 93 - notification: Notification; 94 - statusCode?: number; 95 - message?: string; 96 - cronTimestamp: number; 97 - region?: Region; 98 - latency?: number; 99 - }) => { 73 + }: NotificationContext) => { 74 + // Convert regions array to single region for backwards compatibility 75 + const region = regions?.[0] as Region | undefined; 100 76 const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); 101 77 102 78 const config = emailDataSchema.safeParse(JSON.parse(notification.data));
+1
packages/notifications/email/src/mock.ts
··· 26 26 otelHeaders: [], 27 27 followRedirects: false, 28 28 retry: 3, 29 + externalName: null, 29 30 }; 30 31 31 32 const notification: Notification = {
+3 -1
packages/notifications/google-chat/package.json
··· 3 3 "version": "1.0.0", 4 4 "main": "src/index.ts", 5 5 "scripts": { 6 - "test": "bun test" 6 + "test": "bun test", 7 + "tsc": "tsc" 7 8 }, 8 9 "dependencies": { 9 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 10 12 "zod": "4.1.13" 11 13 }, 12 14 "devDependencies": {
+4 -44
packages/notifications/google-chat/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 1 import { googleChatDataSchema } from "@openstatus/db/src/schema"; 3 - import type { Region } from "@openstatus/db/src/schema/constants"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 4 3 5 4 const postToWebhook = async (content: string, webhookUrl: string) => { 6 5 const res = await fetch(webhookUrl, { ··· 25 24 statusCode, 26 25 message, 27 26 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 - }) => { 27 + }: NotificationContext) => { 38 28 const notificationData = googleChatDataSchema.parse( 39 29 JSON.parse(notification.data), 40 30 ); ··· 61 51 export const sendRecovery = async ({ 62 52 monitor, 63 53 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 - }) => { 54 + }: NotificationContext) => { 80 55 const notificationData = googleChatDataSchema.parse( 81 56 JSON.parse(notification.data), 82 57 ); ··· 97 72 export const sendDegraded = async ({ 98 73 monitor, 99 74 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 - }) => { 75 + }: NotificationContext) => { 116 76 const notificationData = googleChatDataSchema.parse( 117 77 JSON.parse(notification.data), 118 78 );
+1
packages/notifications/google-chat/src/mock.ts
··· 26 26 otelHeaders: [], 27 27 followRedirects: true, 28 28 retry: 3, 29 + externalName: null, 29 30 }; 30 31 31 32 const notification: Notification = {
+3 -1
packages/notifications/ntfy/package.json
··· 3 3 "version": "1.0.0", 4 4 "main": "src/index.ts", 5 5 "scripts": { 6 - "test": "bun test" 6 + "test": "bun test", 7 + "tsc": "tsc" 7 8 }, 8 9 "dependencies": { 9 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 10 12 "zod": "4.1.13" 11 13 }, 12 14 "devDependencies": {
+4 -45
packages/notifications/ntfy/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 - 3 1 import { ntfyDataSchema } from "@openstatus/db/src/schema"; 4 - import type { Region } from "@openstatus/db/src/schema/constants"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 5 3 6 4 export const sendAlert = async ({ 7 5 monitor, 8 6 notification, 9 7 statusCode, 10 8 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 - }) => { 9 + }: NotificationContext) => { 23 10 const notificationData = ntfyDataSchema.parse(JSON.parse(notification.data)); 24 11 const { name } = monitor; 25 12 ··· 50 37 export const sendRecovery = async ({ 51 38 monitor, 52 39 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 - }) => { 40 + }: NotificationContext) => { 69 41 const notificationData = ntfyDataSchema.parse(JSON.parse(notification.data)); 70 42 const { name } = monitor; 71 43 ··· 93 65 export const sendDegraded = async ({ 94 66 monitor, 95 67 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 - }) => { 68 + }: NotificationContext) => { 110 69 const notificationData = ntfyDataSchema.parse(JSON.parse(notification.data)); 111 70 const { name } = monitor; 112 71
+3 -1
packages/notifications/opsgenie/package.json
··· 3 3 "version": "0.0.0", 4 4 "main": "src/index.ts", 5 5 "scripts": { 6 - "test": "bun test" 6 + "test": "bun test", 7 + "tsc": "tsc" 7 8 }, 8 9 "dependencies": { 9 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 10 12 "@t3-oss/env-core": "0.13.10", 11 13 "@types/validator": "13.12.0", 12 14 "validator": "13.12.0",
+24 -8
packages/notifications/opsgenie/src/index.test.ts
··· 46 46 }), 47 47 }); 48 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 + 49 59 test("Send Alert with US region", async () => { 50 60 const monitor = createMockMonitor(); 51 61 const notification = selectNotificationSchema.parse( 52 62 createMockNotification("us"), 53 63 ); 64 + const incident = createMockIncident(); 54 65 55 66 await sendAlert({ 56 67 // @ts-expect-error ··· 58 69 notification, 59 70 statusCode: 500, 60 71 message: "Something went wrong", 61 - incidentId: "incident-123", 72 + // @ts-expect-error 73 + incident, 62 74 cronTimestamp: Date.now(), 63 75 }); 64 76 ··· 71 83 72 84 const body = JSON.parse(callArgs[1].body); 73 85 expect(body.message).toBe("API Health Check is down"); 74 - expect(body.alias).toBe("monitor-1}-incident-123"); 86 + expect(body.alias).toBe("monitor-1"); 75 87 expect(body.details.severity).toBe("down"); 76 88 expect(body.details.status).toBe(500); 77 89 expect(body.details.message).toBe("Something went wrong"); ··· 82 94 const notification = selectNotificationSchema.parse( 83 95 createMockNotification("eu"), 84 96 ); 85 - 97 + const incident = createMockIncident(); 86 98 await sendAlert({ 87 99 // @ts-expect-error 88 100 monitor, 89 101 notification, 90 102 statusCode: 500, 91 103 message: "Error", 92 - incidentId: "incident-456", 104 + // @ts-expect-error 105 + incident, 93 106 cronTimestamp: Date.now(), 94 107 }); 95 108 ··· 103 116 const notification = selectNotificationSchema.parse( 104 117 createMockNotification(), 105 118 ); 106 - 119 + const incident = createMockIncident(); 107 120 await sendDegraded({ 108 121 // @ts-expect-error 109 122 monitor, 110 123 notification, 111 124 statusCode: 503, 112 125 message: "Service degraded", 113 - incidentId: "incident-789", 126 + // @ts-expect-error 127 + incident, 114 128 cronTimestamp: Date.now(), 115 129 }); 116 130 ··· 118 132 const callArgs = fetchMock.mock.calls[0]; 119 133 const body = JSON.parse(callArgs[1].body); 120 134 expect(body.details.severity).toBe("degraded"); 121 - expect(body.message).toBe("API Health Check is down"); 135 + expect(body.message).toBe("API Health Check is degraded"); 122 136 }); 123 137 124 138 test("Handle fetch error gracefully", async () => { ··· 130 144 const notification = selectNotificationSchema.parse( 131 145 createMockNotification(), 132 146 ); 133 - 147 + const incident = createMockIncident(); 134 148 expect( 135 149 sendAlert({ 136 150 // @ts-expect-error ··· 138 152 notification, 139 153 statusCode: 500, 140 154 message: "Error", 155 + // @ts-expect-error 156 + incident, 141 157 cronTimestamp: Date.now(), 142 158 }), 143 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"; 1 + import type { NotificationContext } from "@openstatus/notification-base"; 3 2 import { OpsGeniePayloadAlert, OpsGenieSchema } from "./schema"; 4 3 5 4 export const sendAlert = async ({ ··· 7 6 notification, 8 7 statusCode, 9 8 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 - }) => { 9 + }: NotificationContext) => { 22 10 const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); 23 11 const { name } = monitor; 24 12 25 13 const event = OpsGeniePayloadAlert.parse({ 26 - alias: `${monitor.id}}-${incidentId}`, 14 + alias: `${monitor.id}`, 27 15 message: `${name} is down`, 28 16 description: message, 29 17 details: { ··· 58 46 notification, 59 47 statusCode, 60 48 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 - }) => { 49 + }: NotificationContext) => { 72 50 const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); 73 51 const { name } = monitor; 74 52 75 53 const event = OpsGeniePayloadAlert.parse({ 76 - alias: `${monitor.id}}-${incidentId}`, 77 - message: `${name} is down`, 54 + alias: `${monitor.id}`, 55 + message: `${name} is degraded`, 78 56 description: message, 79 57 details: { 80 58 message, ··· 107 85 notification, 108 86 statusCode, 109 87 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 - }) => { 88 + }: NotificationContext) => { 121 89 const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); 122 90 123 91 const url = 124 92 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`; 93 + ? `https://api.eu.opsgenie.com/v2/alerts/${monitor.id}/close` 94 + : `https://api.opsgenie.com/v2/alerts/${monitor.id}/close`; 127 95 128 96 const event = OpsGeniePayloadAlert.parse({ 129 - alias: `${monitor.id}}-${incidentId}`, 97 + alias: `${monitor.id}`, 130 98 message: `${monitor.name} has recovered`, 131 99 description: message, 132 100 details: {
+3 -1
packages/notifications/pagerduty/package.json
··· 3 3 "version": "0.0.0", 4 4 "main": "src/index.ts", 5 5 "scripts": { 6 - "test": "bun test" 6 + "test": "bun test", 7 + "tsc": "tsc" 7 8 }, 8 9 "dependencies": { 9 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 10 12 "@t3-oss/env-core": "0.13.10", 11 13 "@types/validator": "13.12.0", 12 14 "validator": "13.12.0",
+28 -6
packages/notifications/pagerduty/src/index.test.ts
··· 31 31 region: "us-east-1", 32 32 }); 33 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 + 34 44 const createMockNotification = () => ({ 35 45 id: 1, 36 46 name: "PagerDuty Notification", ··· 46 56 const notification = selectNotificationSchema.parse( 47 57 createMockNotification(), 48 58 ); 59 + const incident = createMockIncident(); 49 60 50 61 await sendAlert({ 51 62 // @ts-expect-error 52 63 monitor, 53 64 notification, 65 + // @ts-expect-error 66 + incident, 54 67 statusCode: 500, 55 68 message: "Something went wrong", 56 - incidentId: "incident-123", 57 69 cronTimestamp: Date.now(), 58 70 }); 59 71 ··· 64 76 65 77 const body = JSON.parse(callArgs[1].body); 66 78 expect(body.routing_key).toBe("my_key"); 67 - expect(body.dedup_key).toBe("monitor-1}-incident-123"); 79 + expect(body.dedup_key).toBe("monitor-1"); 68 80 expect(body.event_action).toBe("trigger"); 69 81 expect(body.payload.summary).toBe("API Health Check is down"); 70 82 expect(body.payload.severity).toBe("error"); ··· 83 95 updatedAt: new Date(), 84 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\\"}}"}', 85 97 }); 98 + const incident = createMockIncident(); 86 99 87 100 await sendAlert({ 88 101 // @ts-expect-error ··· 90 103 notification, 91 104 statusCode: 500, 92 105 message: "Error", 93 - incidentId: "incident-456", 106 + // @ts-expect-error 107 + incident, 94 108 cronTimestamp: Date.now(), 95 109 }); 96 110 ··· 104 118 const notification = selectNotificationSchema.parse( 105 119 createMockNotification(), 106 120 ); 121 + const incident = createMockIncident(); 107 122 108 123 await sendDegraded({ 109 124 // @ts-expect-error ··· 111 126 notification, 112 127 statusCode: 503, 113 128 message: "Service degraded", 129 + // @ts-expect-error 130 + incident, 114 131 cronTimestamp: Date.now(), 115 132 }); 116 133 ··· 119 136 const body = JSON.parse(callArgs[1].body); 120 137 expect(body.payload.summary).toBe("API Health Check is degraded"); 121 138 expect(body.payload.severity).toBe("warning"); 122 - expect(body.dedup_key).toBe("monitor-1}"); 139 + expect(body.dedup_key).toBe("monitor-1"); 123 140 }); 124 141 125 142 test("Send Recovery", async () => { ··· 127 144 const notification = selectNotificationSchema.parse( 128 145 createMockNotification(), 129 146 ); 147 + const incident = createMockIncident(); 130 148 131 149 await sendRecovery({ 132 150 // @ts-expect-error ··· 134 152 notification, 135 153 statusCode: 200, 136 154 message: "Service recovered", 137 - incidentId: "incident-123", 155 + // @ts-expect-error 156 + incident, 138 157 cronTimestamp: Date.now(), 139 158 }); 140 159 ··· 143 162 expect(callArgs[0]).toBe("https://events.pagerduty.com/v2/enqueue"); 144 163 const body = JSON.parse(callArgs[1].body); 145 164 expect(body.routing_key).toBe("my_key"); 146 - expect(body.dedup_key).toBe("monitor-1}-incident-123"); 165 + expect(body.dedup_key).toBe("monitor-1"); 147 166 expect(body.event_action).toBe("resolve"); 148 167 }); 149 168 ··· 189 208 const notification = selectNotificationSchema.parse( 190 209 createMockNotification(), 191 210 ); 211 + const incident = createMockIncident(); 192 212 193 213 expect( 194 214 sendAlert({ ··· 197 217 notification, 198 218 statusCode: 500, 199 219 message: "Error", 220 + // @ts-expect-error 221 + incident, 200 222 cronTimestamp: Date.now(), 201 223 }), 202 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"; 1 + import { pagerdutyDataSchema } from "@openstatus/db/src/schema"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 8 3 import { 9 4 PagerDutySchema, 10 5 resolveEventPayloadSchema, ··· 16 11 notification, 17 12 statusCode, 18 13 message, 19 - incidentId, 20 14 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 - }) => { 15 + }: NotificationContext) => { 31 16 const data = pagerdutyDataSchema.parse(JSON.parse(notification.data)); 32 17 33 18 const notificationData = PagerDutySchema.parse(JSON.parse(data.pagerduty)); ··· 38 23 const { integration_key } = integrationKey; 39 24 const event = triggerEventPayloadSchema.parse({ 40 25 routing_key: integration_key, 41 - dedup_key: `${monitor.id}}-${incidentId}`, 26 + dedup_key: `${monitor.id}`, 42 27 event_action: "trigger", 43 28 payload: { 44 29 summary: `${name} is down`, ··· 67 52 notification, 68 53 statusCode, 69 54 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 - }) => { 55 + }: NotificationContext) => { 80 56 const data = pagerdutyDataSchema.parse(JSON.parse(notification.data)); 81 57 82 58 const notificationData = PagerDutySchema.parse(JSON.parse(data.pagerduty)); ··· 87 63 88 64 const event = triggerEventPayloadSchema.parse({ 89 65 routing_key: integration_key, 90 - dedup_key: `${monitor.id}}`, 66 + dedup_key: `${monitor.id}`, 91 67 event_action: "trigger", 92 68 payload: { 93 69 summary: `${name} is degraded`, ··· 115 91 export const sendRecovery = async ({ 116 92 monitor, 117 93 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 - }) => { 94 + incident, 95 + }: NotificationContext) => { 129 96 const data = pagerdutyDataSchema.parse(JSON.parse(notification.data)); 130 97 131 98 const notificationData = PagerDutySchema.parse(JSON.parse(data.pagerduty)); ··· 133 100 for (const integrationKey of notificationData.integration_keys) { 134 101 const event = resolveEventPayloadSchema.parse({ 135 102 routing_key: integrationKey.integration_key, 136 - dedup_key: `${monitor.id}}-${incidentId}`, 103 + dedup_key: `${monitor.id}`, 137 104 event_action: "resolve", 138 105 }); 139 106 const res = await fetch("https://events.pagerduty.com/v2/enqueue", {
+3 -1
packages/notifications/slack/package.json
··· 3 3 "version": "0.0.0", 4 4 "main": "src/index.ts", 5 5 "scripts": { 6 - "test": "bun test" 6 + "test": "bun test", 7 + "tsc": "tsc" 7 8 }, 8 9 "dependencies": { 9 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 10 12 "zod": "4.1.13" 11 13 }, 12 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 1 import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; 2 2 import { selectNotificationSchema } from "@openstatus/db/src/schema"; 3 + import { COLORS } from "@openstatus/notification-base"; 3 4 import { 4 5 sendAlert, 5 6 sendDegraded, ··· 67 68 expect(callArgs[1].method).toBe("POST"); 68 69 69 70 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"); 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"); 75 76 }); 76 77 77 78 test("Send Alert without statusCode", async () => { ··· 91 92 expect(fetchMock).toHaveBeenCalledTimes(1); 92 93 const callArgs = fetchMock.mock.calls[0]; 93 94 const body = JSON.parse(callArgs[1].body); 94 - expect(body.blocks[1].text.text).toContain("_empty_"); 95 + expect(body.attachments[0].color).toBe(COLORS.red); 96 + expect(body.attachments[0].blocks[3].fields[0].text).toContain("Unknown"); 95 97 }); 96 98 97 99 test("Send Recovery", async () => { ··· 112 114 expect(fetchMock).toHaveBeenCalledTimes(1); 113 115 const callArgs = fetchMock.mock.calls[0]; 114 116 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 + 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"); 117 120 }); 118 121 119 122 test("Send Degraded", async () => { ··· 134 137 expect(fetchMock).toHaveBeenCalledTimes(1); 135 138 const callArgs = fetchMock.mock.calls[0]; 136 139 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"); 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"); 139 143 }); 140 144 141 145 test("Send Test Slack Message", async () => { 142 146 const webhookUrl = "https://hooks.slack.com/services/test/url"; 143 147 144 - const result = await sendTestSlackMessage(webhookUrl); 148 + await sendTestSlackMessage(webhookUrl); 145 149 146 - expect(result).toBe(true); 147 150 expect(fetchMock).toHaveBeenCalledTimes(1); 148 151 const callArgs = fetchMock.mock.calls[0]; 149 152 expect(callArgs[0]).toBe(webhookUrl); 150 153 151 154 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"); 155 + expect(body.attachments[0].blocks[0].text.text).toContain( 156 + "Test Notification", 157 + ); 154 158 }); 155 159 156 - test("Send Test Slack Message returns false on error", async () => { 160 + test("Send Test Slack Message throws error on empty webhookUrl", async () => { 157 161 fetchMock.mockImplementation(() => 158 162 Promise.reject(new Error("Network error")), 159 163 ); 160 164 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); 165 + expect(sendTestSlackMessage("")).rejects.toThrow(); 166 + expect(fetchMock).toHaveBeenCalledTimes(0); 167 167 }); 168 168 169 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 1 import { slackDataSchema } from "@openstatus/db/src/schema"; 3 - import type { Region } from "@openstatus/db/src/schema/constants"; 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"; 4 12 5 13 // biome-ignore lint/suspicious/noExplicitAny: <explanation> 6 14 const postToWebhook = async (body: any, webhookUrl: string) => { 15 + if (!webhookUrl || webhookUrl.trim() === "") { 16 + throw new Error("Slack webhook URL is required"); 17 + } 18 + 7 19 const res = await fetch(webhookUrl, { 8 20 method: "POST", 9 21 body: JSON.stringify(body), ··· 18 30 notification, 19 31 statusCode, 20 32 message, 21 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 22 - incidentId, 23 33 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 + latency, 35 + regions, 36 + }: NotificationContext) => { 34 37 const notificationData = slackDataSchema.parse(JSON.parse(notification.data)); 35 - const { slack: webhookUrl } = notificationData; // webhook url 36 - const { name } = monitor; 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); 37 52 38 53 await postToWebhook( 39 54 { 40 - blocks: [ 41 - { 42 - type: "divider", 43 - }, 55 + attachments: [ 44 56 { 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 - ], 57 + color: COLORS.red, 58 + blocks, 64 59 }, 65 60 ], 66 61 }, ··· 71 66 export const sendRecovery = async ({ 72 67 monitor, 73 68 notification, 74 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 75 69 statusCode, 76 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 77 70 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 - }) => { 71 + incident, 72 + cronTimestamp, 73 + regions, 74 + latency, 75 + }: NotificationContext) => { 90 76 const notificationData = slackDataSchema.parse(JSON.parse(notification.data)); 91 - const { slack: webhookUrl } = notificationData; // webhook url 92 - const { name } = monitor; 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); 93 91 94 92 await postToWebhook( 95 93 { 96 - blocks: [ 97 - { 98 - type: "divider", 99 - }, 100 - { 101 - type: "section", 102 - text: { 103 - type: "mrkdwn", 104 - text: `*✅ Recovered <${monitor.url}/|${name}>*`, 105 - }, 106 - }, 94 + attachments: [ 107 95 { 108 - type: "context", 109 - elements: [ 110 - { 111 - type: "mrkdwn", 112 - text: "Check your <https://www.openstatus.dev/app|Dashboard>.", 113 - }, 114 - ], 96 + color: COLORS.green, 97 + blocks, 115 98 }, 116 99 ], 117 100 }, ··· 122 105 export const sendDegraded = async ({ 123 106 monitor, 124 107 notification, 125 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 126 108 statusCode, 127 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 128 109 message, 129 - }: { 130 - monitor: Monitor; 131 - notification: Notification; 132 - statusCode?: number; 133 - message?: string; 134 - cronTimestamp: number; 135 - region?: Region; 136 - latency?: number; 137 - }) => { 110 + incident, 111 + cronTimestamp, 112 + regions, 113 + latency, 114 + }: NotificationContext) => { 138 115 const notificationData = slackDataSchema.parse(JSON.parse(notification.data)); 139 - const { slack: webhookUrl } = notificationData; // webhook url 140 - const { name } = monitor; 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); 141 130 142 131 await postToWebhook( 143 132 { 144 - blocks: [ 145 - { 146 - type: "divider", 147 - }, 148 - { 149 - type: "section", 150 - text: { 151 - type: "mrkdwn", 152 - text: `*⚠️ Degraded <${monitor.url}/|${name}>*`, 153 - }, 154 - }, 133 + attachments: [ 155 134 { 156 - type: "context", 157 - elements: [ 158 - { 159 - type: "mrkdwn", 160 - text: "Check your <https://www.openstatus.dev/app|Dashboard>.", 161 - }, 162 - ], 135 + color: COLORS.yellow, 136 + blocks, 163 137 }, 164 138 ], 165 139 }, ··· 168 142 }; 169 143 170 144 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!", 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 + }, 183 158 }, 184 - }, 185 - { 186 - type: "context", 187 - elements: [ 188 - { 159 + { 160 + type: "section", 161 + text: { 189 162 type: "mrkdwn", 190 - text: "Check your <https://www.openstatus.dev/app|Dashboard>.", 163 + text: "`🧪 Your Slack webhook is configured correctly!`", 191 164 }, 192 - ], 193 - }, 194 - ], 195 - }, 196 - webhookUrl, 197 - ); 198 - return true; 199 - } catch (_err) { 200 - return false; 201 - } 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 + ); 202 210 };
+3 -2
packages/notifications/slack/src/mock.ts
··· 29 29 deletedAt: null, 30 30 otelEndpoint: null, 31 31 otelHeaders: [], 32 - retry: null, 33 - followRedirects: null, 32 + retry: 3, 33 + followRedirects: false, 34 + externalName: null, 34 35 }; 35 36 36 37 const notification: Notification = {
+3 -1
packages/notifications/telegram/package.json
··· 3 3 "version": "1.0.0", 4 4 "main": "src/index.ts", 5 5 "scripts": { 6 - "test": "bun test" 6 + "test": "bun test", 7 + "tsc": "tsc" 7 8 }, 8 9 "dependencies": { 9 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 10 12 "zod": "4.1.13" 11 13 }, 12 14 "devDependencies": {
+4 -45
packages/notifications/telegram/src/index.ts
··· 1 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 1 import { telegramDataSchema } from "@openstatus/db/src/schema"; 3 - 4 - import type { Region } from "@openstatus/db/src/schema/constants"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 5 3 6 4 export const sendAlert = async ({ 7 5 monitor, 8 6 notification, 9 7 statusCode, 10 8 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 - }) => { 9 + }: NotificationContext) => { 23 10 const notificationData = telegramDataSchema.parse( 24 11 JSON.parse(notification.data), 25 12 ); ··· 38 25 export const sendRecovery = async ({ 39 26 monitor, 40 27 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 - }) => { 28 + }: NotificationContext) => { 57 29 const notificationData = telegramDataSchema.parse( 58 30 JSON.parse(notification.data), 59 31 ); ··· 69 41 export const sendDegraded = async ({ 70 42 monitor, 71 43 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 - }) => { 44 + }: NotificationContext) => { 86 45 const notificationData = telegramDataSchema.parse( 87 46 JSON.parse(notification.data), 88 47 );
+3 -1
packages/notifications/twillio-sms/package.json
··· 3 3 "version": "0.0.0", 4 4 "main": "src/index.ts", 5 5 "scripts": { 6 - "test": "bun test" 6 + "test": "bun test", 7 + "tsc": "tsc" 7 8 }, 8 9 "dependencies": { 9 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 10 12 "@t3-oss/env-core": "0.13.10", 11 13 "validator": "13.12.0", 12 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 1 import { phoneDataSchema } from "@openstatus/db/src/schema"; 4 - import type { Region } from "@openstatus/db/src/schema/constants"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 5 3 import { env } from "./env"; 6 4 7 5 export const sendAlert = async ({ ··· 9 7 notification, 10 8 statusCode, 11 9 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 - }) => { 10 + }: NotificationContext) => { 24 11 const notificationData = phoneDataSchema.parse(JSON.parse(notification.data)); 25 12 const { name } = monitor; 26 13 ··· 54 41 export const sendRecovery = async ({ 55 42 monitor, 56 43 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 - }) => { 44 + }: NotificationContext) => { 73 45 const notificationData = phoneDataSchema.parse(JSON.parse(notification.data)); 74 46 const { name } = monitor; 75 47 ··· 98 70 export const sendDegraded = async ({ 99 71 monitor, 100 72 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 - }) => { 73 + }: NotificationContext) => { 115 74 const notificationData = phoneDataSchema.parse(JSON.parse(notification.data)); 116 75 const { name } = monitor; 117 76
+3 -1
packages/notifications/twillio-whatsapp/package.json
··· 3 3 "version": "0.0.0", 4 4 "main": "src/index.ts", 5 5 "scripts": { 6 - "test": "bun test" 6 + "test": "bun test", 7 + "tsc": "tsc" 7 8 }, 8 9 "dependencies": { 9 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 10 12 "@t3-oss/env-core": "0.13.10", 11 13 "validator": "13.12.0", 12 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 1 import { whatsappDataSchema } from "@openstatus/db/src/schema"; 3 - import type { Region } from "@openstatus/db/src/schema/constants"; 2 + import type { NotificationContext } from "@openstatus/notification-base"; 4 3 import { env } from "./env"; 5 4 6 5 export const sendAlert = async ({ 7 6 monitor, 8 7 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 - }) => { 8 + }: NotificationContext) => { 23 9 const notificationData = whatsappDataSchema.parse( 24 10 JSON.parse(notification.data), 25 11 ); ··· 51 37 export const sendRecovery = async ({ 52 38 monitor, 53 39 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 - }) => { 40 + }: NotificationContext) => { 70 41 const notificationData = whatsappDataSchema.parse( 71 42 JSON.parse(notification.data), 72 43 ); ··· 98 69 export const sendDegraded = async ({ 99 70 monitor, 100 71 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 - }) => { 72 + }: NotificationContext) => { 115 73 const notificationData = whatsappDataSchema.parse( 116 74 JSON.parse(notification.data), 117 75 );
+3 -1
packages/notifications/webhook/package.json
··· 3 3 "version": "1.0.0", 4 4 "main": "src/index.ts", 5 5 "scripts": { 6 - "test": "bun test" 6 + "test": "bun test", 7 + "tsc": "tsc" 7 8 }, 8 9 "dependencies": { 9 10 "@openstatus/db": "workspace:*", 11 + "@openstatus/notification-base": "workspace:*", 10 12 "@openstatus/utils": "workspace:*", 11 13 "zod": "4.1.13" 12 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"; 1 + import type { NotificationContext } from "@openstatus/notification-base"; 4 2 import { transformHeaders } from "@openstatus/utils"; 5 3 import { PayloadSchema, WebhookSchema } from "./schema"; 6 4 ··· 11 9 statusCode, 12 10 latency, 13 11 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 - }) => { 12 + }: NotificationContext) => { 26 13 const notificationData = WebhookSchema.parse(JSON.parse(notification.data)); 27 14 28 15 const body = PayloadSchema.parse({ ··· 55 42 latency, 56 43 statusCode, 57 44 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 - }) => { 45 + }: NotificationContext) => { 70 46 const notificationData = WebhookSchema.parse(JSON.parse(notification.data)); 71 47 72 48 const body = PayloadSchema.parse({ ··· 88 64 }, 89 65 }); 90 66 if (!res.ok) { 91 - throw new Error(`Failed to send SMS: ${res.statusText}`); 67 + throw new Error(`Failed to send webhook notification: ${res.statusText}`); 92 68 } 93 69 }; 94 70 ··· 99 75 latency, 100 76 statusCode, 101 77 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 - }) => { 78 + }: NotificationContext) => { 112 79 const notificationData = WebhookSchema.parse(JSON.parse(notification.data)); 113 80 114 81 const body = PayloadSchema.parse({ ··· 130 97 }, 131 98 }); 132 99 if (!res.ok) { 133 - throw new Error(`Failed to send SMS: ${res.statusText}`); 100 + throw new Error(`Failed to send webhook notification: ${res.statusText}`); 134 101 } 135 102 }; 136 103
+3 -2
packages/notifications/webhook/tsconfig.json
··· 1 1 { 2 - "extends": "@openstatus/tsconfig/nextjs.json", 3 - "include": ["src", "*.ts"] 2 + "extends": "@openstatus/tsconfig/base.json", 3 + "include": ["src", "*.ts"], 4 + "exclude": ["**/*.test.ts", "**/*.spec.ts"] 4 5 }
+82 -26
pnpm-lock.yaml
··· 73 73 version: 0.15.15 74 74 '@openpanel/nextjs': 75 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) 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 77 '@openstatus/analytics': 78 78 specifier: workspace:* 79 79 version: link:../../packages/analytics ··· 208 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 209 '@sentry/nextjs': 210 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) 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 212 '@stripe/stripe-js': 213 213 specifier: 2.1.6 214 214 version: 2.1.6 ··· 223 223 version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) 224 224 '@trpc/next': 225 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) 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 227 '@trpc/react-query': 228 228 specifier: 11.4.4 229 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 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 257 next-auth: 258 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) 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 260 next-themes: 261 261 specifier: 0.4.6 262 262 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 263 263 nuqs: 264 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) 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 266 random-word-slugs: 267 267 specifier: 0.1.7 268 268 version: 0.1.7 ··· 575 575 version: 0.15.15 576 576 '@openpanel/nextjs': 577 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) 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 579 '@openstatus/analytics': 580 580 specifier: workspace:* 581 581 version: link:../../packages/analytics ··· 665 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 666 '@sentry/nextjs': 667 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) 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 669 '@stripe/stripe-js': 670 670 specifier: 2.1.6 671 671 version: 2.1.6 ··· 680 680 version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) 681 681 '@trpc/next': 682 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) 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 684 '@trpc/react-query': 685 685 specifier: 11.4.4 686 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 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 714 next-auth: 715 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) 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 717 next-plausible: 718 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) 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 720 next-themes: 721 721 specifier: 0.4.6 722 722 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 723 723 nuqs: 724 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) 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 726 react: 727 727 specifier: 19.2.3 728 728 version: 19.2.3 ··· 819 819 version: 0.15.15 820 820 '@openpanel/nextjs': 821 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) 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 823 '@openstatus/analytics': 824 824 specifier: workspace:* 825 825 version: link:../../packages/analytics ··· 894 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 895 '@sentry/nextjs': 896 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) 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 898 '@stripe/stripe-js': 899 899 specifier: 2.1.6 900 900 version: 2.1.6 ··· 921 921 version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) 922 922 '@trpc/next': 923 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) 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 925 '@trpc/react-query': 926 926 specifier: 11.4.4 927 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 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 970 next-auth: 971 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) 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 973 next-mdx-remote: 974 974 specifier: 5.0.0 975 975 version: 5.0.0(@types/react@19.2.2)(react@19.2.3) 976 976 next-plausible: 977 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) 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 979 next-themes: 980 980 specifier: 0.4.6 981 981 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 982 982 nuqs: 983 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) 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 985 random-word-slugs: 986 986 specifier: 0.1.7 987 987 version: 0.1.7 ··· 1109 1109 '@openstatus/emails': 1110 1110 specifier: workspace:* 1111 1111 version: link:../../packages/emails 1112 + '@openstatus/notification-base': 1113 + specifier: workspace:* 1114 + version: link:../../packages/notifications/base 1112 1115 '@openstatus/notification-discord': 1113 1116 specifier: workspace:* 1114 1117 version: link:../../packages/notifications/discord ··· 1359 1362 version: 0.31.4 1360 1363 next-auth: 1361 1364 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) 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) 1363 1366 typescript: 1364 1367 specifier: 5.9.3 1365 1368 version: 5.9.3 ··· 1463 1466 specifier: 5.9.3 1464 1467 version: 5.9.3 1465 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 + 1466 1491 packages/notifications/discord: 1467 1492 dependencies: 1468 1493 '@openstatus/db': 1469 1494 specifier: workspace:* 1470 1495 version: link:../../db 1496 + '@openstatus/notification-base': 1497 + specifier: workspace:* 1498 + version: link:../base 1471 1499 zod: 1472 1500 specifier: 4.1.13 1473 1501 version: 4.1.13 ··· 1493 1521 '@openstatus/emails': 1494 1522 specifier: workspace:* 1495 1523 version: link:../../emails 1524 + '@openstatus/notification-base': 1525 + specifier: workspace:* 1526 + version: link:../base 1496 1527 '@openstatus/regions': 1497 1528 specifier: workspace:* 1498 1529 version: link:../../regions ··· 1542 1573 '@openstatus/db': 1543 1574 specifier: workspace:* 1544 1575 version: link:../../db 1576 + '@openstatus/notification-base': 1577 + specifier: workspace:* 1578 + version: link:../base 1545 1579 zod: 1546 1580 specifier: 4.1.13 1547 1581 version: 4.1.13 ··· 1564 1598 '@openstatus/db': 1565 1599 specifier: workspace:* 1566 1600 version: link:../../db 1601 + '@openstatus/notification-base': 1602 + specifier: workspace:* 1603 + version: link:../base 1567 1604 zod: 1568 1605 specifier: 4.1.13 1569 1606 version: 4.1.13 ··· 1586 1623 '@openstatus/db': 1587 1624 specifier: workspace:* 1588 1625 version: link:../../db 1626 + '@openstatus/notification-base': 1627 + specifier: workspace:* 1628 + version: link:../base 1589 1629 '@t3-oss/env-core': 1590 1630 specifier: 0.13.10 1591 1631 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) ··· 1623 1663 '@openstatus/db': 1624 1664 specifier: workspace:* 1625 1665 version: link:../../db 1666 + '@openstatus/notification-base': 1667 + specifier: workspace:* 1668 + version: link:../base 1626 1669 '@t3-oss/env-core': 1627 1670 specifier: 0.13.10 1628 1671 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) ··· 1660 1703 '@openstatus/db': 1661 1704 specifier: workspace:* 1662 1705 version: link:../../db 1706 + '@openstatus/notification-base': 1707 + specifier: workspace:* 1708 + version: link:../base 1663 1709 zod: 1664 1710 specifier: 4.1.13 1665 1711 version: 4.1.13 ··· 1682 1728 '@openstatus/db': 1683 1729 specifier: workspace:* 1684 1730 version: link:../../db 1731 + '@openstatus/notification-base': 1732 + specifier: workspace:* 1733 + version: link:../base 1685 1734 zod: 1686 1735 specifier: 4.1.13 1687 1736 version: 4.1.13 ··· 1704 1753 '@openstatus/db': 1705 1754 specifier: workspace:* 1706 1755 version: link:../../db 1756 + '@openstatus/notification-base': 1757 + specifier: workspace:* 1758 + version: link:../base 1707 1759 '@t3-oss/env-core': 1708 1760 specifier: 0.13.10 1709 1761 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) ··· 1735 1787 '@openstatus/db': 1736 1788 specifier: workspace:* 1737 1789 version: link:../../db 1790 + '@openstatus/notification-base': 1791 + specifier: workspace:* 1792 + version: link:../base 1738 1793 '@t3-oss/env-core': 1739 1794 specifier: 0.13.10 1740 1795 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) ··· 1766 1821 '@openstatus/db': 1767 1822 specifier: workspace:* 1768 1823 version: link:../../db 1824 + '@openstatus/notification-base': 1825 + specifier: workspace:* 1826 + version: link:../base 1769 1827 '@openstatus/utils': 1770 1828 specifier: workspace:* 1771 1829 version: link:../../utils ··· 1819 1877 typescript: 1820 1878 specifier: 5.9.3 1821 1879 version: 5.9.3 1822 - 1823 - packages/react/dist: {} 1824 1880 1825 1881 packages/regions: 1826 1882 dependencies: ··· 13420 13476 '@openpanel/web': 1.0.1 13421 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) 13422 13478 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)': 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)': 13424 13480 dependencies: 13425 13481 '@openpanel/web': 1.0.1 13426 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) ··· 15447 15503 '@sentry/types': 8.9.2 15448 15504 '@sentry/utils': 8.9.2 15449 15505 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)': 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)': 15451 15507 dependencies: 15452 15508 '@opentelemetry/api': 1.9.0 15453 15509 '@opentelemetry/semantic-conventions': 1.38.0 ··· 16149 16205 '@trpc/server': 11.4.4(typescript@5.9.3) 16150 16206 typescript: 5.9.3 16151 16207 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)': 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)': 16153 16209 dependencies: 16154 16210 '@trpc/client': 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) 16155 16211 '@trpc/server': 11.4.4(typescript@5.9.3) ··· 19619 19675 19620 19676 netmask@2.0.2: {} 19621 19677 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): 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): 19623 19679 dependencies: 19624 19680 '@auth/core': 0.40.0 19625 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) ··· 19638 19694 - '@types/react' 19639 19695 - supports-color 19640 19696 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): 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): 19642 19698 dependencies: 19643 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) 19644 19700 react: 19.2.3 ··· 19735 19791 dependencies: 19736 19792 boolbase: 1.0.0 19737 19793 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): 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): 19739 19795 dependencies: 19740 19796 '@standard-schema/spec': 1.0.0 19741 19797 react: 19.2.3