From bdba117ec3e40920ca6eaf1bb84c7eedf6006299 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:10:21 +0100 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --- .../content/docs/reference/notification.mdx | 5 + apps/web/public/assets/logos/openstatus.jpeg | Bin 0 -> 4445 bytes apps/workflows/.dockerignore | 2 +- apps/workflows/Dockerfile | 21 +- apps/workflows/README.md | 4 +- apps/workflows/dofigen.lock | 73 ++-- apps/workflows/dofigen.yml | 1 + apps/workflows/package.json | 1 + apps/workflows/src/checker/alerting.test.ts | 2 +- apps/workflows/src/checker/alerting.ts | 35 +- apps/workflows/src/checker/index.ts | 42 +- apps/workflows/src/checker/utils.ts | 32 +- apps/workflows/src/incident/index.ts | 2 +- packages/notifications/base/package.json | 23 ++ packages/notifications/base/src/index.ts | 13 + packages/notifications/base/src/types.ts | 41 ++ .../notifications/base/src/utils/colors.ts | 15 + .../base/src/utils/duration.test.ts | 56 +++ .../notifications/base/src/utils/duration.ts | 66 +++ .../base/src/utils/incident.test.ts | 98 +++++ .../notifications/base/src/utils/incident.ts | 53 +++ .../notifications/base/src/utils/message.ts | 122 ++++++ .../notifications/base/src/utils/timestamp.ts | 22 + packages/notifications/base/tsconfig.json | 5 + packages/notifications/discord/package.json | 4 +- packages/notifications/discord/src/embeds.ts | 268 ++++++++++++ .../notifications/discord/src/index.test.ts | 32 +- packages/notifications/discord/src/index.ts | 197 ++++----- packages/notifications/discord/src/mock.ts | 5 + packages/notifications/email/package.json | 4 +- packages/notifications/email/src/index.ts | 52 +-- packages/notifications/email/src/mock.ts | 1 + .../notifications/google-chat/package.json | 4 +- .../notifications/google-chat/src/index.ts | 48 +-- .../notifications/google-chat/src/mock.ts | 1 + packages/notifications/ntfy/package.json | 4 +- packages/notifications/ntfy/src/index.ts | 49 +-- packages/notifications/opsgenie/package.json | 4 +- .../notifications/opsgenie/src/index.test.ts | 32 +- packages/notifications/opsgenie/src/index.ts | 52 +-- packages/notifications/pagerduty/package.json | 4 +- .../notifications/pagerduty/src/index.test.ts | 34 +- packages/notifications/pagerduty/src/index.ts | 51 +-- packages/notifications/slack/package.json | 4 +- packages/notifications/slack/src/blocks.ts | 390 ++++++++++++++++++ .../notifications/slack/src/index.test.ts | 42 +- packages/notifications/slack/src/index.ts | 272 ++++++------ packages/notifications/slack/src/mock.ts | 5 +- packages/notifications/telegram/package.json | 4 +- packages/notifications/telegram/src/index.ts | 49 +-- .../notifications/twillio-sms/package.json | 4 +- .../notifications/twillio-sms/src/index.ts | 49 +-- .../twillio-whatsapp/package.json | 4 +- .../twillio-whatsapp/src/index.ts | 50 +-- packages/notifications/webhook/package.json | 4 +- packages/notifications/webhook/src/index.ts | 45 +- packages/notifications/webhook/tsconfig.json | 5 +- pnpm-lock.yaml | 108 +++-- 58 files changed, 1811 insertions(+), 804 deletions(-) create mode 100644 apps/web/public/assets/logos/openstatus.jpeg create mode 100644 packages/notifications/base/package.json create mode 100644 packages/notifications/base/src/index.ts create mode 100644 packages/notifications/base/src/types.ts create mode 100644 packages/notifications/base/src/utils/colors.ts create mode 100644 packages/notifications/base/src/utils/duration.test.ts create mode 100644 packages/notifications/base/src/utils/duration.ts create mode 100644 packages/notifications/base/src/utils/incident.test.ts create mode 100644 packages/notifications/base/src/utils/incident.ts create mode 100644 packages/notifications/base/src/utils/message.ts create mode 100644 packages/notifications/base/src/utils/timestamp.ts create mode 100644 packages/notifications/base/tsconfig.json create mode 100644 packages/notifications/discord/src/embeds.ts create mode 100644 packages/notifications/slack/src/blocks.ts diff --git a/apps/docs/src/content/docs/reference/notification.mdx b/apps/docs/src/content/docs/reference/notification.mdx index 0a596e7c9..d19cc96d3 100644 --- a/apps/docs/src/content/docs/reference/notification.mdx +++ b/apps/docs/src/content/docs/reference/notification.mdx @@ -17,6 +17,9 @@ Integrates with Slack to send alerts to a designated channel. **Configuration:** - **Incoming Webhook URL:** (Required) A [Slack incoming webhook URL](https://api.slack.com/incoming-webhooks) where notifications will be posted. + **Example**: `https://hooks.slack.com/services/XXX/YYY/ZZZ` + +You can [download the openstatus logo](https://www.openstatus.dev/assets/logos/openstatus.jpeg) to add a custom logo. ### Email @@ -33,6 +36,8 @@ Delivers alerts to a Discord channel via a webhook. - **Webhook URL:** (Required) A [Discord webhook URL](https://support.discord.com/hc/en-us/articles/228383668) for the target channel. **Example:** `https://discordapp.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz1234567890` +You can [download the openstatus logo](https://www.openstatus.dev/assets/logos/openstatus.jpeg) to add a custom logo. + ### Google Chat Sends notifications to a Google Chat space. diff --git a/apps/web/public/assets/logos/openstatus.jpeg b/apps/web/public/assets/logos/openstatus.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..bc0470aaf74388a58ebe6678ecad3271b12c9659 GIT binary patch literal 4445 zcmex=9GX20;#n6AUMq8I>5A1R0qH8UG()kOeuDl@a7#1}NZQVP#|IWMpFge}q8< zSpp*e{}uxeGb00&0J8uC1LIT$1_oBnn5z|qih=9)OjsMb@zevby6w|>3uTpm?*4LC zfq^wu;@+nGx&Im5LnfSxo6f+%z;q}2fj1LKA>;f6rr0Lm)tj}=Cex4 z<>xaOFSEC~AglND*^HOzbFRo5oV3inylDq-;MT_%c|cZy?5#h*ImaVGIc0Kxiau0~ zvzdQcr^11&(sB2S-iCTXMdx2oU!Hn|A=$oFg!4IATTc{nlIjXjR@EP>Bq5ORCYHoFdSEicYS+YU;4Y_JO%~^ zxp`@~g_5SdnE_IiT{}VH-pp5_oTsNdYpFq zjLa;IEQ~D7P+?XE7B)s^b}=SV1p@~GK_L-wL(_o&w-`7W85tN^nf^0y-hTE*ci+j2 z*R;N63m)6Q`^IX^HSSs)Pky|9HRI>*-*qjMYSfJSo*rg-_4Mxd?bT6x_8ohnvnwVy zx%i+_R;B#@Ji|5nb*wc{ZC$VRqvF$gzCfN$FLER7^L*F0yu3E?;-#D2H?z;{cqM&P z`5^dglHuPc9uXp^l64Dp)t|SYmT@`Rr@6CGsS4Z~Bc6uXnjeXVe^}DoK4qG>t^hFC}HXs$fS3&@y%)XswLBR zbMMbN-5)Ep>&4aUZtM0L<|th|rkb-zV_U+PXou9TtkD6XTUoarWY)uJ8|1np1-4G)juG1QS?!F<-WzkoLnnXpxnZj)f9veU+_q^q zGIP^bZCk9JVX&>3J56s}((6>cZBD0?wC3#I_w9N9r;T^Aqx-%+ji_~=^rbMweXa02 z6{~}3Mn4wMm}zelr1L7w$79(;_V4qUF1cAn@>L60KJ9k7cj~_T;4)t#M$}t@iXMlN?w)1Mhewq{eLBbMAe1+V**|?43NqYx3nedVlHN z*j}Ie&Zuu+_=gpB*_;v%&MSl;Na*K*f?dOfQXV&P@<@c zv59kXVbR1%2OkE66gO>jN!oPj1K3hV1~z-9f2?yR{VqIwt7JmZRGZ&+u|7=G`&K=8 zc&7NRqyM#aH|6JQx3nAX-^f}Y133^?8&=y{xj^qXn$PFvF}@! z|EKpEdgu0;JULYokzg`$s!Z;wpjP3q@|XNvp`lyD=T+%fNv&w`!Jlazh%{`~b`_OS+*QN_TZO@{! z*NR8z2YuxCp7hA+5lViJfo*^IR{7C{u7v9>DpJw5B{>(wLbUQ|<_>?3lb zB$ib))UWRDtD{U?J3MFm@4a+*bN#M;g^J6JHkYOIvfYnb&#^mk^&!0*Ss%K$N$p9> zJubCp;oIXKxPIZN$b020iweNYGzl*x!nFL`A3aBUOQ zhgIu7xh(!FnKLKu$nyBYqB#XqPS(CYDLLWRjmK_n_F2I~(O>0Q3ax`ro1JBO=k0lG zSB>tI3%SvrYnDylSW*%BwB!DERU6hylVg87&HgFPi#_{q(|?9O-(B(7{69YJc)n59 z# z)TqAKQaNYaOg{?`rFmahTy(ygx-r*UxFX+n=S10(YnO~tLc`Y0h-O^9Ozc3`%hnB2 zyApHQ*3F2$Q?Pm&f4Pj-%jUa>xppOdPdK!0hV31L)vLtId9+@&f&?Fy7ap3yamHLm zOk-8IQ`5AkVt-U7+&ZFiZvDL%k8kdsU_D>)#%Jc1+(=VVZ7bWY)xNi<#$7IZ8ouT3 z&d|5!c@bB^#A^`iwQ*j=)t9Kma}f8reqO}2BIk25$|s0h)s)oW^#N2Pnxjm zmP@bi956X!v2em^&!py!1T zihKLIjz=l)y6wXk{AbV;S-bDzrbi_)(&9C}1{e1{ zdL?%3>O#v5>pADIW!j(a)+H$kInCvRuwI?{CjBr(&%TFpI%p7*<%`(r&G4LebWVn{|unkkd^==0|N^a zCo8;F#K<71Xy}+Ipx_W#*tqfF2ZMi(#+raO}lI*vqgj@(*`0?_oVa1&&@8_JxezHRrF=; z>!~-2j<)K0oqS(4Z$sNuL;uPvuf^6%5AaWq;3hf8C(JTKJDY z8~+`CqsHCEJ9TEgxNO9`Fn;Qs^l3*Ot!^)4Jao_K+V^`CLZA9R)^KyxT4!~qE?i&I zVOzZHg-H9h7rK*Amxy&f-tDnC*im+QkJ0K|vOrHtDOjd^mJ^)ys9P z%l7V2JKkU3FZZ7F=Y1d zoaZ@LY0({Bme)(41=}pXaI^E8tF5mZ=dOy{$!Q_ci`y@joSnG2;LKtFLhtnUi)N`F z+brahmoA-bv-rx*Zk?IW<=UnnGtwyU>0jlko-!dvTx5d#-sudoTh%8pv{#+7$=*_N zJ>%oL3*z%XF1?uWCE#F+)WpYU{HnM*El(VCTsO5t?DT@~^H$|=E0A7hu6{0Hp`dsF zeL4GkHuZbVrFdr5NR>_JlUe%3Vr%80l}%Zn%zdx1GWZs>9Mg~GZ~bhW7Nq0twyI#q zxyl^>@QJGWd?Na~g{Q4;eNPrP7NzU|h?rl^#5TR~cwvEISNN8rN!wS4U)aC=!_*Cl zn}s%5&ufp5s*X|o&k(#zUOnUNp6N@~ceM3io)b3pg3ph{GwT9+I09vC6#`hU9b^h! zv7}PFxORaEE9>3Y-LqeP2=BJkJs*>b~jdxM=;*9$+HE6Fpjx-QB0$U@WO z&bFuPs`(%4GPX@z|2ch;V9Z7y?=w>}`C=7?r>wtjrL5M*-~aGG1FW6T0BUdwd_HgU zna>17p0{Z@Z?o~N&FAwL!RKv0pVtXKZ!`J4&F3=~htAr3Ja6;iu#LfS5r|3LIz_d2bVK2N*zt8TG&TA#4F^4g~E!t#xl4R=+SJmLG;-L-`A zfvmyJSCgxL$N7I+Z~j)bY~l*FUBa7<-pQWYJ5B%IPUU+WCw$G>%XzJFl^Mh8gs5p# z0+)G6-;%wkQ6*5&dNpSKlBAjo+8xt{tExP?UQW7pJoX(|MAhWA?g2Rq+)mg{n|0Tl z@5$i{tN~(^w0kVtIDIyUFl=A*#i!ikjT+y9rYj)^84f#cPF|#>(Z6t^uW8{M3SCl33FHZXQ#zXL0z}Slf7F6l@>W&k}A4)K+vG}$Jzf3HScX!TM2KP zF)3N7uwN$C$F!oHTgh{=qeOH{#ue2o8YzuecJiDQ5{&3wDfvo|kKN>r%!GJR9k0n} zr@WcAXV<^bZ*vu5lI|yI=X3{OQLvCrCHwr`neNM3+?xt}zGRpDXQVIcRwlo@=S-y3eaH9O1%p&s-XI5z}VmNwN^Y<4f_w@etVXFCY0!d3F zv(#MOdlC*rIBq_m5#(CFLdIue&`Q;Wu)A^TIvtE!7q$cnotl0~?d_j4**eEG4C}Yt zQhnpvmb<_uHs#gg!sMBIj_dOtxx%2Q@X{~wK*yzXEen?%iqKy-uTget=^oFiIb1ve zn>P!u(p>e6t)IP#lQpSYsiP}!o8zIEMoy`_7wNxNQFWj3G}qF==nJdvd8Qr-ZtaDQ zjEpa1?kBKyJdth`WN~ZdD0QBwI(Ob`)q15?E+h3NZ|5>hEz_K~<;o9+o!hN7C)xdY z*Szl6qFwt|3V0n~B(ZH{f`e8{J^NMOdz~+RYLoYW=r@v%c=Ja)FyYI&6)9EfOZCN% z>E7kf6pM^ITKne@qt~VNFRdNJvW{{7bN=o$$&B}B|5II+lhqIRm+I|EpZ7uEghf!C Me~06L2LAsy0iyPpFaQ7m literal 0 HcmV?d00001 diff --git a/apps/workflows/.dockerignore b/apps/workflows/.dockerignore index 5e73b32b9..c304379d7 100644 --- a/apps/workflows/.dockerignore +++ b/apps/workflows/.dockerignore @@ -1,4 +1,4 @@ -# This file is generated by Dofigen v2.6.0 +# This file is generated by Dofigen v2.5.0 # See https://github.com/lenra-io/dofigen node_modules diff --git a/apps/workflows/Dockerfile b/apps/workflows/Dockerfile index b1053b8b9..f713fe1f0 100644 --- a/apps/workflows/Dockerfile +++ b/apps/workflows/Dockerfile @@ -1,14 +1,7 @@ -# syntax=docker/dockerfile:1.19.0 -# This file is generated by Dofigen v2.6.0 +# syntax=docker/dockerfile:1.11 +# This file is generated by Dofigen v2.5.0 # See https://github.com/lenra-io/dofigen -# ca-certs -FROM debian@sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 AS ca-certs -LABEL \ - org.opencontainers.image.base.digest="sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734" \ - org.opencontainers.image.base.name="docker.io/debian:bullseye-slim" -RUN apt update && apt install -y ca-certificates curl && update-ca-certificates - # docker FROM oven/bun@sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a AS docker LABEL \ @@ -34,6 +27,7 @@ RUN \ --mount=type=bind,target=packages/assertions/package.json,source=packages/assertions/package.json \ --mount=type=bind,target=packages/db/package.json,source=packages/db/package.json \ --mount=type=bind,target=packages/emails/package.json,source=packages/emails/package.json \ + --mount=type=bind,target=packages/notifications/base/package.json,source=packages/notifications/base/package.json \ --mount=type=bind,target=packages/notifications/discord/package.json,source=packages/notifications/discord/package.json \ --mount=type=bind,target=packages/notifications/email/package.json,source=packages/notifications/email/package.json \ --mount=type=bind,target=packages/notifications/google-chat/package.json,source=packages/notifications/google-chat/package.json \ @@ -71,6 +65,13 @@ COPY \ "/app/node_modules" "/app/node_modules" RUN bun build --compile --target bun --sourcemap src/index.ts --outfile=app +# ca-certs +FROM debian@sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 AS ca-certs +LABEL \ + org.opencontainers.image.base.digest="sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734" \ + org.opencontainers.image.base.name="docker.io/debian:bullseye-slim" +RUN apt update && apt install -y ca-certificates curl && update-ca-certificates + # libsql FROM oven/bun@sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a AS libsql LABEL \ @@ -86,7 +87,7 @@ RUN bun install # runtime FROM debian@sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 AS runtime LABEL \ - io.dofigen.version="2.6.0" \ + io.dofigen.version="2.5.0" \ org.opencontainers.image.authors="OpenStatus Team" \ org.opencontainers.image.base.digest="sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734" \ org.opencontainers.image.base.name="docker.io/debian:bullseye-slim" \ diff --git a/apps/workflows/README.md b/apps/workflows/README.md index 949e7f029..87025f5fd 100644 --- a/apps/workflows/README.md +++ b/apps/workflows/README.md @@ -30,8 +30,10 @@ The Dockerfile is generated thanks to [Dofigen](https://github.com/lenra-io/dofi To generate the Dockerfile, run the following command from the `apps/workflows` directory: ```bash +# Install Dofigen +cargo install dofigen # Update the dependent image versions dofigen update # Generate the Dockerfile dofigen gen -``` +``` \ No newline at end of file diff --git a/apps/workflows/dofigen.lock b/apps/workflows/dofigen.lock index 7c9e1d934..52ded8cf6 100644 --- a/apps/workflows/dofigen.lock +++ b/apps/workflows/dofigen.lock @@ -12,14 +12,35 @@ effective: | - /packages/error - /packages/tracker builders: + build: + fromImage: + path: oven/bun + digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a + label: + org.opencontainers.image.stage: build + org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 + org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a + workdir: /app/apps/workflows + env: + NODE_ENV: production + copy: + - paths: + - . + target: /app/ + - fromBuilder: install + paths: + - /app/node_modules + target: /app/node_modules + run: + - bun build --compile --target bun --sourcemap src/index.ts --outfile=app install: fromImage: path: oven/bun digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a label: org.opencontainers.image.stage: install - org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a + org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 workdir: /app/ run: - bun install --production --frozen-lockfile --verbose @@ -38,6 +59,8 @@ effective: | source: packages/db/package.json - target: packages/emails/package.json source: packages/emails/package.json + - target: packages/notifications/base/package.json + source: packages/notifications/base/package.json - target: packages/notifications/discord/package.json source: packages/notifications/discord/package.json - target: packages/notifications/email/package.json @@ -72,15 +95,6 @@ effective: | source: packages/upstash/package.json - target: packages/theme-store/package.json source: packages/theme-store/package.json - ca-certs: - fromImage: - path: debian - digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 - label: - org.opencontainers.image.base.digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 - org.opencontainers.image.base.name: docker.io/debian:bullseye-slim - run: - - apt update && apt install -y ca-certificates curl && update-ca-certificates libsql: fromImage: path: oven/bun @@ -96,41 +110,29 @@ effective: | target: /app/package.json run: - bun install - docker: + ca-certs: fromImage: - path: oven/bun - digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a + path: debian + digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 label: - org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a - org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 - workdir: /app/apps/workflows - copy: - - paths: - - . - target: /app/ + org.opencontainers.image.base.name: docker.io/debian:bullseye-slim + org.opencontainers.image.base.digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 run: - - bun run src/build-docker.ts - build: + - apt update && apt install -y ca-certificates curl && update-ca-certificates + docker: fromImage: path: oven/bun digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a label: - org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a - org.opencontainers.image.stage: build org.opencontainers.image.base.name: docker.io/oven/bun:1.3.6 + org.opencontainers.image.base.digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a workdir: /app/apps/workflows - env: - NODE_ENV: production copy: - paths: - . target: /app/ - - fromBuilder: install - paths: - - /app/node_modules - target: /app/node_modules run: - - bun build --compile --target bun --sourcemap src/index.ts --outfile=app + - bun run src/build-docker.ts fromImage: path: debian digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 @@ -138,11 +140,11 @@ effective: | org.opencontainers.image.authors: OpenStatus Team org.opencontainers.image.source: https://github.com/openstatusHQ/openstatus org.opencontainers.image.title: OpenStatus Workflows + io.dofigen.version: 2.5.0 org.opencontainers.image.base.digest: sha256:b32674fb57780ad57d7b0749242d3f585f462f4ec4a60ae0adacd945f9cb9734 - org.opencontainers.image.description: Background job processing and probe scheduling for OpenStatus - io.dofigen.version: 2.6.0 - org.opencontainers.image.vendor: OpenStatus org.opencontainers.image.base.name: docker.io/debian:bullseye-slim + org.opencontainers.image.vendor: OpenStatus + org.opencontainers.image.description: Background job processing and probe scheduling for OpenStatus workdir: /app/ copy: - fromBuilder: build @@ -182,7 +184,7 @@ images: digest: sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a resources: dofigen.yml: - hash: 070adced2c20f3f63d9a803b4494401f9df769d071fca19c29755f75a0127a06 + hash: 6a13d3715d011de108b8e7710153a84e1c2a05a1aaf9f89daf6ae6a7315490f8 content: | ignore: - node_modules @@ -209,6 +211,7 @@ resources: - packages/assertions/package.json - packages/db/package.json - packages/emails/package.json + - packages/notifications/base/package.json - packages/notifications/discord/package.json - packages/notifications/email/package.json - packages/notifications/google-chat/package.json diff --git a/apps/workflows/dofigen.yml b/apps/workflows/dofigen.yml index 03eb93118..401ea0f77 100644 --- a/apps/workflows/dofigen.yml +++ b/apps/workflows/dofigen.yml @@ -23,6 +23,7 @@ builders: - packages/assertions/package.json - packages/db/package.json - packages/emails/package.json + - packages/notifications/base/package.json - packages/notifications/discord/package.json - packages/notifications/email/package.json - packages/notifications/google-chat/package.json diff --git a/apps/workflows/package.json b/apps/workflows/package.json index 77170d2dd..310615f6e 100644 --- a/apps/workflows/package.json +++ b/apps/workflows/package.json @@ -14,6 +14,7 @@ "@logtape/sentry": "2.0.1", "@openstatus/db": "workspace:*", "@openstatus/emails": "workspace:*", + "@openstatus/notification-base": "workspace:*", "@openstatus/notification-discord": "workspace:*", "@openstatus/notification-emails": "workspace:*", "@openstatus/notification-google-chat": "workspace:*", diff --git a/apps/workflows/src/checker/alerting.test.ts b/apps/workflows/src/checker/alerting.test.ts index c03f0fe48..79bc410e4 100644 --- a/apps/workflows/src/checker/alerting.test.ts +++ b/apps/workflows/src/checker/alerting.test.ts @@ -16,7 +16,7 @@ test.todo("should send email notification", async () => { statusCode: 400, notifType: "alert", cronTimestamp: 123456, - incidentId: "1", + incidentId: 1, }); expect(fn).toHaveBeenCalled(); }); diff --git a/apps/workflows/src/checker/alerting.ts b/apps/workflows/src/checker/alerting.ts index 7bcb306c8..d36869868 100644 --- a/apps/workflows/src/checker/alerting.ts +++ b/apps/workflows/src/checker/alerting.ts @@ -1,5 +1,5 @@ import { and, count, db, eq, gte, inArray, schema } from "@openstatus/db"; -import type { MonitorStatus } from "@openstatus/db/src/schema"; +import type { Incident, MonitorStatus } from "@openstatus/db/src/schema"; import { selectMonitorSchema, selectNotificationSchema, @@ -21,7 +21,7 @@ export const triggerNotifications = async ({ notifType, cronTimestamp, incidentId, - region, + regions, latency, }: { monitorId: string; @@ -29,14 +29,29 @@ export const triggerNotifications = async ({ message?: string; notifType: "alert" | "recovery" | "degraded"; cronTimestamp: number; - incidentId: string; - region?: Region; + incidentId?: number; + regions?: string[]; latency?: number; }) => { logger.info("Triggering alerting", { monitor_id: monitorId, notification_type: notifType, }); + + let incident: Incident | undefined; + if (incidentId) { + try { + incident = await db.query.incidentTable.findFirst({ + where: eq(schema.incidentTable.id, incidentId), + }); + } catch (err) { + logger.warn("Failed to fetch incident data", { + incident_id: incidentId, + error_message: err instanceof Error ? err.message : String(err), + }); + } + } + const notifications = await db .select() .from(schema.notificationsToMonitors) @@ -129,9 +144,9 @@ export const triggerNotifications = async ({ notification: selectNotificationSchema.parse(notif.notification), statusCode, message, - incidentId, + incident, cronTimestamp, - region, + regions, latency, }), @@ -161,9 +176,9 @@ export const triggerNotifications = async ({ notification: selectNotificationSchema.parse(notif.notification), statusCode, message, - incidentId, + incident, cronTimestamp, - region, + regions, latency, }), catch: (_unknown) => @@ -192,9 +207,9 @@ export const triggerNotifications = async ({ notification: selectNotificationSchema.parse(notif.notification), statusCode, message, - incidentId, + incident, cronTimestamp, - region, + regions, latency, }), catch: (_unknown) => diff --git a/apps/workflows/src/checker/index.ts b/apps/workflows/src/checker/index.ts index 675f883ed..d7ad49497 100644 --- a/apps/workflows/src/checker/index.ts +++ b/apps/workflows/src/checker/index.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { z } from "zod"; -import { and, count, db, eq, inArray, isNull, schema } from "@openstatus/db"; +import { and, db, eq, inArray, isNull, schema } from "@openstatus/db"; import { incidentTable } from "@openstatus/db/src/schema"; import { monitorStatusSchema, @@ -142,8 +142,9 @@ checkerRoute.post("/updateStatus", async (c) => { const monitor = selectMonitorSchema.parse(currentMonitor); const numberOfRegions = monitor.regions.length; - const affectedRegion = await db - .select({ count: count() }) + // Fetch all affected regions for notifications (single query) + const affectedRegions = await db + .select({ region: schema.monitorStatusTable.region }) .from(schema.monitorStatusTable) .where( and( @@ -152,9 +153,12 @@ checkerRoute.post("/updateStatus", async (c) => { inArray(schema.monitorStatusTable.region, monitor.regions), ), ) - .get(); + .all(); + + const affectedRegionsList = affectedRegions.map((r) => r.region); + const affectedRegionCount = affectedRegionsList.length; - if (!affectedRegion?.count) { + if (affectedRegionCount === 0) { return c.json({ success: true }, 200); } @@ -203,7 +207,7 @@ checkerRoute.post("/updateStatus", async (c) => { break; } - if (affectedRegion.count >= numberOfRegions / 2 || numberOfRegions === 1) { + if (affectedRegionCount >= numberOfRegions / 2 || numberOfRegions === 1) { switch (status) { case "active": { if (monitor.status === "active") { @@ -219,8 +223,9 @@ checkerRoute.post("/updateStatus", async (c) => { .set({ status: "active" }) .where(eq(schema.monitor.id, monitor.id)); + let incident = null; if (monitor.status === "error") { - await resolveIncident({ monitorId, cronTimestamp }); + incident = await resolveIncident({ monitorId, cronTimestamp }); } await triggerNotifications({ @@ -229,9 +234,9 @@ checkerRoute.post("/updateStatus", async (c) => { message, notifType: "recovery", cronTimestamp, - region, + regions: affectedRegionsList, latency, - incidentId: `${cronTimestamp}`, + incidentId: incident?.id, }); break; @@ -251,6 +256,14 @@ checkerRoute.post("/updateStatus", async (c) => { .set({ status: "degraded" }) .where(eq(schema.monitor.id, monitor.id)); + let incident = null; + if (monitor.status === "error") { + incident = await resolveIncident({ + monitorId, + cronTimestamp, + }); + } + await triggerNotifications({ monitorId, statusCode, @@ -258,13 +271,10 @@ checkerRoute.post("/updateStatus", async (c) => { notifType: "degraded", cronTimestamp, latency, - region, - incidentId: `${cronTimestamp}`, + regions: affectedRegionsList, + incidentId: incident?.id, }); - if (monitor.status === "error") { - await resolveIncident({ monitorId, cronTimestamp }); - } break; case "error": if (monitor.status === "error") { @@ -317,8 +327,8 @@ checkerRoute.post("/updateStatus", async (c) => { notifType: "alert", cronTimestamp, latency, - region, - incidentId: String(newIncident.id), + regions: affectedRegionsList, + incidentId: newIncident.id, }); } catch (error) { logger.warning("Failed to create incident", { error }); diff --git a/apps/workflows/src/checker/utils.ts b/apps/workflows/src/checker/utils.ts index a1245d180..6bd18ed6c 100644 --- a/apps/workflows/src/checker/utils.ts +++ b/apps/workflows/src/checker/utils.ts @@ -1,9 +1,5 @@ -import type { - Monitor, - Notification, - NotificationProvider, -} from "@openstatus/db/src/schema"; -import type { Region } from "@openstatus/db/src/schema/constants"; +import type { NotificationProvider } from "@openstatus/db/src/schema"; +import type { NotificationContext } from "@openstatus/notification-base"; import { sendAlert as sendDiscordAlert, sendDegraded as sendDiscordDegraded, @@ -60,25 +56,7 @@ import { sendRecovery as sendWebhookRecovery, } from "@openstatus/notification-webhook"; -type SendNotification = ({ - monitor, - notification, - statusCode, - message, - incidentId, - cronTimestamp, - latency, - region, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => Promise; +type SendNotification = (props: NotificationContext) => Promise; type Notif = { sendAlert: SendNotification; @@ -86,7 +64,7 @@ type Notif = { sendDegraded: SendNotification; }; -export const providerToFunction = { +export const providerToFunction: Record = { discord: { sendAlert: sendDiscordAlert, sendRecovery: sendDiscordRecovery, @@ -142,4 +120,4 @@ export const providerToFunction = { sendRecovery: sendTelegramRecovery, sendDegraded: sendTelegramDegraded, }, -} satisfies Record; +}; diff --git a/apps/workflows/src/incident/index.ts b/apps/workflows/src/incident/index.ts index 251c21e95..ad37afb62 100644 --- a/apps/workflows/src/incident/index.ts +++ b/apps/workflows/src/incident/index.ts @@ -19,7 +19,7 @@ incidentRoute.get("/cleanup", async (c) => { const unresolvedIncidentMonitorIds = db .select({ monitorId: schema.incidentTable.monitorId }) .from(schema.incidentTable) - .where(and(isNull(schema.incidentTable.resolvedAt))); + .where(isNull(schema.incidentTable.resolvedAt)); const activeMonitorsWithUnresolvedIncidents = await db .select({ id: schema.monitor.id }) diff --git a/packages/notifications/base/package.json b/packages/notifications/base/package.json new file mode 100644 index 000000000..7c021bd4a --- /dev/null +++ b/packages/notifications/base/package.json @@ -0,0 +1,23 @@ +{ + "name": "@openstatus/notification-base", + "version": "0.0.1", + "description": "Shared types and utilities for OpenStatus notification providers", + "main": "src/index.ts", + "scripts": { + "test": "bun test", + "tsc": "tsc" + }, + "dependencies": { + "@openstatus/db": "workspace:*", + "@openstatus/regions": "workspace:*" + }, + "devDependencies": { + "@openstatus/tsconfig": "workspace:*", + "@types/node": "24.0.8", + "bun-types": "1.3.1", + "typescript": "5.9.3" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/packages/notifications/base/src/index.ts b/packages/notifications/base/src/index.ts new file mode 100644 index 000000000..d9650cf6f --- /dev/null +++ b/packages/notifications/base/src/index.ts @@ -0,0 +1,13 @@ +// Types +export type { + NotificationContext, + FormattedMessageData, + NotificationType, +} from "./types"; + +// Utilities +export { formatDuration, calculateDuration } from "./utils/duration"; +export { formatTimestamp } from "./utils/timestamp"; +export { getIncidentDuration } from "./utils/incident"; +export { formatStatusCode, buildCommonMessageData } from "./utils/message"; +export { COLORS, COLOR_DECIMALS } from "./utils/colors"; diff --git a/packages/notifications/base/src/types.ts b/packages/notifications/base/src/types.ts new file mode 100644 index 000000000..00e6a0988 --- /dev/null +++ b/packages/notifications/base/src/types.ts @@ -0,0 +1,41 @@ +import type { + Incident, + Monitor, + Notification, +} from "@openstatus/db/src/schema"; + +/** + * Common context passed to all notification providers + */ +export interface NotificationContext { + monitor: Monitor; + notification: Notification; + statusCode?: number; + message?: string; + cronTimestamp: number; + regions?: string[]; + latency?: number; + incident?: Incident; +} + +/** + * Formatted common message data ready for rendering + */ +export interface FormattedMessageData { + monitorName: string; + monitorUrl: string; + monitorMethod?: string; + monitorJobType: string; + statusCodeFormatted: string; + errorMessage: string; + timestampFormatted: string; + regionsDisplay: string; + latencyDisplay: string; + dashboardUrl: string; + incidentDuration?: string; +} + +/** + * Notification type discriminator + */ +export type NotificationType = "alert" | "recovery" | "degraded"; diff --git a/packages/notifications/base/src/utils/colors.ts b/packages/notifications/base/src/utils/colors.ts new file mode 100644 index 000000000..037ef042e --- /dev/null +++ b/packages/notifications/base/src/utils/colors.ts @@ -0,0 +1,15 @@ +type Color = "red" | "yellow" | "green" | "blue"; + +export const COLORS = { + red: "#e7000b", // Alert/Error - red left border + yellow: "#f49f1e", // Degraded/Warning - yellow/orange left border + green: "#20c45f", // Recovery/Success - green left border + blue: "#3a81f6", // Monitoring - blue left border +} as const satisfies Record; + +export const COLOR_DECIMALS = { + red: 15138827, // Alert/Error - red left border + yellow: 16031518, // Degraded/Warning - yellow/orange left border + green: 2147423, // Recovery/Success - green left border + blue: 3834358, // Monitoring - blue left border +} as const satisfies Record; diff --git a/packages/notifications/base/src/utils/duration.test.ts b/packages/notifications/base/src/utils/duration.test.ts new file mode 100644 index 000000000..e036c71f5 --- /dev/null +++ b/packages/notifications/base/src/utils/duration.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test"; +import { calculateDuration, formatDuration } from "./duration"; + +describe("formatDuration", () => { + it("formats short durations (seconds only)", () => { + expect(formatDuration(30000)).toBe("30s"); + }); + + it("formats medium durations (minutes and seconds)", () => { + expect(formatDuration(135000)).toBe("2m 15s"); + }); + + it("formats long durations (hours, minutes, seconds)", () => { + expect(formatDuration(8130000)).toBe("2h 15m 30s"); + }); + + it("formats very long durations (days and hours)", () => { + expect(formatDuration(90000000)).toBe("1d 1h"); + }); + + it("handles 0ms", () => { + expect(formatDuration(0)).toBe("0s"); + }); + + it("handles negative values", () => { + expect(formatDuration(-1000)).toBe("0s"); + }); + + it("handles sub-second durations", () => { + expect(formatDuration(500)).toBe("0s"); + }); + + it("respects maxUnits option", () => { + expect(formatDuration(90061000, { maxUnits: 2 })).toBe("1d 1h"); + expect(formatDuration(90061000, { maxUnits: 1 })).toBe("1d"); + }); + + it("only shows non-zero units", () => { + expect(formatDuration(3600000)).toBe("1h"); + expect(formatDuration(60000)).toBe("1m"); + expect(formatDuration(86400000)).toBe("1d"); + }); +}); + +describe("calculateDuration", () => { + it("calculates duration between two dates", () => { + const start = new Date("2026-01-22T10:00:00Z"); + const end = new Date("2026-01-22T12:15:30Z"); + expect(calculateDuration(start, end)).toBe(8130000); + }); + + it("handles same start and end", () => { + const date = new Date("2026-01-22T10:00:00Z"); + expect(calculateDuration(date, date)).toBe(0); + }); +}); diff --git a/packages/notifications/base/src/utils/duration.ts b/packages/notifications/base/src/utils/duration.ts new file mode 100644 index 000000000..023457810 --- /dev/null +++ b/packages/notifications/base/src/utils/duration.ts @@ -0,0 +1,66 @@ +/** + * Format milliseconds to human-readable duration + * + * @example + * formatDuration(30000) // "30s" + * formatDuration(135000) // "2m 15s" + * formatDuration(8130000) // "2h 15m 30s" + * formatDuration(90000000) // "1d 1h" + * + * @param durationMs - Duration in milliseconds + * @param options - Formatting options + * @param options.maxUnits - Max number of units to show (default: 3) + * @returns Formatted duration string + */ +export function formatDuration( + durationMs: number, + options?: { + maxUnits?: number; + }, +): string { + const maxUnits = options?.maxUnits ?? 3; + + if (durationMs < 0) { + return "0s"; + } + + const totalSeconds = Math.floor(durationMs / 1000); + + if (totalSeconds === 0) { + return "0s"; + } + + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts: string[] = []; + + if (days > 0) { + parts.push(`${days}d`); + } + if (hours > 0) { + parts.push(`${hours}h`); + } + if (minutes > 0) { + parts.push(`${minutes}m`); + } + if (seconds > 0) { + parts.push(`${seconds}s`); + } + + // Take only the first maxUnits parts + return parts.slice(0, maxUnits).join(" "); +} + +/** + * Calculate duration between two timestamps + * + * @param start - Start date + * @param end - End date + * @returns Duration in milliseconds + */ +export function calculateDuration(start: Date, end: Date): number { + return end.getTime() - start.getTime(); +} diff --git a/packages/notifications/base/src/utils/incident.test.ts b/packages/notifications/base/src/utils/incident.test.ts new file mode 100644 index 000000000..f016771e7 --- /dev/null +++ b/packages/notifications/base/src/utils/incident.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "bun:test"; +import type { Incident } from "@openstatus/db/src/schema"; +import { getIncidentDuration } from "./incident"; + +// Helper to create a partial incident object for testing +function createIncident(overrides: Partial): Incident { + return { + id: 1, + monitorId: 1, + workspaceId: 1, + startedAt: null, + resolvedAt: null, + autoResolved: false, + acknowledgedAt: null, + acknowledgedBy: null, + ...overrides, + } as Incident; +} + +describe("getIncidentDuration", () => { + it("returns null when incident.startedAt is missing", () => { + const incident = createIncident({ + startedAt: null, + resolvedAt: new Date("2026-01-22T12:00:00Z"), + }); + expect(getIncidentDuration(incident)).toBe(null); + }); + + it("returns null when incident.resolvedAt is null (ongoing incident)", () => { + const incident = createIncident({ + startedAt: new Date("2026-01-22T10:00:00Z"), + resolvedAt: null, + }); + expect(getIncidentDuration(incident)).toBe(null); + }); + + it("calculates correct duration for resolved incident", () => { + // 2h 15m 30s = 8130000ms + const incident = createIncident({ + startedAt: new Date("2026-01-22T10:00:00Z"), + resolvedAt: new Date("2026-01-22T12:15:30Z"), + }); + expect(getIncidentDuration(incident)).toBe("2h 15m 30s"); + }); + + it("calculates duration with Date objects", () => { + const incident = createIncident({ + startedAt: new Date("2026-01-22T10:00:00Z"), + resolvedAt: new Date("2026-01-22T10:30:00Z"), + }); + expect(getIncidentDuration(incident)).toBe("30m"); + }); + + it("calculates duration with timestamp numbers", () => { + // 5 minutes = 300000ms + const startTime = new Date("2026-01-22T10:00:00Z").getTime(); + const endTime = new Date("2026-01-22T10:05:00Z").getTime(); + const incident = createIncident({ + startedAt: startTime as unknown as Date, + resolvedAt: endTime as unknown as Date, + }); + expect(getIncidentDuration(incident)).toBe("5m"); + }); + + it("handles very short durations (less than a minute)", () => { + const incident = createIncident({ + startedAt: new Date("2026-01-22T10:00:00Z"), + resolvedAt: new Date("2026-01-22T10:00:45Z"), + }); + expect(getIncidentDuration(incident)).toBe("45s"); + }); + + it("handles very long durations (over a day)", () => { + // 1 day, 2 hours = 26 hours = 93600000ms + const incident = createIncident({ + startedAt: new Date("2026-01-21T10:00:00Z"), + resolvedAt: new Date("2026-01-22T12:00:00Z"), + }); + expect(getIncidentDuration(incident)).toBe("1d 2h"); + }); + + it("handles same start and end time (0 duration)", () => { + const timestamp = new Date("2026-01-22T10:00:00Z"); + const incident = createIncident({ + startedAt: timestamp, + resolvedAt: timestamp, + }); + expect(getIncidentDuration(incident)).toBe("0s"); + }); + + it("returns null when resolvedAt is before startedAt (negative duration)", () => { + const incident = createIncident({ + startedAt: new Date("2026-01-22T12:00:00Z"), + resolvedAt: new Date("2026-01-22T10:00:00Z"), + }); + expect(getIncidentDuration(incident)).toBe(null); + }); +}); diff --git a/packages/notifications/base/src/utils/incident.ts b/packages/notifications/base/src/utils/incident.ts new file mode 100644 index 000000000..b349fe475 --- /dev/null +++ b/packages/notifications/base/src/utils/incident.ts @@ -0,0 +1,53 @@ +import type { Incident } from "@openstatus/db/src/schema"; +import { formatDuration } from "./duration"; + +/** + * Get formatted incident duration + * + * Returns duration string only if incident is resolved (has resolvedAt timestamp). + * Returns null for ongoing incidents or if startedAt is missing. + * + * @example + * // Resolved incident + * getIncidentDuration({ + * startedAt: new Date('2026-01-22T10:00:00Z'), + * resolvedAt: new Date('2026-01-22T12:15:30Z') + * }) // "2h 15m 30s" + * + * // Ongoing incident + * getIncidentDuration({ + * startedAt: new Date('2026-01-22T10:00:00Z'), + * resolvedAt: null + * }) // null + * + * @param incident - The incident object + * @returns Formatted duration string or null if incident is not resolved + */ +export function getIncidentDuration(incident: Incident): string | null { + if (!incident.startedAt) { + return null; + } + + // Only calculate duration for resolved incidents + if (!incident.resolvedAt) { + return null; + } + + const startTime = + incident.startedAt instanceof Date + ? incident.startedAt.getTime() + : incident.startedAt; + + const endTime = + incident.resolvedAt instanceof Date + ? incident.resolvedAt.getTime() + : incident.resolvedAt; + + const durationMs = endTime - startTime; + + if (durationMs < 0) { + return null; + } + + return formatDuration(durationMs); +} diff --git a/packages/notifications/base/src/utils/message.ts b/packages/notifications/base/src/utils/message.ts new file mode 100644 index 000000000..1777eeaa0 --- /dev/null +++ b/packages/notifications/base/src/utils/message.ts @@ -0,0 +1,122 @@ +import type { Incident } from "@openstatus/db/src/schema"; +import { getRegionInfo } from "@openstatus/regions"; +import type { FormattedMessageData, NotificationContext } from "../types"; +import { getIncidentDuration } from "./incident"; +import { formatTimestamp } from "./timestamp"; + +/** + * Common HTTP status descriptions + */ +const statusDescriptions: Record = { + 200: "OK", + 201: "Created", + 204: "No Content", + 301: "Moved Permanently", + 302: "Found", + 304: "Not Modified", + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 408: "Request Timeout", + 429: "Too Many Requests", + 500: "Internal Server Error", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", +}; + +/** + * Format status code for display with human-readable description + * + * @example + * formatStatusCode(503) // "503 Service Unavailable" + * formatStatusCode(404) // "404 Not Found" + * formatStatusCode(418) // "418" (no description available) + * formatStatusCode(undefined) // "Unknown" + * + * @param statusCode - HTTP status code + * @returns Formatted status code string + */ +export function formatStatusCode(statusCode?: number): string { + if (!statusCode) { + return "Unknown"; + } + + const description = statusDescriptions[statusCode]; + return description ? `${statusCode} ${description}` : `${statusCode}`; +} + +/** + * Build common formatted message data from notification context + * + * Centralizes formatting logic used by all providers to ensure consistency. + * + * @example + * const data = buildCommonMessageData(context); + * // Returns: { + * // monitorName: "My API", + * // monitorUrl: "https://api.example.com", + * // monitorMethod: "GET", + * // monitorJobType: "http", + * // statusCodeFormatted: "503 Service Unavailable", + * // errorMessage: "Connection timeout", + * // timestampFormatted: "Jan 22, 2026 at 14:30 UTC", + * // regionsDisplay: "ams, fra, syd", + * // latencyDisplay: "2,450ms", + * // dashboardUrl: "https://app.openstatus.dev/monitors/123", + * // incidentDuration: undefined + * // } + * + * @param context - Notification context with monitor and event data + * @param options - Optional configuration + * @param options.incident - Include incident data for duration calculation + * @returns Formatted message data ready for rendering + */ +export function buildCommonMessageData( + context: NotificationContext, + options?: { + incident?: Incident; + }, +): FormattedMessageData { + const { monitor, statusCode, message, cronTimestamp, regions, latency } = + context; + + // Format multiple regions as comma-separated list + let regionsDisplay = "Unknown"; + if (regions && regions.length > 0) { + if (regions.length === 1) { + // Single region: show code and location + const regionInfo = getRegionInfo(regions[0]); + regionsDisplay = regionInfo + ? `${regionInfo.code} (${regionInfo.location})` + : regions[0]; + } else { + // Multiple regions: show comma-separated codes + regionsDisplay = regions.join(", "); + } + } + + // Calculate incident duration only if incident is resolved + let incidentDuration: string | undefined; + if (options?.incident?.resolvedAt) { + const duration = getIncidentDuration(options.incident); + incidentDuration = duration ?? undefined; + } + + return { + monitorName: monitor.name, + monitorUrl: monitor.url, + monitorMethod: monitor.method ?? undefined, + monitorJobType: monitor.jobType, + statusCodeFormatted: formatStatusCode(statusCode), + errorMessage: message || "No error message available", + timestampFormatted: formatTimestamp(cronTimestamp), + regionsDisplay, + latencyDisplay: + typeof latency === "number" ? `${latency.toLocaleString()}ms` : "N/A", + dashboardUrl: `https://app.openstatus.dev/monitors/${monitor.id}`, + incidentDuration, + }; +} diff --git a/packages/notifications/base/src/utils/timestamp.ts b/packages/notifications/base/src/utils/timestamp.ts new file mode 100644 index 000000000..a08c2df30 --- /dev/null +++ b/packages/notifications/base/src/utils/timestamp.ts @@ -0,0 +1,22 @@ +/** + * Format cron timestamp (epoch ms) to ISO string + * + * @example + * formatTimestamp(1737553800000) // "2026-01-22T14:30:00.000Z" + * + * @param cronTimestamp - Epoch timestamp in milliseconds + * @returns Formatted timestamp string to ISO string + */ +export function formatTimestamp(cronTimestamp: number): string { + if (!cronTimestamp || !Number.isFinite(cronTimestamp)) { + return "Unknown"; + } + + const date = new Date(cronTimestamp); + + if (Number.isNaN(date.getTime())) { + return "Unknown"; + } + + return date.toISOString(); +} diff --git a/packages/notifications/base/tsconfig.json b/packages/notifications/base/tsconfig.json new file mode 100644 index 000000000..fc180ffce --- /dev/null +++ b/packages/notifications/base/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@openstatus/tsconfig/base.json", + "include": ["src", "*.ts"], + "exclude": ["**/*.test.ts"] +} diff --git a/packages/notifications/discord/package.json b/packages/notifications/discord/package.json index 7b4d3088c..85c9971ff 100644 --- a/packages/notifications/discord/package.json +++ b/packages/notifications/discord/package.json @@ -3,10 +3,12 @@ "version": "1.0.0", "main": "src/index.ts", "scripts": { - "test": "bun test" + "test": "bun test", + "tsc": "tsc" }, "dependencies": { "@openstatus/db": "workspace:*", + "@openstatus/notification-base": "workspace:*", "zod": "4.1.13" }, "devDependencies": { diff --git a/packages/notifications/discord/src/embeds.ts b/packages/notifications/discord/src/embeds.ts new file mode 100644 index 000000000..71f9580a1 --- /dev/null +++ b/packages/notifications/discord/src/embeds.ts @@ -0,0 +1,268 @@ +import { + COLOR_DECIMALS, + type FormattedMessageData, +} from "@openstatus/notification-base"; + +/** + * Discord Embed structure for webhook messages + * Reference: https://birdie0.github.io/discord-webhooks-guide/structure/embeds.html + */ + +interface DiscordEmbedField { + name: string; + value: string; + inline?: boolean; +} + +interface DiscordEmbedFooter { + text: string; +} + +export interface DiscordEmbed { + title: string; + description: string; + color: number; + fields: DiscordEmbedField[]; + timestamp: string; + footer: DiscordEmbedFooter; + url: string; +} + +/** + * Builds Discord embed for alert notifications + * + * Layout: + * - Title: "{monitor.name} is failing" + * - Description: "METHOD URL" in code format (e.g., `GET https://api.example.com`) + * - Color: Red (#ED4245 / 15548997) + * - Fields: Status Code, Regions, Latency, Cron Timestamp (inline), Error Message in code block (full width) + * - Timestamp: ISO 8601 format + * - Footer: "openstatus" + * - URL: Dashboard link + * + * @param data - Formatted message data from buildCommonMessageData + * @returns DiscordEmbed object ready for webhook payload + * + * @example + * const embed = buildAlertEmbed({ + * monitorName: "API Health", + * monitorUrl: "https://api.example.com", + * statusCodeFormatted: "503 Service Unavailable", + * errorMessage: "Connection timeout", + * timestampFormatted: "Jan 22, 2026 at 14:30 UTC", + * regionDisplay: "iad (Virginia)", + * latencyDisplay: "1,234 ms", + * dashboardUrl: "https://app.openstatus.dev/monitors/123" + * }); + */ +export function buildAlertEmbed(data: FormattedMessageData): DiscordEmbed { + // Format description as "METHOD URL" or just "URL" for non-HTTP + const description = + data.monitorMethod && data.monitorJobType === "http" + ? `${data.monitorMethod} ${data.monitorUrl}` + : data.monitorUrl; + + return { + title: `${data.monitorName} is failing`, + description: `\`${description}\``, + color: COLOR_DECIMALS.red, + fields: [ + { + name: "Status Code", + value: data.statusCodeFormatted, + inline: true, + }, + { + name: "Regions", + value: data.regionsDisplay, + inline: true, + }, + { + name: "Latency", + value: data.latencyDisplay, + inline: true, + }, + { + name: "Cron Timestamp", + value: data.timestampFormatted, + inline: true, + }, + { + name: "Error Message", + value: `\`\`\`${data.errorMessage}\`\`\``, + inline: false, + }, + ], + timestamp: new Date().toISOString(), + footer: { + text: "openstatus", + }, + url: data.dashboardUrl, + }; +} + +/** + * Builds Discord embed for recovery notifications + * + * Layout: + * - Title: "{monitor.name} is recovered" + * - Description: "METHOD URL" in code format (e.g., `GET https://api.example.com`) + * - Color: Green (#57F287 / 5763719) + * - Fields: Optional Downtime field (if incidentDuration exists), Status Code, Regions, Latency, Cron Timestamp (inline) + * - Timestamp: ISO 8601 format + * - Footer: "openstatus" + * - URL: Dashboard link + * + * @param data - Formatted message data from buildCommonMessageData + * @returns DiscordEmbed object ready for webhook payload + * + * @example + * const embed = buildRecoveryEmbed({ + * monitorName: "API Health", + * monitorUrl: "https://api.example.com", + * statusCodeFormatted: "200 OK", + * errorMessage: "", + * timestampFormatted: "Jan 22, 2026 at 14:35 UTC", + * regionDisplay: "iad (Virginia)", + * latencyDisplay: "156 ms", + * dashboardUrl: "https://app.openstatus.dev/monitors/123", + * incidentDuration: "5m 30s" + * }); + */ +export function buildRecoveryEmbed(data: FormattedMessageData): DiscordEmbed { + const fields: DiscordEmbedField[] = []; + + // Format description as "METHOD URL" or just "URL" for non-HTTP + const description = + data.monitorMethod && data.monitorJobType === "http" + ? `${data.monitorMethod} ${data.monitorUrl}` + : data.monitorUrl; + + // Add downtime field if incident duration is available + if (data.incidentDuration) { + fields.push({ + name: "⏱️ Downtime", + value: data.incidentDuration, + inline: false, + }); + } + + // Add status fields (inline for 3-column layout) + fields.push( + { + name: "Status Code", + value: data.statusCodeFormatted, + inline: true, + }, + { + name: "Regions", + value: data.regionsDisplay, + inline: true, + }, + { + name: "Latency", + value: data.latencyDisplay, + inline: true, + }, + { + name: "Cron Timestamp", + value: data.timestampFormatted, + inline: true, + }, + ); + + return { + title: `${data.monitorName} is recovered`, + description: `\`${description}\``, + color: COLOR_DECIMALS.green, + fields, + timestamp: new Date().toISOString(), + footer: { + text: "openstatus", + }, + url: data.dashboardUrl, + }; +} + +/** + * Builds Discord embed for degraded notifications + * + * Layout: + * - Title: "{monitor.name} is degraded" + * - Description: "METHOD URL" in code format (e.g., `GET https://api.example.com`) + * - Color: Yellow (#FEE75C / 16705372) + * - Fields: Optional Previous Incident Duration field (if incidentDuration exists), Status Code, Regions, Latency, Cron Timestamp (inline) + * - Timestamp: ISO 8601 format + * - Footer: "openstatus" + * - URL: Dashboard link + * + * @param data - Formatted message data from buildCommonMessageData + * @returns DiscordEmbed object ready for webhook payload + * + * @example + * const embed = buildDegradedEmbed({ + * monitorName: "API Health", + * monitorUrl: "https://api.example.com", + * statusCodeFormatted: "504 Gateway Timeout", + * errorMessage: "Slow response", + * timestampFormatted: "Jan 22, 2026 at 14:40 UTC", + * regionDisplay: "iad (Virginia)", + * latencyDisplay: "5,234 ms", + * dashboardUrl: "https://app.openstatus.dev/monitors/123", + * incidentDuration: "2h 15m" + * }); + */ +export function buildDegradedEmbed(data: FormattedMessageData): DiscordEmbed { + const fields: DiscordEmbedField[] = []; + + // Format description as "METHOD URL" or just "URL" for non-HTTP + const description = + data.monitorMethod && data.monitorJobType === "http" + ? `${data.monitorMethod} ${data.monitorUrl}` + : data.monitorUrl; + + // Add previous incident duration field if available + if (data.incidentDuration) { + fields.push({ + name: "⏱️ Previous Incident Duration", + value: data.incidentDuration, + inline: false, + }); + } + + // Add status fields (inline for 3-column layout) + fields.push( + { + name: "Status Code", + value: data.statusCodeFormatted, + inline: true, + }, + { + name: "Regions", + value: data.regionsDisplay, + inline: true, + }, + { + name: "Latency", + value: data.latencyDisplay, + inline: true, + }, + { + name: "Cron Timestamp", + value: data.timestampFormatted, + inline: true, + }, + ); + + return { + title: `${data.monitorName} is degraded`, + description: `\`${description}\``, + color: COLOR_DECIMALS.yellow, + fields, + timestamp: new Date().toISOString(), + footer: { + text: "openstatus", + }, + url: data.dashboardUrl, + }; +} diff --git a/packages/notifications/discord/src/index.test.ts b/packages/notifications/discord/src/index.test.ts index cc9b47cb1..f485ca034 100644 --- a/packages/notifications/discord/src/index.test.ts +++ b/packages/notifications/discord/src/index.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { selectNotificationSchema } from "@openstatus/db/src/schema"; +import { COLOR_DECIMALS } from "@openstatus/notification-base"; import { sendAlert, sendDegraded, @@ -70,9 +71,10 @@ describe("Discord Notifications", () => { expect(callArgs[1].headers["Content-Type"]).toBe("application/json"); const body = JSON.parse(callArgs[1].body); - expect(body.content).toContain("🚨 Alert"); - expect(body.content).toContain("API Health Check"); - expect(body.content).toContain("Something went wrong"); + expect(body.embeds).toBeDefined(); + expect(body.embeds[0].title).toContain("is failing"); + expect(body.embeds[0].title).toContain("API Health Check"); + expect(body.embeds[0].color).toBe(COLOR_DECIMALS.red); expect(body.username).toBe("OpenStatus Notifications"); expect(body.avatar_url).toBeDefined(); }); @@ -95,8 +97,10 @@ describe("Discord Notifications", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const callArgs = fetchMock.mock.calls[0]; const body = JSON.parse(callArgs[1].body); - expect(body.content).toContain("✅ Recovered"); - expect(body.content).toContain("API Health Check"); + expect(body.embeds).toBeDefined(); + expect(body.embeds[0].title).toContain("is recovered"); + expect(body.embeds[0].title).toContain("API Health Check"); + expect(body.embeds[0].color).toBe(COLOR_DECIMALS.green); }); test("Send Degraded", async () => { @@ -117,28 +121,26 @@ describe("Discord Notifications", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const callArgs = fetchMock.mock.calls[0]; const body = JSON.parse(callArgs[1].body); - expect(body.content).toContain("⚠️ Degraded"); - expect(body.content).toContain("API Health Check"); + expect(body.embeds).toBeDefined(); + expect(body.embeds[0].title).toContain("is degraded"); + expect(body.embeds[0].title).toContain("API Health Check"); + expect(body.embeds[0].color).toBe(COLOR_DECIMALS.yellow); // Yellow color }); test("Send Test Discord Message", async () => { const webhookUrl = "https://discord.com/api/webhooks/123456789/abcdefgh"; + await sendTestDiscordMessage(webhookUrl); - const result = await sendTestDiscordMessage(webhookUrl); - - expect(result).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); const callArgs = fetchMock.mock.calls[0]; expect(callArgs[0]).toBe(webhookUrl); const body = JSON.parse(callArgs[1].body); - expect(body.content).toContain("🧪 Test"); - expect(body.content).toContain("OpenStatus"); + expect(body.embeds).toBeDefined(); + expect(body.embeds[0].title).toContain("Test Notification"); }); test("Send Test Discord Message with empty webhookUrl", async () => { - const result = await sendTestDiscordMessage(""); - - expect(result).toBe(false); + expect(sendTestDiscordMessage("")).rejects.toThrow(); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/packages/notifications/discord/src/index.ts b/packages/notifications/discord/src/index.ts index 65192b3f9..02bda1232 100644 --- a/packages/notifications/discord/src/index.ts +++ b/packages/notifications/discord/src/index.ts @@ -1,15 +1,28 @@ -import type { Monitor, Notification } from "@openstatus/db/src/schema"; import { discordDataSchema } from "@openstatus/db/src/schema"; -import type { Region } from "@openstatus/db/src/schema/constants"; +import { + COLOR_DECIMALS, + type NotificationContext, + buildCommonMessageData, +} from "@openstatus/notification-base"; +import { + type DiscordEmbed, + buildAlertEmbed, + buildDegradedEmbed, + buildRecoveryEmbed, +} from "./embeds"; + +const postToWebhook = async (embeds: DiscordEmbed[], webhookUrl: string) => { + if (!webhookUrl || webhookUrl.trim() === "") { + throw new Error("Discord webhook URL is required"); + } -const postToWebhook = async (content: string, webhookUrl: string) => { const res = await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - content, + embeds, avatar_url: "https://img.stackshare.io/service/104872/default_dc6948366d9bae553adbb8f51252eafbc5d2043a.jpg", username: "OpenStatus Notifications", @@ -28,122 +41,120 @@ export const sendAlert = async ({ statusCode, message, cronTimestamp, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { + latency, + regions, +}: NotificationContext) => { const notificationData = discordDataSchema.parse( JSON.parse(notification.data), ); - const { discord: webhookUrl } = notificationData; // webhook url - const { name } = monitor; + const { discord: webhookUrl } = notificationData; - try { - await postToWebhook( - `**🚨 Alert [${name}](<${monitor.url}>)**\nStatus Code: ${ - statusCode || "_empty_" - }\nMessage: ${ - message || "_empty_" - }\nCron Timestamp: ${cronTimestamp} (${new Date( - cronTimestamp, - ).toISOString()})\n> Check your [Dashboard]().\n`, - webhookUrl, - ); - } catch (err) { - console.error(err); - throw err; - } + const context = { + monitor, + notification, + statusCode, + message, + cronTimestamp, + latency, + regions, + }; + + const data = buildCommonMessageData(context); + const embed = buildAlertEmbed(data); + + await postToWebhook([embed], webhookUrl); }; export const sendRecovery = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: statusCode, - // biome-ignore lint/correctness/noUnusedVariables: message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { + incident, + cronTimestamp, + latency, + regions, +}: NotificationContext) => { const notificationData = discordDataSchema.parse( JSON.parse(notification.data), ); - const { discord: webhookUrl } = notificationData; // webhook url - const { name } = monitor; + const { discord: webhookUrl } = notificationData; - try { - await postToWebhook( - `**✅ Recovered [${name}](<${monitor.url}>)**\n> Check your [Dashboard]().\n`, - webhookUrl, - ); - } catch (err) { - console.error(err); - throw err; - } + const context = { + monitor, + notification, + statusCode, + message, + cronTimestamp, + latency, + regions, + }; + + const data = buildCommonMessageData(context, { incident }); + const embed = buildRecoveryEmbed(data); + + await postToWebhook([embed], webhookUrl); }; export const sendDegraded = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: statusCode, - // biome-ignore lint/correctness/noUnusedVariables: message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { + incident, + cronTimestamp, + latency, + regions, +}: NotificationContext) => { const notificationData = discordDataSchema.parse( JSON.parse(notification.data), ); - const { discord: webhookUrl } = notificationData; // webhook url - const { name } = monitor; + const { discord: webhookUrl } = notificationData; - try { - await postToWebhook( - `**⚠️ Degraded [${name}](<${monitor.url}>)**\n> Check your [Dashboard]().\n`, - webhookUrl, - ); - } catch (err) { - console.error(err); - throw err; - } + const context = { + monitor, + notification, + statusCode, + message, + cronTimestamp, + latency, + regions, + }; + + const data = buildCommonMessageData(context, { incident }); + const embed = buildDegradedEmbed(data); + + await postToWebhook([embed], webhookUrl); }; export const sendTestDiscordMessage = async (webhookUrl: string) => { - if (!webhookUrl) { - return false; - } - try { - await postToWebhook( - "**🧪 Test [OpenStatus]()**\nIf you can read this, your Discord webhook is functioning correctly!\n> Check your [Dashboard]().\n", - webhookUrl, - ); - return true; - } catch (_err) { - return false; - } + const testEmbed: DiscordEmbed = { + title: "Test Notification", + description: "🧪 Your Discord webhook is configured correctly!", + color: COLOR_DECIMALS.green, + fields: [ + { + name: "Status", + value: "Webhook Connected", + inline: true, + }, + { + name: "Type", + value: "Test Notification", + inline: true, + }, + { + name: "Next Steps", + value: + "You will receive notifications here when your monitors trigger fail, recover, or degrades.", + inline: false, + }, + ], + timestamp: new Date().toISOString(), + footer: { + text: "openstatus", + }, + url: "https://www.openstatus.dev/app/", + }; + await postToWebhook([testEmbed], webhookUrl); }; diff --git a/packages/notifications/discord/src/mock.ts b/packages/notifications/discord/src/mock.ts index 85f272781..165fd93a0 100644 --- a/packages/notifications/discord/src/mock.ts +++ b/packages/notifications/discord/src/mock.ts @@ -27,6 +27,11 @@ const monitor: Monitor = { status: "active", method: "GET", deletedAt: null, + externalName: null, + otelEndpoint: null, + otelHeaders: [], + retry: 3, + followRedirects: false, }; const notification: Notification = { diff --git a/packages/notifications/email/package.json b/packages/notifications/email/package.json index 2af86f367..771871eaa 100644 --- a/packages/notifications/email/package.json +++ b/packages/notifications/email/package.json @@ -4,11 +4,13 @@ "main": "src/index.ts", "description": "Log drains Vercel integration.", "scripts": { - "test": "bun test" + "test": "bun test", + "tsc": "tsc" }, "dependencies": { "@openstatus/db": "workspace:*", "@openstatus/emails": "workspace:*", + "@openstatus/notification-base": "workspace:*", "@openstatus/regions": "workspace:*", "@openstatus/tinybird": "workspace:*", "@openstatus/utils": "workspace:*", diff --git a/packages/notifications/email/src/index.ts b/packages/notifications/email/src/index.ts index a7bb4a786..ecd7f0e08 100644 --- a/packages/notifications/email/src/index.ts +++ b/packages/notifications/email/src/index.ts @@ -1,11 +1,7 @@ -import { - type Monitor, - type Notification, - emailDataSchema, -} from "@openstatus/db/src/schema"; - +import { emailDataSchema } from "@openstatus/db/src/schema"; import type { Region } from "@openstatus/db/src/schema/constants"; import { EmailClient } from "@openstatus/emails/src/client"; +import type { NotificationContext } from "@openstatus/notification-base"; import { regionDict } from "@openstatus/regions"; import { env } from "../env"; @@ -16,17 +12,10 @@ export const sendAlert = async ({ message, cronTimestamp, latency, - region, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - region?: Region; - latency?: number; -}) => { + regions, +}: NotificationContext) => { + // Convert regions array to single region for backwards compatibility + const region = regions?.[0] as Region | undefined; const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); const config = emailDataSchema.safeParse(JSON.parse(notification.data)); @@ -51,18 +40,11 @@ export const sendRecovery = async ({ notification, statusCode, cronTimestamp, - region, + regions, latency, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - region?: Region; - latency?: number; -}) => { +}: NotificationContext) => { + // Convert regions array to single region for backwards compatibility + const region = regions?.[0] as Region | undefined; const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); const config = emailDataSchema.safeParse(JSON.parse(notification.data)); @@ -86,17 +68,11 @@ export const sendDegraded = async ({ notification, statusCode, cronTimestamp, - region, + regions, latency, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - cronTimestamp: number; - region?: Region; - latency?: number; -}) => { +}: NotificationContext) => { + // Convert regions array to single region for backwards compatibility + const region = regions?.[0] as Region | undefined; const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); const config = emailDataSchema.safeParse(JSON.parse(notification.data)); diff --git a/packages/notifications/email/src/mock.ts b/packages/notifications/email/src/mock.ts index 19f80deb5..3f3e4c486 100644 --- a/packages/notifications/email/src/mock.ts +++ b/packages/notifications/email/src/mock.ts @@ -26,6 +26,7 @@ const monitor: Monitor = { otelHeaders: [], followRedirects: false, retry: 3, + externalName: null, }; const notification: Notification = { diff --git a/packages/notifications/google-chat/package.json b/packages/notifications/google-chat/package.json index 6ffb251c1..e4c5fb805 100644 --- a/packages/notifications/google-chat/package.json +++ b/packages/notifications/google-chat/package.json @@ -3,10 +3,12 @@ "version": "1.0.0", "main": "src/index.ts", "scripts": { - "test": "bun test" + "test": "bun test", + "tsc": "tsc" }, "dependencies": { "@openstatus/db": "workspace:*", + "@openstatus/notification-base": "workspace:*", "zod": "4.1.13" }, "devDependencies": { diff --git a/packages/notifications/google-chat/src/index.ts b/packages/notifications/google-chat/src/index.ts index 396d72885..a4b5febd4 100644 --- a/packages/notifications/google-chat/src/index.ts +++ b/packages/notifications/google-chat/src/index.ts @@ -1,6 +1,5 @@ -import type { Monitor, Notification } from "@openstatus/db/src/schema"; import { googleChatDataSchema } from "@openstatus/db/src/schema"; -import type { Region } from "@openstatus/db/src/schema/constants"; +import type { NotificationContext } from "@openstatus/notification-base"; const postToWebhook = async (content: string, webhookUrl: string) => { const res = await fetch(webhookUrl, { @@ -25,16 +24,7 @@ export const sendAlert = async ({ statusCode, message, cronTimestamp, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = googleChatDataSchema.parse( JSON.parse(notification.data), ); @@ -61,22 +51,7 @@ export const sendAlert = async ({ export const sendRecovery = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: - statusCode, - // biome-ignore lint/correctness/noUnusedVariables: - message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = googleChatDataSchema.parse( JSON.parse(notification.data), ); @@ -97,22 +72,7 @@ export const sendRecovery = async ({ export const sendDegraded = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: - statusCode, - // biome-ignore lint/correctness/noUnusedVariables: - message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = googleChatDataSchema.parse( JSON.parse(notification.data), ); diff --git a/packages/notifications/google-chat/src/mock.ts b/packages/notifications/google-chat/src/mock.ts index 1134b2449..f2886b5a6 100644 --- a/packages/notifications/google-chat/src/mock.ts +++ b/packages/notifications/google-chat/src/mock.ts @@ -26,6 +26,7 @@ const monitor: Monitor = { otelHeaders: [], followRedirects: true, retry: 3, + externalName: null, }; const notification: Notification = { diff --git a/packages/notifications/ntfy/package.json b/packages/notifications/ntfy/package.json index 3a6634679..0f4d9eec2 100644 --- a/packages/notifications/ntfy/package.json +++ b/packages/notifications/ntfy/package.json @@ -3,10 +3,12 @@ "version": "1.0.0", "main": "src/index.ts", "scripts": { - "test": "bun test" + "test": "bun test", + "tsc": "tsc" }, "dependencies": { "@openstatus/db": "workspace:*", + "@openstatus/notification-base": "workspace:*", "zod": "4.1.13" }, "devDependencies": { diff --git a/packages/notifications/ntfy/src/index.ts b/packages/notifications/ntfy/src/index.ts index 55698c872..0f8f35900 100644 --- a/packages/notifications/ntfy/src/index.ts +++ b/packages/notifications/ntfy/src/index.ts @@ -1,25 +1,12 @@ -import type { Monitor, Notification } from "@openstatus/db/src/schema"; - import { ntfyDataSchema } from "@openstatus/db/src/schema"; -import type { Region } from "@openstatus/db/src/schema/constants"; +import type { NotificationContext } from "@openstatus/notification-base"; export const sendAlert = async ({ monitor, notification, statusCode, message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = ntfyDataSchema.parse(JSON.parse(notification.data)); const { name } = monitor; @@ -50,22 +37,7 @@ export const sendAlert = async ({ export const sendRecovery = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: - statusCode, - // biome-ignore lint/correctness/noUnusedVariables: - message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = ntfyDataSchema.parse(JSON.parse(notification.data)); const { name } = monitor; @@ -93,20 +65,7 @@ export const sendRecovery = async ({ export const sendDegraded = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: - statusCode, - // biome-ignore lint/correctness/noUnusedVariables: - message, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = ntfyDataSchema.parse(JSON.parse(notification.data)); const { name } = monitor; diff --git a/packages/notifications/opsgenie/package.json b/packages/notifications/opsgenie/package.json index 43b3d7ccf..c71753019 100644 --- a/packages/notifications/opsgenie/package.json +++ b/packages/notifications/opsgenie/package.json @@ -3,10 +3,12 @@ "version": "0.0.0", "main": "src/index.ts", "scripts": { - "test": "bun test" + "test": "bun test", + "tsc": "tsc" }, "dependencies": { "@openstatus/db": "workspace:*", + "@openstatus/notification-base": "workspace:*", "@t3-oss/env-core": "0.13.10", "@types/validator": "13.12.0", "validator": "13.12.0", diff --git a/packages/notifications/opsgenie/src/index.test.ts b/packages/notifications/opsgenie/src/index.test.ts index bfa3cf0af..b1493bde8 100644 --- a/packages/notifications/opsgenie/src/index.test.ts +++ b/packages/notifications/opsgenie/src/index.test.ts @@ -46,11 +46,22 @@ describe("OpsGenie Notifications", () => { }), }); + const createMockIncident = () => ({ + id: 1, + title: "API Health Check is down", + summary: "API Health Check is down", + status: "triage" as const, + monitorId: "monitor-1", + workspaceId: 1, + startedAt: Date.now(), + }); + test("Send Alert with US region", async () => { const monitor = createMockMonitor(); const notification = selectNotificationSchema.parse( createMockNotification("us"), ); + const incident = createMockIncident(); await sendAlert({ // @ts-expect-error @@ -58,7 +69,8 @@ describe("OpsGenie Notifications", () => { notification, statusCode: 500, message: "Something went wrong", - incidentId: "incident-123", + // @ts-expect-error + incident, cronTimestamp: Date.now(), }); @@ -71,7 +83,7 @@ describe("OpsGenie Notifications", () => { const body = JSON.parse(callArgs[1].body); expect(body.message).toBe("API Health Check is down"); - expect(body.alias).toBe("monitor-1}-incident-123"); + expect(body.alias).toBe("monitor-1"); expect(body.details.severity).toBe("down"); expect(body.details.status).toBe(500); expect(body.details.message).toBe("Something went wrong"); @@ -82,14 +94,15 @@ describe("OpsGenie Notifications", () => { const notification = selectNotificationSchema.parse( createMockNotification("eu"), ); - + const incident = createMockIncident(); await sendAlert({ // @ts-expect-error monitor, notification, statusCode: 500, message: "Error", - incidentId: "incident-456", + // @ts-expect-error + incident, cronTimestamp: Date.now(), }); @@ -103,14 +116,15 @@ describe("OpsGenie Notifications", () => { const notification = selectNotificationSchema.parse( createMockNotification(), ); - + const incident = createMockIncident(); await sendDegraded({ // @ts-expect-error monitor, notification, statusCode: 503, message: "Service degraded", - incidentId: "incident-789", + // @ts-expect-error + incident, cronTimestamp: Date.now(), }); @@ -118,7 +132,7 @@ describe("OpsGenie Notifications", () => { const callArgs = fetchMock.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body.details.severity).toBe("degraded"); - expect(body.message).toBe("API Health Check is down"); + expect(body.message).toBe("API Health Check is degraded"); }); test("Handle fetch error gracefully", async () => { @@ -130,7 +144,7 @@ describe("OpsGenie Notifications", () => { const notification = selectNotificationSchema.parse( createMockNotification(), ); - + const incident = createMockIncident(); expect( sendAlert({ // @ts-expect-error @@ -138,6 +152,8 @@ describe("OpsGenie Notifications", () => { notification, statusCode: 500, message: "Error", + // @ts-expect-error + incident, cronTimestamp: Date.now(), }), ).rejects.toThrow(); diff --git a/packages/notifications/opsgenie/src/index.ts b/packages/notifications/opsgenie/src/index.ts index 8c8bd8008..e72025e6b 100644 --- a/packages/notifications/opsgenie/src/index.ts +++ b/packages/notifications/opsgenie/src/index.ts @@ -1,5 +1,4 @@ -import type { Monitor, Notification } from "@openstatus/db/src/schema"; -import type { Region } from "@openstatus/db/src/schema/constants"; +import type { NotificationContext } from "@openstatus/notification-base"; import { OpsGeniePayloadAlert, OpsGenieSchema } from "./schema"; export const sendAlert = async ({ @@ -7,23 +6,12 @@ export const sendAlert = async ({ notification, statusCode, message, - incidentId, - cronTimestamp, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); const { name } = monitor; const event = OpsGeniePayloadAlert.parse({ - alias: `${monitor.id}}-${incidentId}`, + alias: `${monitor.id}`, message: `${name} is down`, description: message, details: { @@ -58,23 +46,13 @@ export const sendDegraded = async ({ notification, statusCode, message, - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); const { name } = monitor; const event = OpsGeniePayloadAlert.parse({ - alias: `${monitor.id}}-${incidentId}`, - message: `${name} is down`, + alias: `${monitor.id}`, + message: `${name} is degraded`, description: message, details: { message, @@ -107,26 +85,16 @@ export const sendRecovery = async ({ notification, statusCode, message, - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); const url = opsgenie.region === "eu" - ? `https://api.eu.opsgenie.com/v2/alerts/${monitor.id}}-${incidentId}/close` - : `https://api.opsgenie.com/v2/alerts/${monitor.id}}-${incidentId}/close`; + ? `https://api.eu.opsgenie.com/v2/alerts/${monitor.id}/close` + : `https://api.opsgenie.com/v2/alerts/${monitor.id}/close`; const event = OpsGeniePayloadAlert.parse({ - alias: `${monitor.id}}-${incidentId}`, + alias: `${monitor.id}`, message: `${monitor.name} has recovered`, description: message, details: { diff --git a/packages/notifications/pagerduty/package.json b/packages/notifications/pagerduty/package.json index 9dfe9e635..a03234082 100644 --- a/packages/notifications/pagerduty/package.json +++ b/packages/notifications/pagerduty/package.json @@ -3,10 +3,12 @@ "version": "0.0.0", "main": "src/index.ts", "scripts": { - "test": "bun test" + "test": "bun test", + "tsc": "tsc" }, "dependencies": { "@openstatus/db": "workspace:*", + "@openstatus/notification-base": "workspace:*", "@t3-oss/env-core": "0.13.10", "@types/validator": "13.12.0", "validator": "13.12.0", diff --git a/packages/notifications/pagerduty/src/index.test.ts b/packages/notifications/pagerduty/src/index.test.ts index edcf64725..a1ec51bb0 100644 --- a/packages/notifications/pagerduty/src/index.test.ts +++ b/packages/notifications/pagerduty/src/index.test.ts @@ -31,6 +31,16 @@ describe("PagerDuty Notifications", () => { region: "us-east-1", }); + const createMockIncident = () => ({ + id: 1, + title: "API Health Check is down", + summary: "API Health Check is down", + status: "triage" as const, + monitorId: "monitor-1", + workspaceId: 1, + startedAt: Date.now(), + }); + const createMockNotification = () => ({ id: 1, name: "PagerDuty Notification", @@ -46,14 +56,16 @@ describe("PagerDuty Notifications", () => { const notification = selectNotificationSchema.parse( createMockNotification(), ); + const incident = createMockIncident(); await sendAlert({ // @ts-expect-error monitor, notification, + // @ts-expect-error + incident, statusCode: 500, message: "Something went wrong", - incidentId: "incident-123", cronTimestamp: Date.now(), }); @@ -64,7 +76,7 @@ describe("PagerDuty Notifications", () => { const body = JSON.parse(callArgs[1].body); expect(body.routing_key).toBe("my_key"); - expect(body.dedup_key).toBe("monitor-1}-incident-123"); + expect(body.dedup_key).toBe("monitor-1"); expect(body.event_action).toBe("trigger"); expect(body.payload.summary).toBe("API Health Check is down"); expect(body.payload.severity).toBe("error"); @@ -83,6 +95,7 @@ describe("PagerDuty Notifications", () => { updatedAt: new Date(), 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\\"}}"}', }); + const incident = createMockIncident(); await sendAlert({ // @ts-expect-error @@ -90,7 +103,8 @@ describe("PagerDuty Notifications", () => { notification, statusCode: 500, message: "Error", - incidentId: "incident-456", + // @ts-expect-error + incident, cronTimestamp: Date.now(), }); @@ -104,6 +118,7 @@ describe("PagerDuty Notifications", () => { const notification = selectNotificationSchema.parse( createMockNotification(), ); + const incident = createMockIncident(); await sendDegraded({ // @ts-expect-error @@ -111,6 +126,8 @@ describe("PagerDuty Notifications", () => { notification, statusCode: 503, message: "Service degraded", + // @ts-expect-error + incident, cronTimestamp: Date.now(), }); @@ -119,7 +136,7 @@ describe("PagerDuty Notifications", () => { const body = JSON.parse(callArgs[1].body); expect(body.payload.summary).toBe("API Health Check is degraded"); expect(body.payload.severity).toBe("warning"); - expect(body.dedup_key).toBe("monitor-1}"); + expect(body.dedup_key).toBe("monitor-1"); }); test("Send Recovery", async () => { @@ -127,6 +144,7 @@ describe("PagerDuty Notifications", () => { const notification = selectNotificationSchema.parse( createMockNotification(), ); + const incident = createMockIncident(); await sendRecovery({ // @ts-expect-error @@ -134,7 +152,8 @@ describe("PagerDuty Notifications", () => { notification, statusCode: 200, message: "Service recovered", - incidentId: "incident-123", + // @ts-expect-error + incident, cronTimestamp: Date.now(), }); @@ -143,7 +162,7 @@ describe("PagerDuty Notifications", () => { expect(callArgs[0]).toBe("https://events.pagerduty.com/v2/enqueue"); const body = JSON.parse(callArgs[1].body); expect(body.routing_key).toBe("my_key"); - expect(body.dedup_key).toBe("monitor-1}-incident-123"); + expect(body.dedup_key).toBe("monitor-1"); expect(body.event_action).toBe("resolve"); }); @@ -189,6 +208,7 @@ describe("PagerDuty Notifications", () => { const notification = selectNotificationSchema.parse( createMockNotification(), ); + const incident = createMockIncident(); expect( sendAlert({ @@ -197,6 +217,8 @@ describe("PagerDuty Notifications", () => { notification, statusCode: 500, message: "Error", + // @ts-expect-error + incident, cronTimestamp: Date.now(), }), ).rejects.toThrow(); diff --git a/packages/notifications/pagerduty/src/index.ts b/packages/notifications/pagerduty/src/index.ts index 148c93762..f9251e5e7 100644 --- a/packages/notifications/pagerduty/src/index.ts +++ b/packages/notifications/pagerduty/src/index.ts @@ -1,10 +1,5 @@ -import { - type Monitor, - type Notification, - pagerdutyDataSchema, -} from "@openstatus/db/src/schema"; - -import type { Region } from "@openstatus/db/src/schema/constants"; +import { pagerdutyDataSchema } from "@openstatus/db/src/schema"; +import type { NotificationContext } from "@openstatus/notification-base"; import { PagerDutySchema, resolveEventPayloadSchema, @@ -16,18 +11,8 @@ export const sendAlert = async ({ notification, statusCode, message, - incidentId, cronTimestamp, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const data = pagerdutyDataSchema.parse(JSON.parse(notification.data)); const notificationData = PagerDutySchema.parse(JSON.parse(data.pagerduty)); @@ -38,7 +23,7 @@ export const sendAlert = async ({ const { integration_key } = integrationKey; const event = triggerEventPayloadSchema.parse({ routing_key: integration_key, - dedup_key: `${monitor.id}}-${incidentId}`, + dedup_key: `${monitor.id}`, event_action: "trigger", payload: { summary: `${name} is down`, @@ -67,16 +52,7 @@ export const sendDegraded = async ({ notification, statusCode, message, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const data = pagerdutyDataSchema.parse(JSON.parse(notification.data)); const notificationData = PagerDutySchema.parse(JSON.parse(data.pagerduty)); @@ -87,7 +63,7 @@ export const sendDegraded = async ({ const event = triggerEventPayloadSchema.parse({ routing_key: integration_key, - dedup_key: `${monitor.id}}`, + dedup_key: `${monitor.id}`, event_action: "trigger", payload: { summary: `${name} is degraded`, @@ -115,17 +91,8 @@ export const sendDegraded = async ({ export const sendRecovery = async ({ monitor, notification, - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { + incident, +}: NotificationContext) => { const data = pagerdutyDataSchema.parse(JSON.parse(notification.data)); const notificationData = PagerDutySchema.parse(JSON.parse(data.pagerduty)); @@ -133,7 +100,7 @@ export const sendRecovery = async ({ for (const integrationKey of notificationData.integration_keys) { const event = resolveEventPayloadSchema.parse({ routing_key: integrationKey.integration_key, - dedup_key: `${monitor.id}}-${incidentId}`, + dedup_key: `${monitor.id}`, event_action: "resolve", }); const res = await fetch("https://events.pagerduty.com/v2/enqueue", { diff --git a/packages/notifications/slack/package.json b/packages/notifications/slack/package.json index 6c9314984..f3f6a1a4b 100644 --- a/packages/notifications/slack/package.json +++ b/packages/notifications/slack/package.json @@ -3,10 +3,12 @@ "version": "0.0.0", "main": "src/index.ts", "scripts": { - "test": "bun test" + "test": "bun test", + "tsc": "tsc" }, "dependencies": { "@openstatus/db": "workspace:*", + "@openstatus/notification-base": "workspace:*", "zod": "4.1.13" }, "devDependencies": { diff --git a/packages/notifications/slack/src/blocks.ts b/packages/notifications/slack/src/blocks.ts new file mode 100644 index 000000000..e38d1aa06 --- /dev/null +++ b/packages/notifications/slack/src/blocks.ts @@ -0,0 +1,390 @@ +import type { FormattedMessageData } from "@openstatus/notification-base"; + +/** + * Slack Block types for rich message formatting + * Reference: https://docs.slack.dev/messaging/formatting-message-text + */ + +interface SlackTextObject { + type: "plain_text" | "mrkdwn"; + text: string; + emoji?: boolean; +} + +interface SlackHeaderBlock { + type: "header"; + text: SlackTextObject; +} + +interface SlackSectionBlock { + type: "section"; + text?: SlackTextObject; + fields?: SlackTextObject[]; + accessory?: SlackButtonElement; +} + +interface SlackDividerBlock { + type: "divider"; +} + +interface SlackActionsBlock { + type: "actions"; + elements: SlackButtonElement[]; +} + +interface SlackButtonElement { + type: "button"; + text: SlackTextObject; + url?: string; + action_id?: string; +} + +type SlackBlock = + | SlackHeaderBlock + | SlackSectionBlock + | SlackDividerBlock + | SlackActionsBlock; + +/** + * Escapes special characters for Slack mrkdwn format + * Reference: https://api.slack.com/reference/surfaces/formatting#escaping + * + * @param text - Text to escape + * @returns Escaped text safe for Slack mrkdwn + * + * @example + * escapeSlackText("Hello & ") // "Hello & <world>" + */ +export function escapeSlackText(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +/** + * Builds Slack blocks for alert notifications + * + * Layout: + * - Header: "{monitor.name} is failing" + * - Section: "METHOD URL" in code format (e.g., `GET https://api.example.com`) + * - Divider + * - Section: 4 fields in 2x2 grid (Status, Regions, Latency, Cron Timestamp) + * - Section: Error message in code block + * - Actions: Dashboard button + * + * @param data - Formatted message data from buildCommonMessageData + * @returns Array of Slack blocks + * + * @example + * const blocks = buildAlertBlocks({ + * monitorName: "API Health", + * monitorUrl: "https://api.example.com", + * monitorMethod: "GET", + * monitorJobType: "http", + * statusCodeFormatted: "503 Service Unavailable", + * errorMessage: "Connection timeout", + * timestampFormatted: "Jan 22, 2026 at 14:30 UTC", + * regionsDisplay: "iad, fra, syd", + * latencyDisplay: "1,234 ms", + * dashboardUrl: "https://app.openstatus.dev/monitors/123" + * }); + */ +export function buildAlertBlocks(data: FormattedMessageData): SlackBlock[] { + const escapedName = escapeSlackText(data.monitorName); + const escapedError = escapeSlackText(data.errorMessage); + + // Format description as "METHOD URL" or just "URL" for non-HTTP + const description = + data.monitorMethod && data.monitorJobType === "http" + ? `${data.monitorMethod} <${data.monitorUrl}|${data.monitorUrl}>` + : `<${data.monitorUrl}|${data.monitorUrl}>`; + + return [ + { + type: "header", + text: { + type: "plain_text", + text: `${escapedName} is failing`, + emoji: false, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `\`${description}\``, + }, + }, + { + type: "divider", + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Status*\n${data.statusCodeFormatted}`, + }, + { + type: "mrkdwn", + text: `*Regions*\n${data.regionsDisplay}`, + }, + { + type: "mrkdwn", + text: `*Latency*\n${data.latencyDisplay}`, + }, + { + type: "mrkdwn", + text: `*Cron Timestamp*\n${data.timestampFormatted}`, + }, + ], + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `*Error*\n\`\`\`${escapedError}\`\`\``, + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View Dashboard", + emoji: true, + }, + url: data.dashboardUrl, + action_id: "view_dashboard", + }, + ], + }, + ]; +} + +/** + * Builds Slack blocks for recovery notifications + * + * Layout: + * - Header: "{monitor.name} is recovered" + * - Section: "METHOD URL" in code format (e.g., `GET https://api.example.com`) + * - Section: Downtime duration (optional, only if data.incidentDuration exists) + * - Divider + * - Section: 4 fields in 2x2 grid (Status, Regions, Latency, Cron Timestamp) + * - Actions: Dashboard button + * + * @param data - Formatted message data from buildCommonMessageData + * @returns Array of Slack blocks + * + * @example + * const blocks = buildRecoveryBlocks({ + * monitorName: "API Health", + * monitorUrl: "https://api.example.com", + * monitorMethod: "GET", + * monitorJobType: "http", + * statusCodeFormatted: "200 OK", + * errorMessage: "", + * timestampFormatted: "Jan 22, 2026 at 14:35 UTC", + * regionsDisplay: "iad, fra, syd", + * latencyDisplay: "156 ms", + * dashboardUrl: "https://app.openstatus.dev/monitors/123", + * incidentDuration: "5m 30s" + * }); + */ +export function buildRecoveryBlocks(data: FormattedMessageData): SlackBlock[] { + const escapedName = escapeSlackText(data.monitorName); + + // Format description as "METHOD URL" or just "URL" for non-HTTP + const description = + data.monitorMethod && data.monitorJobType === "http" + ? `${data.monitorMethod} <${data.monitorUrl}|${data.monitorUrl}>` + : `<${data.monitorUrl}|${data.monitorUrl}>`; + + const blocks: SlackBlock[] = [ + { + type: "header", + text: { + type: "plain_text", + text: `${escapedName} is recovered`, + emoji: false, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `\`${description}\``, + }, + }, + ]; + + // Only include downtime if incident duration is available + if (data.incidentDuration) { + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `⏱️ *Downtime:* ${data.incidentDuration}`, + }, + }); + } + + blocks.push( + { + type: "divider", + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Status*\n${data.statusCodeFormatted}`, + }, + { + type: "mrkdwn", + text: `*Regions*\n${data.regionsDisplay}`, + }, + { + type: "mrkdwn", + text: `*Latency*\n${data.latencyDisplay}`, + }, + { + type: "mrkdwn", + text: `*Cron Timestamp*\n${data.timestampFormatted}`, + }, + ], + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View Dashboard", + emoji: true, + }, + url: data.dashboardUrl, + action_id: "view_dashboard", + }, + ], + }, + ); + + return blocks; +} + +/** + * Builds Slack blocks for degraded notifications + * + * Layout: + * - Header: "{monitor.name} is degraded" + * - Section: "METHOD URL" in code format (e.g., `GET https://api.example.com`) + * - Section: Previous incident duration (optional, only if data.incidentDuration exists) + * - Divider + * - Section: 4 fields in 2x2 grid (Status, Regions, Latency, Cron Timestamp) + * - Actions: Dashboard button + * + * @param data - Formatted message data from buildCommonMessageData + * @returns Array of Slack blocks + * + * @example + * const blocks = buildDegradedBlocks({ + * monitorName: "API Health", + * monitorUrl: "https://api.example.com", + * monitorMethod: "GET", + * monitorJobType: "http", + * statusCodeFormatted: "504 Gateway Timeout", + * errorMessage: "Slow response", + * timestampFormatted: "Jan 22, 2026 at 14:40 UTC", + * regionsDisplay: "iad, fra, syd", + * latencyDisplay: "5,234 ms", + * dashboardUrl: "https://app.openstatus.dev/monitors/123", + * incidentDuration: "2h 15m" + * }); + */ +export function buildDegradedBlocks(data: FormattedMessageData): SlackBlock[] { + const escapedName = escapeSlackText(data.monitorName); + + // Format description as "METHOD URL" or just "URL" for non-HTTP + const description = + data.monitorMethod && data.monitorJobType === "http" + ? `${data.monitorMethod} <${data.monitorUrl}|${data.monitorUrl}>` + : `<${data.monitorUrl}|${data.monitorUrl}>`; + + const blocks: SlackBlock[] = [ + { + type: "header", + text: { + type: "plain_text", + text: `${escapedName} is degraded`, + emoji: false, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `\`${description}\``, + }, + }, + ]; + + // Only include previous incident duration if available + if (data.incidentDuration) { + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `⏱️ *Previous Incident Duration:* ${data.incidentDuration}`, + }, + }); + } + + blocks.push( + { + type: "divider", + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Status*\n${data.statusCodeFormatted}`, + }, + { + type: "mrkdwn", + text: `*Regions*\n${data.regionsDisplay}`, + }, + { + type: "mrkdwn", + text: `*Latency*\n${data.latencyDisplay}`, + }, + { + type: "mrkdwn", + text: `*Cron Timestamp*\n${data.timestampFormatted}`, + }, + ], + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View Dashboard", + emoji: true, + }, + url: data.dashboardUrl, + action_id: "view_dashboard", + }, + ], + }, + ); + + return blocks; +} diff --git a/packages/notifications/slack/src/index.test.ts b/packages/notifications/slack/src/index.test.ts index 629054db9..e035e56ef 100644 --- a/packages/notifications/slack/src/index.test.ts +++ b/packages/notifications/slack/src/index.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { selectNotificationSchema } from "@openstatus/db/src/schema"; +import { COLORS } from "@openstatus/notification-base"; import { sendAlert, sendDegraded, @@ -67,11 +68,11 @@ describe("Slack Notifications", () => { expect(callArgs[1].method).toBe("POST"); const body = JSON.parse(callArgs[1].body); - expect(body.blocks).toBeDefined(); - expect(body.blocks.length).toBeGreaterThan(0); - expect(body.blocks[1].text.text).toContain("🚨 Alert"); - expect(body.blocks[1].text.text).toContain("API Health Check"); - expect(body.blocks[1].text.text).toContain("Something went wrong"); + expect(body.attachments).toBeDefined(); + expect(body.attachments[0].color).toBe(COLORS.red); + expect(body.attachments[0].blocks).toBeDefined(); + expect(body.attachments[0].blocks.length).toBeGreaterThan(0); + expect(body.attachments[0].blocks[0].text.text).toContain("is failing"); }); test("Send Alert without statusCode", async () => { @@ -91,7 +92,8 @@ describe("Slack Notifications", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const callArgs = fetchMock.mock.calls[0]; const body = JSON.parse(callArgs[1].body); - expect(body.blocks[1].text.text).toContain("_empty_"); + expect(body.attachments[0].color).toBe(COLORS.red); + expect(body.attachments[0].blocks[3].fields[0].text).toContain("Unknown"); }); test("Send Recovery", async () => { @@ -112,8 +114,9 @@ describe("Slack Notifications", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const callArgs = fetchMock.mock.calls[0]; const body = JSON.parse(callArgs[1].body); - expect(body.blocks[1].text.text).toContain("✅ Recovered"); - expect(body.blocks[1].text.text).toContain("API Health Check"); + expect(body.attachments).toBeDefined(); + expect(body.attachments[0].color).toBe(COLORS.green); + expect(body.attachments[0].blocks[0].text.text).toContain("is recovered"); }); test("Send Degraded", async () => { @@ -134,36 +137,33 @@ describe("Slack Notifications", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const callArgs = fetchMock.mock.calls[0]; const body = JSON.parse(callArgs[1].body); - expect(body.blocks[1].text.text).toContain("⚠️ Degraded"); - expect(body.blocks[1].text.text).toContain("API Health Check"); + expect(body.attachments).toBeDefined(); + expect(body.attachments[0].color).toBe(COLORS.yellow); + expect(body.attachments[0].blocks[0].text.text).toContain("is degraded"); }); test("Send Test Slack Message", async () => { const webhookUrl = "https://hooks.slack.com/services/test/url"; - const result = await sendTestSlackMessage(webhookUrl); + await sendTestSlackMessage(webhookUrl); - expect(result).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); const callArgs = fetchMock.mock.calls[0]; expect(callArgs[0]).toBe(webhookUrl); const body = JSON.parse(callArgs[1].body); - expect(body.blocks[1].text.text).toContain("🧪 Test"); - expect(body.blocks[1].text.text).toContain("OpenStatus"); + expect(body.attachments[0].blocks[0].text.text).toContain( + "Test Notification", + ); }); - test("Send Test Slack Message returns false on error", async () => { + test("Send Test Slack Message throws error on empty webhookUrl", async () => { fetchMock.mockImplementation(() => Promise.reject(new Error("Network error")), ); - const result = await sendTestSlackMessage( - "https://hooks.slack.com/services/test/url", - ); - - expect(result).toBe(false); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(sendTestSlackMessage("")).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(0); }); test("Handle fetch error gracefully", async () => { diff --git a/packages/notifications/slack/src/index.ts b/packages/notifications/slack/src/index.ts index 5e905ba64..dfb3432fc 100644 --- a/packages/notifications/slack/src/index.ts +++ b/packages/notifications/slack/src/index.ts @@ -1,9 +1,21 @@ -import type { Monitor, Notification } from "@openstatus/db/src/schema"; import { slackDataSchema } from "@openstatus/db/src/schema"; -import type { Region } from "@openstatus/db/src/schema/constants"; +import { + COLORS, + type NotificationContext, + buildCommonMessageData, +} from "@openstatus/notification-base"; +import { + buildAlertBlocks, + buildDegradedBlocks, + buildRecoveryBlocks, +} from "./blocks"; // biome-ignore lint/suspicious/noExplicitAny: const postToWebhook = async (body: any, webhookUrl: string) => { + if (!webhookUrl || webhookUrl.trim() === "") { + throw new Error("Slack webhook URL is required"); + } + const res = await fetch(webhookUrl, { method: "POST", body: JSON.stringify(body), @@ -18,49 +30,32 @@ export const sendAlert = async ({ notification, statusCode, message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, cronTimestamp, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { + latency, + regions, +}: NotificationContext) => { const notificationData = slackDataSchema.parse(JSON.parse(notification.data)); - const { slack: webhookUrl } = notificationData; // webhook url - const { name } = monitor; + const { slack: webhookUrl } = notificationData; + + const context = { + monitor, + notification, + statusCode, + message, + cronTimestamp, + latency, + regions, + }; + + const data = buildCommonMessageData(context); + const blocks = buildAlertBlocks(data); await postToWebhook( { - blocks: [ - { - type: "divider", - }, - { - type: "section", - text: { - type: "mrkdwn", - text: ` -*🚨 Alert <${monitor.url}|${name}>*\n\n -Status Code: ${statusCode || "_empty_"}\n -Message: ${message || "_empty_"}\n -Cron Timestamp: ${cronTimestamp} (${new Date(cronTimestamp).toISOString()}) -`, - }, - }, + attachments: [ { - type: "context", - elements: [ - { - type: "mrkdwn", - text: "Check your .", - }, - ], + color: COLORS.red, + blocks, }, ], }, @@ -71,47 +66,35 @@ Cron Timestamp: ${cronTimestamp} (${new Date(cronTimestamp).toISOString()}) export const sendRecovery = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: statusCode, - // biome-ignore lint/correctness/noUnusedVariables: message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - region?: Region; - latency?: number; -}) => { + incident, + cronTimestamp, + regions, + latency, +}: NotificationContext) => { const notificationData = slackDataSchema.parse(JSON.parse(notification.data)); - const { slack: webhookUrl } = notificationData; // webhook url - const { name } = monitor; + const { slack: webhookUrl } = notificationData; + + const context = { + monitor, + notification, + statusCode, + message, + cronTimestamp, + latency, + regions, + }; + + const data = buildCommonMessageData(context, { incident }); + const blocks = buildRecoveryBlocks(data); await postToWebhook( { - blocks: [ + attachments: [ { - type: "divider", - }, - { - type: "section", - text: { - type: "mrkdwn", - text: `*✅ Recovered <${monitor.url}/|${name}>*`, - }, - }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: "Check your .", - }, - ], + color: COLORS.green, + blocks, }, ], }, @@ -122,44 +105,35 @@ export const sendRecovery = async ({ export const sendDegraded = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: statusCode, - // biome-ignore lint/correctness/noUnusedVariables: message, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - cronTimestamp: number; - region?: Region; - latency?: number; -}) => { + incident, + cronTimestamp, + regions, + latency, +}: NotificationContext) => { const notificationData = slackDataSchema.parse(JSON.parse(notification.data)); - const { slack: webhookUrl } = notificationData; // webhook url - const { name } = monitor; + const { slack: webhookUrl } = notificationData; + + const context = { + monitor, + notification, + statusCode, + message, + cronTimestamp, + latency, + regions, + }; + + const data = buildCommonMessageData(context, { incident }); + const blocks = buildDegradedBlocks(data); await postToWebhook( { - blocks: [ + attachments: [ { - type: "divider", - }, - { - type: "section", - text: { - type: "mrkdwn", - text: `*⚠️ Degraded <${monitor.url}/|${name}>*`, - }, - }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: "Check your .", - }, - ], + color: COLORS.yellow, + blocks, }, ], }, @@ -168,35 +142,69 @@ export const sendDegraded = async ({ }; export const sendTestSlackMessage = async (webhookUrl: string) => { - try { - await postToWebhook( - { - blocks: [ - { - type: "divider", - }, - { - type: "section", - text: { - type: "mrkdwn", - text: "*🧪 Test *\n\nIf you can read this, your Slack webhook is functioning correctly!", + await postToWebhook( + { + attachments: [ + { + color: COLORS.green, + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: "Test Notification", + emoji: false, + }, }, - }, - { - type: "context", - elements: [ - { + { + type: "section", + text: { type: "mrkdwn", - text: "Check your .", + text: "`🧪 Your Slack webhook is configured correctly!`", }, - ], - }, - ], - }, - webhookUrl, - ); - return true; - } catch (_err) { - return false; - } + }, + { + type: "divider", + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: "*Status*\nWebhook Connected", + }, + { + type: "mrkdwn", + text: "*Type*\nTest Notification", + }, + ], + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "*Next Steps*\nYou will receive notifications here when your monitors trigger fail, recover, or degrades.", + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View Dashboard", + emoji: true, + }, + url: "https://app.openstatus.dev", + action_id: "view_dashboard", + }, + ], + }, + ], + }, + ], + }, + webhookUrl, + ); }; diff --git a/packages/notifications/slack/src/mock.ts b/packages/notifications/slack/src/mock.ts index 3300a9e05..843e5c104 100644 --- a/packages/notifications/slack/src/mock.ts +++ b/packages/notifications/slack/src/mock.ts @@ -29,8 +29,9 @@ const monitor: Monitor = { deletedAt: null, otelEndpoint: null, otelHeaders: [], - retry: null, - followRedirects: null, + retry: 3, + followRedirects: false, + externalName: null, }; const notification: Notification = { diff --git a/packages/notifications/telegram/package.json b/packages/notifications/telegram/package.json index 861fadf66..fd46e756d 100644 --- a/packages/notifications/telegram/package.json +++ b/packages/notifications/telegram/package.json @@ -3,10 +3,12 @@ "version": "1.0.0", "main": "src/index.ts", "scripts": { - "test": "bun test" + "test": "bun test", + "tsc": "tsc" }, "dependencies": { "@openstatus/db": "workspace:*", + "@openstatus/notification-base": "workspace:*", "zod": "4.1.13" }, "devDependencies": { diff --git a/packages/notifications/telegram/src/index.ts b/packages/notifications/telegram/src/index.ts index 01e07a084..2b279c294 100644 --- a/packages/notifications/telegram/src/index.ts +++ b/packages/notifications/telegram/src/index.ts @@ -1,25 +1,12 @@ -import type { Monitor, Notification } from "@openstatus/db/src/schema"; import { telegramDataSchema } from "@openstatus/db/src/schema"; - -import type { Region } from "@openstatus/db/src/schema/constants"; +import type { NotificationContext } from "@openstatus/notification-base"; export const sendAlert = async ({ monitor, notification, statusCode, message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = telegramDataSchema.parse( JSON.parse(notification.data), ); @@ -38,22 +25,7 @@ export const sendAlert = async ({ export const sendRecovery = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: - statusCode, - // biome-ignore lint/correctness/noUnusedVariables: - message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = telegramDataSchema.parse( JSON.parse(notification.data), ); @@ -69,20 +41,7 @@ export const sendRecovery = async ({ export const sendDegraded = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: - statusCode, - // biome-ignore lint/correctness/noUnusedVariables: - message, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = telegramDataSchema.parse( JSON.parse(notification.data), ); diff --git a/packages/notifications/twillio-sms/package.json b/packages/notifications/twillio-sms/package.json index 294cdd72a..5fdf2814e 100644 --- a/packages/notifications/twillio-sms/package.json +++ b/packages/notifications/twillio-sms/package.json @@ -3,10 +3,12 @@ "version": "0.0.0", "main": "src/index.ts", "scripts": { - "test": "bun test" + "test": "bun test", + "tsc": "tsc" }, "dependencies": { "@openstatus/db": "workspace:*", + "@openstatus/notification-base": "workspace:*", "@t3-oss/env-core": "0.13.10", "validator": "13.12.0", "zod": "4.1.13" diff --git a/packages/notifications/twillio-sms/src/index.ts b/packages/notifications/twillio-sms/src/index.ts index 9942d7a1f..920b5ece2 100644 --- a/packages/notifications/twillio-sms/src/index.ts +++ b/packages/notifications/twillio-sms/src/index.ts @@ -1,7 +1,5 @@ -import type { Monitor, Notification } from "@openstatus/db/src/schema"; - import { phoneDataSchema } from "@openstatus/db/src/schema"; -import type { Region } from "@openstatus/db/src/schema/constants"; +import type { NotificationContext } from "@openstatus/notification-base"; import { env } from "./env"; export const sendAlert = async ({ @@ -9,18 +7,7 @@ export const sendAlert = async ({ notification, statusCode, message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = phoneDataSchema.parse(JSON.parse(notification.data)); const { name } = monitor; @@ -54,22 +41,7 @@ export const sendAlert = async ({ export const sendRecovery = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: - statusCode, - // biome-ignore lint/correctness/noUnusedVariables: - message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = phoneDataSchema.parse(JSON.parse(notification.data)); const { name } = monitor; @@ -98,20 +70,7 @@ export const sendRecovery = async ({ export const sendDegraded = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: - statusCode, - // biome-ignore lint/correctness/noUnusedVariables: - message, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = phoneDataSchema.parse(JSON.parse(notification.data)); const { name } = monitor; diff --git a/packages/notifications/twillio-whatsapp/package.json b/packages/notifications/twillio-whatsapp/package.json index 9a17656ec..9b84bc4b2 100644 --- a/packages/notifications/twillio-whatsapp/package.json +++ b/packages/notifications/twillio-whatsapp/package.json @@ -3,10 +3,12 @@ "version": "0.0.0", "main": "src/index.ts", "scripts": { - "test": "bun test" + "test": "bun test", + "tsc": "tsc" }, "dependencies": { "@openstatus/db": "workspace:*", + "@openstatus/notification-base": "workspace:*", "@t3-oss/env-core": "0.13.10", "validator": "13.12.0", "zod": "4.1.13" diff --git a/packages/notifications/twillio-whatsapp/src/index.ts b/packages/notifications/twillio-whatsapp/src/index.ts index 044262c10..ebb9c4787 100644 --- a/packages/notifications/twillio-whatsapp/src/index.ts +++ b/packages/notifications/twillio-whatsapp/src/index.ts @@ -1,25 +1,11 @@ -import type { Monitor, Notification } from "@openstatus/db/src/schema"; import { whatsappDataSchema } from "@openstatus/db/src/schema"; -import type { Region } from "@openstatus/db/src/schema/constants"; +import type { NotificationContext } from "@openstatus/notification-base"; import { env } from "./env"; export const sendAlert = async ({ monitor, notification, - statusCode, - message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = whatsappDataSchema.parse( JSON.parse(notification.data), ); @@ -51,22 +37,7 @@ export const sendAlert = async ({ export const sendRecovery = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: - statusCode, - // biome-ignore lint/correctness/noUnusedVariables: - message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = whatsappDataSchema.parse( JSON.parse(notification.data), ); @@ -98,20 +69,7 @@ export const sendRecovery = async ({ export const sendDegraded = async ({ monitor, notification, - // biome-ignore lint/correctness/noUnusedVariables: - statusCode, - // biome-ignore lint/correctness/noUnusedVariables: - message, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = whatsappDataSchema.parse( JSON.parse(notification.data), ); diff --git a/packages/notifications/webhook/package.json b/packages/notifications/webhook/package.json index b65ab51aa..d80569838 100644 --- a/packages/notifications/webhook/package.json +++ b/packages/notifications/webhook/package.json @@ -3,10 +3,12 @@ "version": "1.0.0", "main": "src/index.ts", "scripts": { - "test": "bun test" + "test": "bun test", + "tsc": "tsc" }, "dependencies": { "@openstatus/db": "workspace:*", + "@openstatus/notification-base": "workspace:*", "@openstatus/utils": "workspace:*", "zod": "4.1.13" }, diff --git a/packages/notifications/webhook/src/index.ts b/packages/notifications/webhook/src/index.ts index 7a04bbcd2..d966be3aa 100644 --- a/packages/notifications/webhook/src/index.ts +++ b/packages/notifications/webhook/src/index.ts @@ -1,6 +1,4 @@ -import type { Monitor, Notification } from "@openstatus/db/src/schema"; - -import type { Region } from "@openstatus/db/src/schema/constants"; +import type { NotificationContext } from "@openstatus/notification-base"; import { transformHeaders } from "@openstatus/utils"; import { PayloadSchema, WebhookSchema } from "./schema"; @@ -11,18 +9,7 @@ export const sendAlert = async ({ statusCode, latency, message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = WebhookSchema.parse(JSON.parse(notification.data)); const body = PayloadSchema.parse({ @@ -55,18 +42,7 @@ export const sendRecovery = async ({ latency, statusCode, message, - // biome-ignore lint/correctness/noUnusedVariables: - incidentId, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = WebhookSchema.parse(JSON.parse(notification.data)); const body = PayloadSchema.parse({ @@ -88,7 +64,7 @@ export const sendRecovery = async ({ }, }); if (!res.ok) { - throw new Error(`Failed to send SMS: ${res.statusText}`); + throw new Error(`Failed to send webhook notification: ${res.statusText}`); } }; @@ -99,16 +75,7 @@ export const sendDegraded = async ({ latency, statusCode, message, -}: { - monitor: Monitor; - notification: Notification; - statusCode?: number; - message?: string; - incidentId?: string; - cronTimestamp: number; - latency?: number; - region?: Region; -}) => { +}: NotificationContext) => { const notificationData = WebhookSchema.parse(JSON.parse(notification.data)); const body = PayloadSchema.parse({ @@ -130,7 +97,7 @@ export const sendDegraded = async ({ }, }); if (!res.ok) { - throw new Error(`Failed to send SMS: ${res.statusText}`); + throw new Error(`Failed to send webhook notification: ${res.statusText}`); } }; diff --git a/packages/notifications/webhook/tsconfig.json b/packages/notifications/webhook/tsconfig.json index a8a8c9309..fccf088fc 100644 --- a/packages/notifications/webhook/tsconfig.json +++ b/packages/notifications/webhook/tsconfig.json @@ -1,4 +1,5 @@ { - "extends": "@openstatus/tsconfig/nextjs.json", - "include": ["src", "*.ts"] + "extends": "@openstatus/tsconfig/base.json", + "include": ["src", "*.ts"], + "exclude": ["**/*.test.ts", "**/*.spec.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf7c20eb2..45e5574bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,7 +73,7 @@ importers: version: 0.15.15 '@openpanel/nextjs': specifier: 1.0.8 - 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) + 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) '@openstatus/analytics': specifier: workspace:* version: link:../../packages/analytics @@ -208,7 +208,7 @@ importers: 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) '@sentry/nextjs': specifier: 10.31.0 - 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) + 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) '@stripe/stripe-js': specifier: 2.1.6 version: 2.1.6 @@ -223,7 +223,7 @@ importers: version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) '@trpc/next': specifier: 11.4.4 - 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) + 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) '@trpc/react-query': specifier: 11.4.4 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,13 +256,13 @@ importers: 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) next-auth: specifier: 5.0.0-beta.29 - 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) + 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) next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nuqs: specifier: 2.8.5 - 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) + 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) random-word-slugs: specifier: 0.1.7 version: 0.1.7 @@ -575,7 +575,7 @@ importers: version: 0.15.15 '@openpanel/nextjs': specifier: 1.0.8 - 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) + 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) '@openstatus/analytics': specifier: workspace:* version: link:../../packages/analytics @@ -665,7 +665,7 @@ importers: 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) '@sentry/nextjs': specifier: 10.31.0 - 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) + 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) '@stripe/stripe-js': specifier: 2.1.6 version: 2.1.6 @@ -680,7 +680,7 @@ importers: version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) '@trpc/next': specifier: 11.4.4 - 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) + 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) '@trpc/react-query': specifier: 11.4.4 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,16 +713,16 @@ importers: 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) next-auth: specifier: 5.0.0-beta.29 - 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) + 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) next-plausible: specifier: 3.12.5 - 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) + 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) next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nuqs: specifier: 2.8.5 - 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) + 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) react: specifier: 19.2.3 version: 19.2.3 @@ -819,7 +819,7 @@ importers: version: 0.15.15 '@openpanel/nextjs': specifier: 1.0.8 - 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) + 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) '@openstatus/analytics': specifier: workspace:* version: link:../../packages/analytics @@ -894,7 +894,7 @@ importers: 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) '@sentry/nextjs': specifier: 10.31.0 - 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) + 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) '@stripe/stripe-js': specifier: 2.1.6 version: 2.1.6 @@ -921,7 +921,7 @@ importers: version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) '@trpc/next': specifier: 11.4.4 - 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) + 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) '@trpc/react-query': specifier: 11.4.4 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,19 +969,19 @@ importers: 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) next-auth: specifier: 5.0.0-beta.29 - 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) + 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) next-mdx-remote: specifier: 5.0.0 version: 5.0.0(@types/react@19.2.2)(react@19.2.3) next-plausible: specifier: 3.12.5 - 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) + 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) next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nuqs: specifier: 2.8.5 - 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) + 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) random-word-slugs: specifier: 0.1.7 version: 0.1.7 @@ -1109,6 +1109,9 @@ importers: '@openstatus/emails': specifier: workspace:* version: link:../../packages/emails + '@openstatus/notification-base': + specifier: workspace:* + version: link:../../packages/notifications/base '@openstatus/notification-discord': specifier: workspace:* version: link:../../packages/notifications/discord @@ -1359,7 +1362,7 @@ importers: version: 0.31.4 next-auth: specifier: 5.0.0-beta.29 - 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) + 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) typescript: specifier: 5.9.3 version: 5.9.3 @@ -1463,11 +1466,36 @@ importers: specifier: 5.9.3 version: 5.9.3 + packages/notifications/base: + dependencies: + '@openstatus/db': + specifier: workspace:* + version: link:../../db + '@openstatus/regions': + specifier: workspace:* + version: link:../../regions + devDependencies: + '@openstatus/tsconfig': + specifier: workspace:* + version: link:../../tsconfig + '@types/node': + specifier: 24.0.8 + version: 24.0.8 + bun-types: + specifier: 1.3.1 + version: 1.3.1(@types/react@19.2.2) + typescript: + specifier: 5.9.3 + version: 5.9.3 + packages/notifications/discord: dependencies: '@openstatus/db': specifier: workspace:* version: link:../../db + '@openstatus/notification-base': + specifier: workspace:* + version: link:../base zod: specifier: 4.1.13 version: 4.1.13 @@ -1493,6 +1521,9 @@ importers: '@openstatus/emails': specifier: workspace:* version: link:../../emails + '@openstatus/notification-base': + specifier: workspace:* + version: link:../base '@openstatus/regions': specifier: workspace:* version: link:../../regions @@ -1542,6 +1573,9 @@ importers: '@openstatus/db': specifier: workspace:* version: link:../../db + '@openstatus/notification-base': + specifier: workspace:* + version: link:../base zod: specifier: 4.1.13 version: 4.1.13 @@ -1564,6 +1598,9 @@ importers: '@openstatus/db': specifier: workspace:* version: link:../../db + '@openstatus/notification-base': + specifier: workspace:* + version: link:../base zod: specifier: 4.1.13 version: 4.1.13 @@ -1586,6 +1623,9 @@ importers: '@openstatus/db': specifier: workspace:* version: link:../../db + '@openstatus/notification-base': + specifier: workspace:* + version: link:../base '@t3-oss/env-core': specifier: 0.13.10 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) @@ -1623,6 +1663,9 @@ importers: '@openstatus/db': specifier: workspace:* version: link:../../db + '@openstatus/notification-base': + specifier: workspace:* + version: link:../base '@t3-oss/env-core': specifier: 0.13.10 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) @@ -1660,6 +1703,9 @@ importers: '@openstatus/db': specifier: workspace:* version: link:../../db + '@openstatus/notification-base': + specifier: workspace:* + version: link:../base zod: specifier: 4.1.13 version: 4.1.13 @@ -1682,6 +1728,9 @@ importers: '@openstatus/db': specifier: workspace:* version: link:../../db + '@openstatus/notification-base': + specifier: workspace:* + version: link:../base zod: specifier: 4.1.13 version: 4.1.13 @@ -1704,6 +1753,9 @@ importers: '@openstatus/db': specifier: workspace:* version: link:../../db + '@openstatus/notification-base': + specifier: workspace:* + version: link:../base '@t3-oss/env-core': specifier: 0.13.10 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) @@ -1735,6 +1787,9 @@ importers: '@openstatus/db': specifier: workspace:* version: link:../../db + '@openstatus/notification-base': + specifier: workspace:* + version: link:../base '@t3-oss/env-core': specifier: 0.13.10 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) @@ -1766,6 +1821,9 @@ importers: '@openstatus/db': specifier: workspace:* version: link:../../db + '@openstatus/notification-base': + specifier: workspace:* + version: link:../base '@openstatus/utils': specifier: workspace:* version: link:../../utils @@ -1820,8 +1878,6 @@ importers: specifier: 5.9.3 version: 5.9.3 - packages/react/dist: {} - packages/regions: dependencies: zod: @@ -13420,7 +13476,7 @@ snapshots: '@openpanel/web': 1.0.1 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) - '@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)': + '@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)': dependencies: '@openpanel/web': 1.0.1 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,7 +15503,7 @@ snapshots: '@sentry/types': 8.9.2 '@sentry/utils': 8.9.2 - '@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)': + '@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)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.38.0 @@ -16149,7 +16205,7 @@ snapshots: '@trpc/server': 11.4.4(typescript@5.9.3) typescript: 5.9.3 - '@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)': + '@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)': dependencies: '@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) @@ -19619,7 +19675,7 @@ snapshots: netmask@2.0.2: {} - 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): + 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): dependencies: '@auth/core': 0.40.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) @@ -19638,7 +19694,7 @@ snapshots: - '@types/react' - supports-color - 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): + 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): dependencies: 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 @@ -19735,7 +19791,7 @@ snapshots: dependencies: boolbase: 1.0.0 - 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): + 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): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.3 -- 2.54.0