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