From 70c438d9d2625485bbb2909ad2505df5c57e2366 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Thu, 23 Apr 2026 03:04:24 +0100 Subject: [PATCH 01/13] feat: Rework alerts into customisable notifications --- README.md | 2 +- backend/.env.example | 9 +- backend/apps/cloud/src/ai/ai.controller.ts | 2 +- .../apps/cloud/src/alert/alert.controller.ts | 59 +- backend/apps/cloud/src/alert/alert.module.ts | 2 + backend/apps/cloud/src/alert/alert.service.ts | 20 +- backend/apps/cloud/src/alert/dto/alert.dto.ts | 21 + .../cloud/src/alert/entity/alert.entity.ts | 26 +- .../src/analytics/utils/clickIdSources.ts | 8 +- backend/apps/cloud/src/app.module.ts | 2 + .../apps/cloud/src/mailer/mailer.service.ts | 23 + .../src/notification-channel/alert-context.ts | 59 ++ .../dispatchers/channel-dispatcher.service.ts | 69 ++ .../dispatchers/discord-channel.service.ts | 29 + .../dispatchers/email-channel.service.ts | 114 ++++ .../dispatchers/slack-channel.service.ts | 29 + .../dispatchers/telegram-channel.service.ts | 35 + .../notification-channel/dispatchers/types.ts | 22 + .../dispatchers/webhook-channel.service.ts | 107 +++ .../dispatchers/webpush-channel.service.ts | 90 +++ .../dto/notification-channel.dto.ts | 83 +++ .../entity/notification-channel.entity.ts | 95 +++ .../notification-channel.controller.ts | 315 +++++++++ .../notification-channel.module.ts | 57 ++ .../notification-channel.service.ts | 489 ++++++++++++++ .../template-renderer.service.ts | 87 +++ .../src/task-manager/task-manager.module.ts | 2 + .../src/task-manager/task-manager.service.ts | 366 +++++------ .../2026_04_23_notification_channels.sql | 78 +++ ..._drop_legacy_user_notification_columns.sql | 25 + backend/package-lock.json | 63 ++ backend/package.json | 2 + .../EnableWebPushButton.tsx | 166 +++++ .../NotificationChannels.tsx | 611 ++++++++++++++++++ web/app/lib/models/Alerts.ts | 5 + web/app/lib/models/NotificationChannel.ts | 23 + .../pages/Organisations/Settings/index.tsx | 34 +- .../Settings/Alerts/AlertTemplateEditor.tsx | 317 +++++++++ .../Settings/Alerts/ProjectAlertsSettings.tsx | 162 ++++- .../Settings/Alerts/ProjectAlertsView.tsx | 8 +- .../Project/Settings/ProjectSettings.tsx | 38 +- web/app/pages/UserSettings/UserSettings.tsx | 22 +- .../UserSettings/components/Integrations.tsx | 535 --------------- web/app/routes/notification-channel.tsx | 297 +++++++++ web/app/routes/projects.$id.tsx | 46 ++ web/public/locales/en.json | 76 +++ web/public/sw.js | 46 ++ 47 files changed, 3974 insertions(+), 802 deletions(-) create mode 100644 backend/apps/cloud/src/notification-channel/alert-context.ts create mode 100644 backend/apps/cloud/src/notification-channel/dispatchers/channel-dispatcher.service.ts create mode 100644 backend/apps/cloud/src/notification-channel/dispatchers/discord-channel.service.ts create mode 100644 backend/apps/cloud/src/notification-channel/dispatchers/email-channel.service.ts create mode 100644 backend/apps/cloud/src/notification-channel/dispatchers/slack-channel.service.ts create mode 100644 backend/apps/cloud/src/notification-channel/dispatchers/telegram-channel.service.ts create mode 100644 backend/apps/cloud/src/notification-channel/dispatchers/types.ts create mode 100644 backend/apps/cloud/src/notification-channel/dispatchers/webhook-channel.service.ts create mode 100644 backend/apps/cloud/src/notification-channel/dispatchers/webpush-channel.service.ts create mode 100644 backend/apps/cloud/src/notification-channel/dto/notification-channel.dto.ts create mode 100644 backend/apps/cloud/src/notification-channel/entity/notification-channel.entity.ts create mode 100644 backend/apps/cloud/src/notification-channel/notification-channel.controller.ts create mode 100644 backend/apps/cloud/src/notification-channel/notification-channel.module.ts create mode 100644 backend/apps/cloud/src/notification-channel/notification-channel.service.ts create mode 100644 backend/apps/cloud/src/notification-channel/template-renderer.service.ts create mode 100644 backend/migrations/mysql/2026_04_23_notification_channels.sql create mode 100644 backend/migrations/mysql/_pending_drop_legacy_user_notification_columns.sql create mode 100644 web/app/components/NotificationChannels/EnableWebPushButton.tsx create mode 100644 web/app/components/NotificationChannels/NotificationChannels.tsx create mode 100644 web/app/lib/models/NotificationChannel.ts create mode 100644 web/app/pages/Project/Settings/Alerts/AlertTemplateEditor.tsx delete mode 100644 web/app/pages/UserSettings/components/Integrations.tsx create mode 100644 web/app/routes/notification-channel.tsx create mode 100644 web/public/sw.js diff --git a/README.md b/README.md index fc5f01e3a..f60677a28 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ We've been building Swetrix since 2021 with a goal to make web analytics simple - **Error tracking**: capture client‑side errors with details and aggregated views. - **Shareable analytics**: public or password‑protected dashboards; invite teammates with roles, or manage access with organisations. - **Data portability**: export to CSV and access data via our [developer API](https://docs.swetrix.com/statistics-api). -- **Alerts & notifications (Cloud)**: get notified on thresholds via Slack, Telegram or Discord. +- **Alerts & notifications (Cloud)**: get notified on thresholds via Email, Slack, Telegram, Discord, generic outbound webhook or browser web push, with per-alert custom message templates. - **Feature flags**: manage feature rollouts and conduct safe releases. - **Experiments (Cloud)**: run A/B tests and experiments to optimize your site. - **Revenue analytics (Cloud)**: track MRR, churn and other financial metrics. diff --git a/backend/.env.example b/backend/.env.example index bc3b63c8d..688e028e7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -75,6 +75,13 @@ ENABLE_TELEGRAM_INTEGRATION=false # Telegram integration TELEGRAM_BOT_TOKEN='' +# Web Push (VAPID) - required to enable browser push notifications channels. +# Generate a fresh keypair with: npx web-push generate-vapid-keys +# VAPID_SUBJECT must be a mailto: URI or HTTPS URL identifying you as the sender. +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT='mailto:contact@swetrix.com' + # Paddle PADDLE_VENDOR_ID= PADDLE_API_KEY= @@ -105,6 +112,6 @@ SWETRIX_PID=STEzHcB1rALV # Shared secret the proxy.swetrix.org edge box sends in `X-Edge-Api-Key` to # call the /v1/proxy-domain/allow and /v1/proxy-domain/active endpoints. # Generate with: openssl rand -hex 32 -# Leave blank if you don't run the managed reverse proxy edge — the endpoints +# Leave blank if you don't run the managed reverse proxy edge - the endpoints # fail closed (404) when this is unset. MANAGED_PROXY_EDGE_API_KEY= diff --git a/backend/apps/cloud/src/ai/ai.controller.ts b/backend/apps/cloud/src/ai/ai.controller.ts index 663a208a4..d8821bbf6 100644 --- a/backend/apps/cloud/src/ai/ai.controller.ts +++ b/backend/apps/cloud/src/ai/ai.controller.ts @@ -942,7 +942,7 @@ export class AiController { @Patch(':pid/chats/:chatId') @Auth(false, true) // Allow optional auth for public projects @ApiOperation({ - summary: 'Update chat metadata (pinned, tags, name) — owner only', + summary: 'Update chat metadata (pinned, tags, name) - owner only', }) @ApiResponse({ status: 200, description: 'Chat metadata updated' }) async updateChatMeta( diff --git a/backend/apps/cloud/src/alert/alert.controller.ts b/backend/apps/cloud/src/alert/alert.controller.ts index a41334175..5a194324e 100644 --- a/backend/apps/cloud/src/alert/alert.controller.ts +++ b/backend/apps/cloud/src/alert/alert.controller.ts @@ -41,6 +41,9 @@ import { import { AlertService } from './alert.service' import { getIPFromHeaders } from '../common/utils' import { trackCustom } from '../common/analytics' +import { NotificationChannelService } from '../notification-channel/notification-channel.service' +import { TemplateRendererService } from '../notification-channel/template-renderer.service' +import { NotificationChannelType } from '../notification-channel/entity/notification-channel.entity' const ALERTS_MAXIMUM = ACCOUNT_PLANS[PlanCode.free].maxAlerts @@ -52,8 +55,23 @@ export class AlertController { private readonly projectService: ProjectService, private readonly logger: AppLoggerService, private readonly userService: UserService, + private readonly channelService: NotificationChannelService, + private readonly templateRenderer: TemplateRendererService, ) {} + @ApiBearerAuth() + @Get('/template-variables') + @Auth() + async getTemplateVariables(@Query('metric') metric: QueryMetric) { + if (!metric || !Object.values(QueryMetric).includes(metric)) { + throw new BadRequestException('Unknown metric') + } + return { + variables: this.templateRenderer.getVariablesForMetric(metric), + defaultTemplate: this.templateRenderer.getDefaultTemplate(metric), + } + } + @ApiBearerAuth() @Get('/:alertId') @Auth() @@ -66,7 +84,7 @@ export class AlertController { const alert = await this.alertService.findOne({ where: { id: alertId }, - relations: ['project'], + relations: ['project', 'channels'], }) if (_isEmpty(alert)) { @@ -109,7 +127,7 @@ export class AlertController { const result = await this.alertService.paginate( { take: safeTake, skip: safeSkip }, { project: { id: projectId } }, - ['project'], + ['project', 'channels'], ) // @ts-expect-error @@ -192,6 +210,21 @@ export class AlertController { ) } + const channelIds = alertDTO.channelIds ?? [] + const validatedChannels = + await this.channelService.validateChannelsForProject( + channelIds, + alertDTO.pid, + uid, + ) + + if ( + validatedChannels.some((c) => c.type === NotificationChannelType.EMAIL) && + !alertDTO.emailSubjectTemplate + ) { + // Use a sensible default if the user picked email but didn't supply a subject. + } + try { const alert: Partial = { name: alertDTO.name, @@ -203,6 +236,9 @@ export class AlertController { queryCustomEvent: alertDTO.queryCustomEvent ?? null, alertOnNewErrorsOnly: alertDTO.alertOnNewErrorsOnly ?? true, alertOnEveryCustomEvent: alertDTO.alertOnEveryCustomEvent ?? false, + messageTemplate: alertDTO.messageTemplate ?? null, + emailSubjectTemplate: alertDTO.emailSubjectTemplate ?? null, + channels: validatedChannels, project, } @@ -290,6 +326,8 @@ export class AlertController { 'alertOnNewErrorsOnly', 'alertOnEveryCustomEvent', 'active', + 'messageTemplate', + 'emailSubjectTemplate', ]), } @@ -323,10 +361,23 @@ export class AlertController { await this.alertService.update( id, - _omit(updatePayload, ['project', 'lastTriggered']), + _omit(updatePayload, ['project', 'lastTriggered', 'channels']), ) - const updatedAlert = await this.alertService.findOne({ where: { id } }) + if (Array.isArray(alertDTO.channelIds)) { + const validatedChannels = + await this.channelService.validateChannelsForProject( + alertDTO.channelIds, + alert.project.id, + uid, + ) + await this.alertService.setChannels(id, validatedChannels) + } + + const updatedAlert = await this.alertService.findOne({ + where: { id }, + relations: ['channels'], + }) if (!updatedAlert) throw new NotFoundException('Alert not found after update') diff --git a/backend/apps/cloud/src/alert/alert.module.ts b/backend/apps/cloud/src/alert/alert.module.ts index 2ae17192e..c98c2454d 100644 --- a/backend/apps/cloud/src/alert/alert.module.ts +++ b/backend/apps/cloud/src/alert/alert.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm' import { ProjectModule } from '../project/project.module' import { AppLoggerModule } from '../logger/logger.module' import { UserModule } from '../user/user.module' +import { NotificationChannelModule } from '../notification-channel/notification-channel.module' import { AlertService } from './alert.service' import { Alert } from './entity/alert.entity' import { AlertController } from './alert.controller' @@ -14,6 +15,7 @@ import { AlertController } from './alert.controller' ProjectModule, AppLoggerModule, UserModule, + NotificationChannelModule, ], providers: [AlertService], exports: [AlertService], diff --git a/backend/apps/cloud/src/alert/alert.service.ts b/backend/apps/cloud/src/alert/alert.service.ts index c8a0a7b21..9149747f8 100644 --- a/backend/apps/cloud/src/alert/alert.service.ts +++ b/backend/apps/cloud/src/alert/alert.service.ts @@ -4,6 +4,7 @@ import { FindManyOptions, FindOneOptions, Repository } from 'typeorm' import { Pagination, PaginationOptionsInterface } from '../common/pagination' import { Alert } from './entity/alert.entity' import { AlertDTO } from './dto/alert.dto' +import { NotificationChannel } from '../notification-channel/entity/notification-channel.entity' @Injectable() export class AlertService { @@ -24,7 +25,9 @@ export class AlertService { order: { name: 'ASC', }, - relations, + relations: relations + ? [...new Set([...relations, 'channels'])] + : ['channels'], }) return new Pagination({ @@ -36,10 +39,23 @@ export class AlertService { findOneWithRelations(id: string): Promise { return this.alertsReporsitory.findOne({ where: { id }, - relations: ['project', 'project.admin'], + relations: ['project', 'project.admin', 'channels'], }) } + async setChannels( + id: string, + channels: NotificationChannel[], + ): Promise { + const alert = await this.alertsReporsitory.findOne({ + where: { id }, + relations: ['channels'], + }) + if (!alert) return + alert.channels = channels + await this.alertsReporsitory.save(alert) + } + async count(options: FindManyOptions = {}): Promise { return this.alertsReporsitory.count(options) } diff --git a/backend/apps/cloud/src/alert/dto/alert.dto.ts b/backend/apps/cloud/src/alert/dto/alert.dto.ts index c48ca682c..7556170c2 100644 --- a/backend/apps/cloud/src/alert/dto/alert.dto.ts +++ b/backend/apps/cloud/src/alert/dto/alert.dto.ts @@ -9,6 +9,9 @@ import { IsString, IsNumber, Matches, + IsArray, + IsUUID, + MaxLength, } from 'class-validator' export enum QueryMetric { @@ -80,6 +83,24 @@ class AlertBaseDTO { @IsBoolean() @IsOptional() alertOnEveryCustomEvent?: boolean + + @ApiProperty({ type: [String] }) + @IsArray() + @IsUUID('4', { each: true }) + @IsOptional() + channelIds?: string[] + + @ApiProperty({ nullable: true }) + @IsString() + @IsOptional() + @MaxLength(5000) + messageTemplate?: string | null + + @ApiProperty({ nullable: true }) + @IsString() + @IsOptional() + @MaxLength(255) + emailSubjectTemplate?: string | null } export class CreateAlertDTO extends AlertBaseDTO { diff --git a/backend/apps/cloud/src/alert/entity/alert.entity.ts b/backend/apps/cloud/src/alert/entity/alert.entity.ts index a6eba843a..1b9df2b41 100644 --- a/backend/apps/cloud/src/alert/entity/alert.entity.ts +++ b/backend/apps/cloud/src/alert/entity/alert.entity.ts @@ -1,7 +1,15 @@ -import { Entity, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' +import { + Entity, + Column, + ManyToOne, + PrimaryGeneratedColumn, + ManyToMany, + JoinTable, +} from 'typeorm' import { ApiProperty } from '@nestjs/swagger' import { Project } from '../../project/entity/project.entity' +import { NotificationChannel } from '../../notification-channel/entity/notification-channel.entity' import { QueryCondition, QueryMetric, QueryTime } from '../dto/alert.dto' @Entity() @@ -90,4 +98,20 @@ export class Alert { default: null, }) queryCustomEvent: string + + @ApiProperty() + @Column('text', { nullable: true, default: null }) + messageTemplate: string | null + + @ApiProperty() + @Column('varchar', { length: 255, nullable: true, default: null }) + emailSubjectTemplate: string | null + + @ManyToMany(() => NotificationChannel, (channel) => channel.alerts) + @JoinTable({ + name: 'alert_channels', + joinColumn: { name: 'alertId', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'channelId', referencedColumnName: 'id' }, + }) + channels: NotificationChannel[] } diff --git a/backend/apps/cloud/src/analytics/utils/clickIdSources.ts b/backend/apps/cloud/src/analytics/utils/clickIdSources.ts index 6e1345400..bcbf7dc86 100644 --- a/backend/apps/cloud/src/analytics/utils/clickIdSources.ts +++ b/backend/apps/cloud/src/analytics/utils/clickIdSources.ts @@ -5,14 +5,14 @@ * tracking parameter (a "click ID") to URLs they send users to. When the * referring app is a native mobile app or in-app browser (Twitter/X, * Reddit, Facebook, TikTok, Gmail iOS, Slack, ...) the HTTP `Referer` - * header is almost always stripped — but the click ID is preserved in + * header is almost always stripped - but the click ID is preserved in * the destination URL. * * By detecting these click IDs server-side we can synthesize a sensible * `source` / `medium` (and a canonical `referrer`) for events that would * otherwise be bucketed as "Direct / None". * - * Explicit UTM parameters set by the marketer always win — this only + * Explicit UTM parameters set by the marketer always win - this only * fills in gaps. */ @@ -110,7 +110,7 @@ const CLICK_ID_MAP: Record = { /** * Iteration order is deterministic in modern engines (insertion order), - * which gives ad-network IDs priority over social/email — matching the + * which gives ad-network IDs priority over social/email - matching the * `CLICK_ID_MAP` declaration above. */ const CLICK_ID_KEYS = Object.keys(CLICK_ID_MAP) @@ -187,7 +187,7 @@ interface TrafficSourceFields { * source information derived from click IDs found in the URL. * * Mutates the passed object (and returns it) for ergonomic call-site - * usage. UTM parameters explicitly set by the marketer always win — we + * usage. UTM parameters explicitly set by the marketer always win - we * only fill empty fields. */ export const enrichTrafficSource = ( diff --git a/backend/apps/cloud/src/app.module.ts b/backend/apps/cloud/src/app.module.ts index 80ee9cf38..6cdd3bd40 100644 --- a/backend/apps/cloud/src/app.module.ts +++ b/backend/apps/cloud/src/app.module.ts @@ -35,6 +35,7 @@ import { BullModule } from '@nestjs/bullmq' import { ToolsModule } from './tools/tools.module' import { PendingInvitationModule } from './pending-invitation/pending-invitation.module' import { DataImportModule } from './data-import/data-import.module' +import { NotificationChannelModule } from './notification-channel/notification-channel.module' const modules = [ SentryModule.forRoot(), @@ -101,6 +102,7 @@ const modules = [ ToolsModule, PendingInvitationModule, DataImportModule, + NotificationChannelModule, ] @Module({ diff --git a/backend/apps/cloud/src/mailer/mailer.service.ts b/backend/apps/cloud/src/mailer/mailer.service.ts index 05d31f53a..d87f8dc99 100644 --- a/backend/apps/cloud/src/mailer/mailer.service.ts +++ b/backend/apps/cloud/src/mailer/mailer.service.ts @@ -177,6 +177,29 @@ export class MailerService { private readonly nodeMailerService: NodeMailerService, ) {} + async sendRawEmail( + email: string, + subject: string, + html: string, + ): Promise { + try { + const message = { + from: `Swetrix <${process.env.FROM_EMAIL}>`, + to: email, + subject, + html, + } + + if (process.env.SMTP_MOCK) { + this.logger.log({ ...message }, 'sendRawEmail', true) + } else { + await this.nodeMailerService.sendMail(message) + } + } catch (reason) { + this.logger.error(reason, 'sendRawEmail', true) + } + } + async sendEmail( email: string, templateName: LetterTemplate, diff --git a/backend/apps/cloud/src/notification-channel/alert-context.ts b/backend/apps/cloud/src/notification-channel/alert-context.ts new file mode 100644 index 000000000..c000eef96 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/alert-context.ts @@ -0,0 +1,59 @@ +import { QueryCondition, QueryMetric, QueryTime } from '../alert/dto/alert.dto' + +export interface AlertContextBase { + alert_name: string + project_name: string + project_id: string + dashboard_url: string + value: number + threshold: number | null + condition: string | null + time_window: string | null + metric: QueryMetric +} + +export interface AlertContextErrors extends AlertContextBase { + error_count: number + error_message: string + error_name: string + errors_url: string + is_new_only: boolean +} + +export interface AlertContextCustomEvents extends AlertContextBase { + event_name: string + event_count: number + every_event_mode: boolean +} + +export interface AlertContextPageViews extends AlertContextBase { + views?: number + unique_views?: number +} + +export interface AlertContextOnline extends AlertContextBase { + online_count: number +} + +export type AlertContext = + | AlertContextErrors + | AlertContextCustomEvents + | AlertContextPageViews + | AlertContextOnline + | AlertContextBase + +export const QUERY_CONDITION_LABEL: Record = { + [QueryCondition.GREATER_THAN]: 'greater than', + [QueryCondition.GREATER_EQUAL_THAN]: 'greater than or equal to', + [QueryCondition.LESS_THAN]: 'less than', + [QueryCondition.LESS_EQUAL_THAN]: 'less than or equal to', +} + +export const QUERY_TIME_LABEL: Record = { + [QueryTime.LAST_15_MINUTES]: '15 minutes', + [QueryTime.LAST_30_MINUTES]: '30 minutes', + [QueryTime.LAST_1_HOUR]: '1 hour', + [QueryTime.LAST_4_HOURS]: '4 hours', + [QueryTime.LAST_24_HOURS]: '24 hours', + [QueryTime.LAST_48_HOURS]: '48 hours', +} diff --git a/backend/apps/cloud/src/notification-channel/dispatchers/channel-dispatcher.service.ts b/backend/apps/cloud/src/notification-channel/dispatchers/channel-dispatcher.service.ts new file mode 100644 index 000000000..f6ae43e15 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/channel-dispatcher.service.ts @@ -0,0 +1,69 @@ +import { Injectable, Logger } from '@nestjs/common' +import { + NotificationChannel, + NotificationChannelType, +} from '../entity/notification-channel.entity' +import { ChannelDispatcher, RenderedAlertMessage } from './types' +import { EmailChannelService } from './email-channel.service' +import { TelegramChannelService } from './telegram-channel.service' +import { DiscordChannelService } from './discord-channel.service' +import { SlackChannelService } from './slack-channel.service' +import { WebhookChannelService } from './webhook-channel.service' +import { WebpushChannelService } from './webpush-channel.service' + +@Injectable() +export class ChannelDispatcherService { + private readonly logger = new Logger(ChannelDispatcherService.name) + + private readonly registry: Map + + constructor( + emailDispatcher: EmailChannelService, + telegramDispatcher: TelegramChannelService, + discordDispatcher: DiscordChannelService, + slackDispatcher: SlackChannelService, + webhookDispatcher: WebhookChannelService, + webpushDispatcher: WebpushChannelService, + ) { + this.registry = new Map([ + [NotificationChannelType.EMAIL, emailDispatcher], + [NotificationChannelType.TELEGRAM, telegramDispatcher], + [NotificationChannelType.DISCORD, discordDispatcher], + [NotificationChannelType.SLACK, slackDispatcher], + [NotificationChannelType.WEBHOOK, webhookDispatcher], + [NotificationChannelType.WEBPUSH, webpushDispatcher], + ]) + } + + isVerifiedAndActive(channel: NotificationChannel): boolean { + if (!channel.isVerified) return false + if (channel.type === NotificationChannelType.EMAIL) { + const cfg = channel.config as { unsubscribed?: boolean } + if (cfg?.unsubscribed) return false + } + return true + } + + async dispatch( + channels: NotificationChannel[], + message: RenderedAlertMessage, + ): Promise { + const tasks = channels + .filter((c) => this.isVerifiedAndActive(c)) + .map(async (channel) => { + const dispatcher = this.registry.get(channel.type) + if (!dispatcher) { + this.logger.warn(`No dispatcher registered for type ${channel.type}`) + return + } + try { + await dispatcher.send(channel, message) + } catch (reason) { + this.logger.error( + `Dispatcher for ${channel.type} failed on channel ${channel.id}: ${reason}`, + ) + } + }) + await Promise.allSettled(tasks) + } +} diff --git a/backend/apps/cloud/src/notification-channel/dispatchers/discord-channel.service.ts b/backend/apps/cloud/src/notification-channel/dispatchers/discord-channel.service.ts new file mode 100644 index 000000000..c4a7a3732 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/discord-channel.service.ts @@ -0,0 +1,29 @@ +import { Injectable, Logger } from '@nestjs/common' +import { DiscordService } from '../../integrations/discord/discord.service' +import { + NotificationChannel, + NotificationChannelType, +} from '../entity/notification-channel.entity' +import { ChannelDispatcher, RenderedAlertMessage } from './types' + +@Injectable() +export class DiscordChannelService implements ChannelDispatcher { + readonly type = NotificationChannelType.DISCORD + + private readonly logger = new Logger(DiscordChannelService.name) + + constructor(private readonly discordService: DiscordService) {} + + async send( + channel: NotificationChannel, + message: RenderedAlertMessage, + ): Promise { + const cfg = channel.config as { url?: string } + if (!cfg?.url) return + try { + await this.discordService.sendWebhook(cfg.url, message.body) + } catch (reason) { + this.logger.error(`Failed to send Discord alert: ${reason}`) + } + } +} diff --git a/backend/apps/cloud/src/notification-channel/dispatchers/email-channel.service.ts b/backend/apps/cloud/src/notification-channel/dispatchers/email-channel.service.ts new file mode 100644 index 000000000..d7eafa63a --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/email-channel.service.ts @@ -0,0 +1,114 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { createHmac } from 'crypto' +import { MailerService } from '../../mailer/mailer.service' +import { + NotificationChannel, + NotificationChannelType, +} from '../entity/notification-channel.entity' +import { ChannelDispatcher, RenderedAlertMessage } from './types' + +const wrapEmailHtml = ( + body: string, + unsubscribeUrl: string, + subject: string, +) => { + // Lightweight email shell — no template file required, easier to keep inline. + const safeSubject = escapeHtml(subject) + const html = simpleMarkdownToHtml(body) + return ` +${safeSubject} + +
+ ${html} +
+

+ You received this email because a Swetrix alert you configured was triggered. + Unsubscribe from this channel. +

+` +} + +const escapeHtml = (s: string) => + s.replace(/[&<>"']/g, (c) => + c === '&' + ? '&' + : c === '<' + ? '<' + : c === '>' + ? '>' + : c === '"' + ? '"' + : ''', + ) + +const simpleMarkdownToHtml = (md: string) => { + // Tiny converter: bold, italics, links, code, line breaks. Anything fancier + // stays as escaped text so we never inject raw HTML from a user template. + let out = escapeHtml(md) + out = out.replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + '$1', + ) + out = out.replace(/\*\*(.+?)\*\*/g, '$1') + out = out.replace(/\*(.+?)\*/g, '$1') + out = out.replace(/`([^`]+)`/g, '$1') + out = out.replace(/\n/g, '
') + return out +} + +@Injectable() +export class EmailChannelService implements ChannelDispatcher { + readonly type = NotificationChannelType.EMAIL + + private readonly logger = new Logger(EmailChannelService.name) + + constructor( + private readonly mailerService: MailerService, + private readonly configService: ConfigService, + ) {} + + buildUnsubscribeToken(channelId: string) { + // Stateless one-click unsubscribe: HMAC over channel id with the JWT secret. + // Avoids storing per-channel tokens just for unsubscribe. + const secret = + this.configService.get('JWT_SECRET') || + this.configService.get('JWT_ACCESS_TOKEN_SECRET') || + 'swetrix-unsubscribe' + const sig = createHmac('sha256', secret).update(channelId).digest('hex') + return `${channelId}.${sig}` + } + + verifyUnsubscribeToken(token: string): string | null { + const dot = token.indexOf('.') + if (dot === -1) return null + const channelId = token.slice(0, dot) + const sig = token.slice(dot + 1) + const expected = this.buildUnsubscribeToken(channelId).split('.')[1] + if (sig.length !== expected.length) return null + let mismatch = 0 + for (let i = 0; i < sig.length; i++) { + mismatch |= sig.charCodeAt(i) ^ expected.charCodeAt(i) + } + return mismatch === 0 ? channelId : null + } + + async send( + channel: NotificationChannel, + message: RenderedAlertMessage, + ): Promise { + const cfg = channel.config as { address?: string; unsubscribed?: boolean } + if (!cfg?.address || cfg.unsubscribed) return + + try { + const clientUrl = + this.configService.get('CLIENT_URL') || 'https://swetrix.com' + const unsubscribeUrl = `${clientUrl}/notification-channel/unsubscribe/${this.buildUnsubscribeToken(channel.id)}` + const subject = message.subject || 'Swetrix alert' + const html = wrapEmailHtml(message.body, unsubscribeUrl, subject) + await this.mailerService.sendRawEmail(cfg.address, subject, html) + } catch (reason) { + this.logger.error(`Failed to send email channel: ${reason}`) + } + } +} diff --git a/backend/apps/cloud/src/notification-channel/dispatchers/slack-channel.service.ts b/backend/apps/cloud/src/notification-channel/dispatchers/slack-channel.service.ts new file mode 100644 index 000000000..eaebf997c --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/slack-channel.service.ts @@ -0,0 +1,29 @@ +import { Injectable, Logger } from '@nestjs/common' +import { SlackService } from '../../integrations/slack/slack.service' +import { + NotificationChannel, + NotificationChannelType, +} from '../entity/notification-channel.entity' +import { ChannelDispatcher, RenderedAlertMessage } from './types' + +@Injectable() +export class SlackChannelService implements ChannelDispatcher { + readonly type = NotificationChannelType.SLACK + + private readonly logger = new Logger(SlackChannelService.name) + + constructor(private readonly slackService: SlackService) {} + + async send( + channel: NotificationChannel, + message: RenderedAlertMessage, + ): Promise { + const cfg = channel.config as { url?: string } + if (!cfg?.url) return + try { + await this.slackService.sendWebhook(cfg.url, message.body) + } catch (reason) { + this.logger.error(`Failed to send Slack alert: ${reason}`) + } + } +} diff --git a/backend/apps/cloud/src/notification-channel/dispatchers/telegram-channel.service.ts b/backend/apps/cloud/src/notification-channel/dispatchers/telegram-channel.service.ts new file mode 100644 index 000000000..b3414307e --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/telegram-channel.service.ts @@ -0,0 +1,35 @@ +import { Injectable, Logger, Optional } from '@nestjs/common' +import { TelegramService } from '../../integrations/telegram/telegram.service' +import { + NotificationChannel, + NotificationChannelType, +} from '../entity/notification-channel.entity' +import { ChannelDispatcher, RenderedAlertMessage } from './types' + +@Injectable() +export class TelegramChannelService implements ChannelDispatcher { + readonly type = NotificationChannelType.TELEGRAM + + private readonly logger = new Logger(TelegramChannelService.name) + + constructor( + @Optional() private readonly telegramService: TelegramService | null, + ) {} + + async send( + channel: NotificationChannel, + message: RenderedAlertMessage, + ): Promise { + const cfg = channel.config as { chatId?: string } + if (!cfg?.chatId || !this.telegramService) return + try { + await this.telegramService.addMessage(cfg.chatId, message.body, { + parse_mode: 'Markdown', + // @ts-expect-error untyped option + disable_web_page_preview: true, + }) + } catch (reason) { + this.logger.error(`Failed to queue Telegram alert: ${reason}`) + } + } +} diff --git a/backend/apps/cloud/src/notification-channel/dispatchers/types.ts b/backend/apps/cloud/src/notification-channel/dispatchers/types.ts new file mode 100644 index 000000000..6f0cbc6bf --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/types.ts @@ -0,0 +1,22 @@ +import { + NotificationChannel, + NotificationChannelType, +} from '../entity/notification-channel.entity' + +export interface RenderedAlertMessage { + /** Plain/markdown body, rendered from the alert's messageTemplate. */ + body: string + /** Optional subject (used by email). Defaults to the alert name. */ + subject?: string + /** The raw alert context, in case a dispatcher wants extra metadata. */ + context: Record +} + +export interface ChannelDispatcher { + readonly type: NotificationChannelType + /** Send the rendered alert. Should never throw — log and swallow per-channel failures. */ + send( + channel: NotificationChannel, + message: RenderedAlertMessage, + ): Promise +} diff --git a/backend/apps/cloud/src/notification-channel/dispatchers/webhook-channel.service.ts b/backend/apps/cloud/src/notification-channel/dispatchers/webhook-channel.service.ts new file mode 100644 index 000000000..7e6f0ec63 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/webhook-channel.service.ts @@ -0,0 +1,107 @@ +import { Injectable, Logger } from '@nestjs/common' +import { createHmac } from 'crypto' +import { + NotificationChannel, + NotificationChannelType, +} from '../entity/notification-channel.entity' +import { ChannelDispatcher, RenderedAlertMessage } from './types' + +@Injectable() +export class WebhookChannelService implements ChannelDispatcher { + readonly type = NotificationChannelType.WEBHOOK + + private readonly logger = new Logger(WebhookChannelService.name) + + static validateUrl(url: string): boolean { + try { + const u = new URL(url) + if (u.protocol !== 'https:' && u.protocol !== 'http:') return false + const host = u.hostname.toLowerCase() + // Block obvious internal targets so users can't probe localhost from our infra. + if ( + host === 'localhost' || + host === '127.0.0.1' || + host === '0.0.0.0' || + host.endsWith('.local') || + host.endsWith('.internal') + ) { + return false + } + return true + } catch { + return false + } + } + + async send( + channel: NotificationChannel, + message: RenderedAlertMessage, + ): Promise { + const cfg = channel.config as { url?: string; secret?: string | null } + if (!cfg?.url || !WebhookChannelService.validateUrl(cfg.url)) return + + try { + const payload = { + type: 'alert', + body: message.body, + subject: message.subject, + context: message.context, + timestamp: new Date().toISOString(), + } + const bodyStr = JSON.stringify(payload) + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': 'Swetrix-Webhook/1.0', + } + if (cfg.secret) { + const sig = createHmac('sha256', cfg.secret) + .update(bodyStr) + .digest('hex') + headers['X-Swetrix-Signature'] = `sha256=${sig}` + } + const res = await fetch(cfg.url, { + method: 'POST', + headers, + body: bodyStr, + signal: AbortSignal.timeout(10_000), + redirect: 'error', + }) + if (!res.ok) { + this.logger.warn( + `Outbound webhook ${cfg.url} responded with status ${res.status}`, + ) + } + } catch (reason) { + this.logger.error(`Failed to send outbound webhook: ${reason}`) + } + } + + async ping(url: string, secret?: string | null): Promise { + if (!WebhookChannelService.validateUrl(url)) return false + try { + const payload = JSON.stringify({ + type: 'verification', + timestamp: new Date().toISOString(), + }) + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': 'Swetrix-Webhook/1.0', + } + if (secret) { + const sig = createHmac('sha256', secret).update(payload).digest('hex') + headers['X-Swetrix-Signature'] = `sha256=${sig}` + } + const res = await fetch(url, { + method: 'POST', + headers, + body: payload, + signal: AbortSignal.timeout(5_000), + redirect: 'error', + }) + return res.ok + } catch (reason) { + this.logger.warn(`Webhook ping failed: ${reason}`) + return false + } + } +} diff --git a/backend/apps/cloud/src/notification-channel/dispatchers/webpush-channel.service.ts b/backend/apps/cloud/src/notification-channel/dispatchers/webpush-channel.service.ts new file mode 100644 index 000000000..bf287d3a8 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/webpush-channel.service.ts @@ -0,0 +1,90 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import webpush, { type PushSubscription } from 'web-push' +import { Repository } from 'typeorm' +import { InjectRepository } from '@nestjs/typeorm' +import { + NotificationChannel, + NotificationChannelType, +} from '../entity/notification-channel.entity' +import { ChannelDispatcher, RenderedAlertMessage } from './types' + +@Injectable() +export class WebpushChannelService implements ChannelDispatcher { + readonly type = NotificationChannelType.WEBPUSH + + private readonly logger = new Logger(WebpushChannelService.name) + + private vapidConfigured = false + + constructor( + private readonly configService: ConfigService, + @InjectRepository(NotificationChannel) + private readonly channelRepository: Repository, + ) { + this.configureVapid() + } + + private configureVapid() { + const publicKey = this.configService.get('VAPID_PUBLIC_KEY') + const privateKey = this.configService.get('VAPID_PRIVATE_KEY') + const subject = + this.configService.get('VAPID_SUBJECT') || + 'mailto:contact@swetrix.com' + + if (!publicKey || !privateKey) { + this.logger.warn( + 'VAPID keys are not configured; web push notifications will be disabled.', + ) + return + } + + try { + webpush.setVapidDetails(subject, publicKey, privateKey) + this.vapidConfigured = true + } catch (reason) { + this.logger.error(`Failed to configure VAPID: ${reason}`) + } + } + + getPublicKey(): string | null { + return this.configService.get('VAPID_PUBLIC_KEY') || null + } + + async send( + channel: NotificationChannel, + message: RenderedAlertMessage, + ): Promise { + if (!this.vapidConfigured) return + const cfg = channel.config as { + endpoint?: string + keys?: { p256dh: string; auth: string } + } + if (!cfg?.endpoint || !cfg?.keys?.p256dh || !cfg?.keys?.auth) return + + const subscription: PushSubscription = { + endpoint: cfg.endpoint, + keys: cfg.keys, + } + + const payload = JSON.stringify({ + title: message.subject || channel.name || 'Swetrix alert', + body: message.body.replace(/[*_`]/g, '').slice(0, 240), + url: (message.context as { dashboard_url?: string })?.dashboard_url, + }) + + try { + await webpush.sendNotification(subscription, payload) + } catch (reason: any) { + // 404/410 mean the subscription is dead — drop it. + if (reason?.statusCode === 404 || reason?.statusCode === 410) { + this.logger.warn( + `Pruning expired webpush channel ${channel.id} (status ${reason.statusCode})`, + ) + await this.channelRepository.delete(channel.id) + return + } + this.logger.error(`Failed to send webpush: ${reason?.message || reason}`) + } + } +} diff --git a/backend/apps/cloud/src/notification-channel/dto/notification-channel.dto.ts b/backend/apps/cloud/src/notification-channel/dto/notification-channel.dto.ts new file mode 100644 index 000000000..8331d2ef3 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dto/notification-channel.dto.ts @@ -0,0 +1,83 @@ +import { ApiProperty } from '@nestjs/swagger' +import { + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + Length, + IsObject, + IsUUID, + Matches, +} from 'class-validator' +import { PID_REGEX } from '../../common/constants' +import { NotificationChannelType } from '../entity/notification-channel.entity' + +export class CreateChannelDTO { + @ApiProperty() + @IsString() + @IsNotEmpty() + @Length(1, 100) + name: string + + @ApiProperty({ enum: NotificationChannelType }) + @IsEnum(NotificationChannelType) + type: NotificationChannelType + + // Discriminated config bag, validated per type at the service layer to keep the DTO simple. + @ApiProperty() + @IsObject() + config: Record + + // Exactly one of the three scope fields must be provided. + @ApiProperty({ required: false }) + @IsOptional() + @IsUUID('4') + organisationId?: string + + @ApiProperty({ required: false }) + @IsOptional() + @Matches(PID_REGEX, { + message: 'The provided Project ID (projectId) is incorrect', + }) + projectId?: string + + // userId omitted: user-scoped channels are owned by the authenticated caller. + @ApiProperty({ required: false, default: false }) + @IsOptional() + userScoped?: boolean +} + +export class UpdateChannelDTO { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @Length(1, 100) + name?: string + + @ApiProperty({ required: false }) + @IsOptional() + @IsObject() + config?: Record +} + +export class WebpushSubscribeDTO { + @ApiProperty() + @IsString() + @IsNotEmpty() + endpoint: string + + @ApiProperty() + @IsObject() + keys: { p256dh: string; auth: string } + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + userAgent?: string + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @Length(1, 100) + name?: string +} diff --git a/backend/apps/cloud/src/notification-channel/entity/notification-channel.entity.ts b/backend/apps/cloud/src/notification-channel/entity/notification-channel.entity.ts new file mode 100644 index 000000000..7ffef2af6 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/entity/notification-channel.entity.ts @@ -0,0 +1,95 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + ManyToMany, + Index, + Check, +} from 'typeorm' +import { ApiProperty } from '@nestjs/swagger' + +import { User } from '../../user/entities/user.entity' +import { Organisation } from '../../organisation/entity/organisation.entity' +import { Project } from '../../project/entity/project.entity' +import { Alert } from '../../alert/entity/alert.entity' + +export enum NotificationChannelType { + EMAIL = 'email', + TELEGRAM = 'telegram', + DISCORD = 'discord', + SLACK = 'slack', + WEBHOOK = 'webhook', + WEBPUSH = 'webpush', +} + +// Discriminated config payload by type. Persisted as JSON. +export type NotificationChannelConfig = + | { address: string; unsubscribed?: boolean } // email + | { chatId: string } // telegram + | { url: string; secret?: string | null } // discord | slack | webhook + | { + endpoint: string + keys: { p256dh: string; auth: string } + userAgent?: string | null + } // webpush + +@Entity({ name: 'notification_channel' }) +@Check( + '`channel_scope_check`', + '((`userId` IS NOT NULL) + (`organisationId` IS NOT NULL) + (`projectId` IS NOT NULL)) = 1', +) +export class NotificationChannel { + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string + + @ApiProperty() + @Column('varchar', { length: 100 }) + name: string + + @ApiProperty({ enum: NotificationChannelType }) + @Column({ + type: 'enum', + enum: NotificationChannelType, + }) + type: NotificationChannelType + + @ApiProperty() + @Column('json') + config: NotificationChannelConfig + + @ApiProperty() + @Column({ type: 'boolean', default: false }) + isVerified: boolean + + @Column('varchar', { length: 64, nullable: true, default: null }) + verificationToken: string | null + + @ApiProperty() + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + created: Date + + @ApiProperty() + @Column({ + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }) + updated: Date + + @Index() + @ManyToOne(() => User, { nullable: true, onDelete: 'CASCADE' }) + user: User | null + + @Index() + @ManyToOne(() => Organisation, { nullable: true, onDelete: 'CASCADE' }) + organisation: Organisation | null + + @Index() + @ManyToOne(() => Project, { nullable: true, onDelete: 'CASCADE' }) + project: Project | null + + @ManyToMany(() => Alert, (alert) => alert.channels) + alerts: Alert[] +} diff --git a/backend/apps/cloud/src/notification-channel/notification-channel.controller.ts b/backend/apps/cloud/src/notification-channel/notification-channel.controller.ts new file mode 100644 index 000000000..031641aab --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/notification-channel.controller.ts @@ -0,0 +1,315 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Res, + HttpStatus, + NotFoundException, + BadRequestException, +} from '@nestjs/common' +import { ApiBearerAuth, ApiTags, ApiResponse } from '@nestjs/swagger' +import type { Response } from 'express' + +import { Auth } from '../auth/decorators' +import { CurrentUserId } from '../auth/decorators/current-user-id.decorator' +import { Public } from '../auth/decorators/public.decorator' +import { AppLoggerService } from '../logger/logger.service' +import { ConfigService } from '@nestjs/config' + +import { + NotificationChannel, + NotificationChannelType, +} from './entity/notification-channel.entity' +import { NotificationChannelService } from './notification-channel.service' +import { + CreateChannelDTO, + UpdateChannelDTO, + WebpushSubscribeDTO, +} from './dto/notification-channel.dto' +import { ChannelDispatcherService } from './dispatchers/channel-dispatcher.service' +import { EmailChannelService } from './dispatchers/email-channel.service' +import { WebhookChannelService } from './dispatchers/webhook-channel.service' +import { WebpushChannelService } from './dispatchers/webpush-channel.service' +import { MailerService } from '../mailer/mailer.service' + +@ApiTags('NotificationChannel') +@Controller('notification-channel') +export class NotificationChannelController { + constructor( + private readonly channelService: NotificationChannelService, + private readonly dispatcherService: ChannelDispatcherService, + private readonly emailDispatcher: EmailChannelService, + private readonly webhookDispatcher: WebhookChannelService, + private readonly webpushDispatcher: WebpushChannelService, + private readonly mailerService: MailerService, + private readonly configService: ConfigService, + private readonly logger: AppLoggerService, + ) {} + + @ApiBearerAuth() + @Get('/') + @Auth() + @ApiResponse({ status: 200, type: [NotificationChannel] }) + async list( + @CurrentUserId() userId: string, + @Query('projectId') projectId?: string, + @Query('organisationId') organisationId?: string, + @Query('scope') scope?: 'user' | 'organisation' | 'project', + ) { + if (projectId) { + const channels = await this.channelService.getChannelsForProject( + projectId, + userId, + ) + return channels.map((c) => this.serialise(c)) + } + if (organisationId) { + const channels = await this.channelService.getChannelsForOrganisation( + organisationId, + userId, + ) + return channels.map((c) => this.serialise(c)) + } + const channels = await this.channelService.getVisibleChannels(userId) + const serialised = channels.map((c) => this.serialise(c)) + if (scope) { + return serialised.filter((c) => c.scope === scope) + } + return serialised + } + + @ApiBearerAuth() + @Get('/webpush/public-key') + @Auth() + async getWebpushPublicKey() { + return { publicKey: this.webpushDispatcher.getPublicKey() } + } + + @ApiBearerAuth() + @Post('/') + @Auth() + async create(@Body() dto: CreateChannelDTO, @CurrentUserId() userId: string) { + this.logger.log({ userId, type: dto.type }, 'POST /notification-channel') + const channel = await this.channelService.create(dto, userId) + if (channel.type === NotificationChannelType.EMAIL) { + await this.sendEmailVerification(channel) + } + return this.serialise(channel) + } + + @ApiBearerAuth() + @Patch('/:id') + @Auth() + async update( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateChannelDTO, + @CurrentUserId() userId: string, + ) { + const channel = await this.channelService.update(id, dto, userId) + if (channel.type === NotificationChannelType.EMAIL && !channel.isVerified) { + await this.sendEmailVerification(channel) + } + return this.serialise(channel) + } + + @ApiBearerAuth() + @Delete('/:id') + @Auth() + async remove( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @CurrentUserId() userId: string, + ) { + await this.channelService.delete(id, userId) + return { ok: true } + } + + @ApiBearerAuth() + @Post('/:id/test') + @Auth() + async sendTest( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @CurrentUserId() userId: string, + ) { + const channel = await this.channelService.findById(id) + if (!channel) throw new NotFoundException('Channel not found') + await this.channelService.ensureCallerCanManage(channel, userId) + await this.dispatcherService.dispatch([channel], { + body: 'This is a test notification from Swetrix. Your channel is wired up correctly!', + subject: 'Swetrix test notification', + context: { test: true }, + }) + return { ok: true } + } + + @ApiBearerAuth() + @Post('/:id/verify') + @Auth() + async kickoffVerification( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @CurrentUserId() userId: string, + ) { + const channel = await this.channelService.findById(id) + if (!channel) throw new NotFoundException('Channel not found') + await this.channelService.ensureCallerCanManage(channel, userId) + + if (channel.type === NotificationChannelType.EMAIL) { + await this.sendEmailVerification(channel) + return { ok: true } + } + + if (channel.type === NotificationChannelType.WEBHOOK) { + const cfg = channel.config as { url: string; secret?: string | null } + const ok = await this.webhookDispatcher.ping(cfg.url, cfg.secret) + if (!ok) { + throw new BadRequestException( + 'Webhook did not respond with 2xx within 5s', + ) + } + await this.channelService.markVerified(channel.id) + return { ok: true } + } + + throw new BadRequestException( + `Verification not supported for channel type ${channel.type}`, + ) + } + + @Public() + @Get('/verify/:token') + async completeVerification( + @Param('token') token: string, + @Res() res: Response, + ) { + const channel = await this.channelService.findByVerificationToken(token) + if (channel) { + await this.channelService.markVerified(channel.id) + } + const clientUrl = + this.configService.get('CLIENT_URL') || 'https://swetrix.com' + res.redirect( + HttpStatus.FOUND, + `${clientUrl}/notification-channels?verified=${channel ? '1' : '0'}`, + ) + } + + @Public() + @Get('/unsubscribe/:token') + async unsubscribeEmail(@Param('token') token: string, @Res() res: Response) { + const channelId = this.emailDispatcher.verifyUnsubscribeToken(token) + if (channelId) { + await this.channelService.setEmailUnsubscribed(channelId, true) + } + const clientUrl = + this.configService.get('CLIENT_URL') || 'https://swetrix.com' + res.redirect( + HttpStatus.FOUND, + `${clientUrl}/notification-channels/unsubscribed?ok=${channelId ? '1' : '0'}`, + ) + } + + @ApiBearerAuth() + @Post('/webpush/subscribe') + @Auth() + async subscribeWebpush( + @Body() dto: WebpushSubscribeDTO, + @CurrentUserId() userId: string, + ) { + const channel = await this.channelService.create( + { + name: dto.name || 'Browser notifications', + type: NotificationChannelType.WEBPUSH, + config: { + endpoint: dto.endpoint, + keys: dto.keys, + userAgent: dto.userAgent, + }, + userScoped: true, + }, + userId, + ) + // Web push channels are verified on subscribe. + await this.channelService.markVerified(channel.id) + return this.serialise({ ...channel, isVerified: true }) + } + + private async sendEmailVerification(channel: NotificationChannel) { + const cfg = channel.config as { address: string } + if (!channel.verificationToken) return + const clientUrl = + this.configService.get('CLIENT_URL') || 'https://swetrix.com' + const url = `${clientUrl}/api/notification-channel/verify/${channel.verificationToken}` + const html = ` +

Confirm your Swetrix notification channel

+

Click the button below to confirm we can send alerts to ${cfg.address}.

+

Confirm channel

+

If you didn't set this up, ignore this email.

+ ` + await this.mailerService.sendRawEmail( + cfg.address, + 'Confirm your Swetrix notification channel', + html, + ) + } + + private serialise(channel: NotificationChannel) { + return { + id: channel.id, + name: channel.name, + type: channel.type, + config: this.redactConfig(channel), + isVerified: channel.isVerified, + created: channel.created, + updated: channel.updated, + scope: channel.user + ? 'user' + : channel.organisation + ? 'organisation' + : 'project', + userId: channel.user?.id, + organisationId: channel.organisation?.id, + projectId: channel.project?.id, + } + } + + private redactConfig(channel: NotificationChannel) { + // Don't leak secrets / endpoints back to the dashboard. + if (channel.type === NotificationChannelType.WEBHOOK) { + const cfg = channel.config as { url: string; secret?: string | null } + return { url: cfg.url, hasSecret: !!cfg.secret } + } + if (channel.type === NotificationChannelType.WEBPUSH) { + const cfg = channel.config as { + endpoint: string + userAgent?: string | null + } + return { + endpoint: cfg.endpoint.slice(0, 64) + '…', + userAgent: cfg.userAgent, + } + } + if (channel.type === NotificationChannelType.SLACK) { + const cfg = channel.config as { url: string } + return { url: this.maskUrl(cfg.url) } + } + if (channel.type === NotificationChannelType.DISCORD) { + const cfg = channel.config as { url: string } + return { url: this.maskUrl(cfg.url) } + } + return channel.config + } + + private maskUrl(url: string) { + try { + const u = new URL(url) + return `${u.protocol}//${u.host}${u.pathname.split('/').slice(0, 3).join('/')}/…` + } catch { + return url + } + } +} diff --git a/backend/apps/cloud/src/notification-channel/notification-channel.module.ts b/backend/apps/cloud/src/notification-channel/notification-channel.module.ts new file mode 100644 index 000000000..0ee7c7eef --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/notification-channel.module.ts @@ -0,0 +1,57 @@ +import { forwardRef, Module } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' + +import { NotificationChannel } from './entity/notification-channel.entity' +import { NotificationChannelService } from './notification-channel.service' +import { NotificationChannelController } from './notification-channel.controller' +import { TemplateRendererService } from './template-renderer.service' + +import { ChannelDispatcherService } from './dispatchers/channel-dispatcher.service' +import { EmailChannelService } from './dispatchers/email-channel.service' +import { TelegramChannelService } from './dispatchers/telegram-channel.service' +import { DiscordChannelService } from './dispatchers/discord-channel.service' +import { SlackChannelService } from './dispatchers/slack-channel.service' +import { WebhookChannelService } from './dispatchers/webhook-channel.service' +import { WebpushChannelService } from './dispatchers/webpush-channel.service' + +import { ProjectModule } from '../project/project.module' +import { OrganisationModule } from '../organisation/organisation.module' +import { MailerModule } from '../mailer/mailer.module' +import { AppLoggerModule } from '../logger/logger.module' +import { TelegramModule } from '../integrations/telegram/telegram.module' +import { DiscordModule } from '../integrations/discord/discord.module' +import { SlackModule } from '../integrations/slack/slack.module' + +const telegramImports = + process.env.ENABLE_TELEGRAM_INTEGRATION === 'true' ? [TelegramModule] : [] + +@Module({ + imports: [ + TypeOrmModule.forFeature([NotificationChannel]), + forwardRef(() => ProjectModule), + OrganisationModule, + MailerModule, + AppLoggerModule, + DiscordModule, + SlackModule, + ...telegramImports, + ], + providers: [ + NotificationChannelService, + TemplateRendererService, + ChannelDispatcherService, + EmailChannelService, + TelegramChannelService, + DiscordChannelService, + SlackChannelService, + WebhookChannelService, + WebpushChannelService, + ], + controllers: [NotificationChannelController], + exports: [ + NotificationChannelService, + TemplateRendererService, + ChannelDispatcherService, + ], +}) +export class NotificationChannelModule {} diff --git a/backend/apps/cloud/src/notification-channel/notification-channel.service.ts b/backend/apps/cloud/src/notification-channel/notification-channel.service.ts new file mode 100644 index 000000000..d3bf6dfb5 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/notification-channel.service.ts @@ -0,0 +1,489 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { In, Repository, IsNull, Not } from 'typeorm' +import { randomBytes } from 'crypto' + +import { + NotificationChannel, + NotificationChannelType, + NotificationChannelConfig, +} from './entity/notification-channel.entity' +import { ProjectService } from '../project/project.service' +import { OrganisationService } from '../organisation/organisation.service' +import { OrganisationRole } from '../organisation/entity/organisation-member.entity' +import { + CreateChannelDTO, + UpdateChannelDTO, +} from './dto/notification-channel.dto' +import { WebhookChannelService } from './dispatchers/webhook-channel.service' + +const MAX_CHANNELS_PER_SCOPE = 50 +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +const isValidHttpsUrl = (url: string, allowedHostSuffix: string[] = []) => { + try { + const u = new URL(url) + if (u.protocol !== 'https:') return false + if (allowedHostSuffix.length === 0) return true + const host = u.hostname.toLowerCase() + return allowedHostSuffix.some( + (suffix) => host === suffix || host.endsWith(`.${suffix}`), + ) + } catch { + return false + } +} + +@Injectable() +export class NotificationChannelService { + constructor( + @InjectRepository(NotificationChannel) + private readonly channelRepository: Repository, + private readonly projectService: ProjectService, + private readonly organisationService: OrganisationService, + ) {} + + // --- Scope helpers -------------------------------------------------------- + + /** All channel ids visible to the caller across user/orgs/projects. */ + async getVisibleChannels(userId: string): Promise { + const [ + userOwned, + ownedProjectChannels, + orgChannels, + sharedProjectChannels, + ] = await Promise.all([ + this.channelRepository.find({ where: { user: { id: userId } } }), + this.getProjectChannelsForUserOwnedProjects(userId), + this.getOrgChannelsForUserMemberships(userId), + this.getSharedProjectChannels(userId), + ]) + + const map = new Map() + for (const c of [ + ...userOwned, + ...ownedProjectChannels, + ...orgChannels, + ...sharedProjectChannels, + ]) { + map.set(c.id, c) + } + return Array.from(map.values()) + } + + private async getProjectChannelsForUserOwnedProjects(userId: string) { + const pids = await this.projectService.getProjectIdsByAdminId(userId) + if (pids.length === 0) return [] + return this.channelRepository.find({ + where: { project: { id: In(pids) } }, + }) + } + + private async getOrgChannelsForUserMemberships(userId: string) { + const memberships = await this.organisationService.findMemberships({ + where: { user: { id: userId }, confirmed: true }, + relations: ['organisation'], + }) + const orgIds = memberships + .map((m) => m.organisation?.id) + .filter((id): id is string => !!id) + if (orgIds.length === 0) return [] + return this.channelRepository.find({ + where: { organisation: { id: In(orgIds) } }, + }) + } + + private async getSharedProjectChannels(userId: string) { + // Channels owned by projects the user has admin share / org membership on. + // We resolve through the existing projectService permission code. + const allProjectChannels = await this.channelRepository.find({ + where: { project: Not(IsNull()) }, + relations: ['project'], + }) + if (allProjectChannels.length === 0) return [] + const accessible: NotificationChannel[] = [] + for (const channel of allProjectChannels) { + if (!channel.project) continue + const project = await this.projectService.getFullProject( + channel.project.id, + ) + if (!project) continue + try { + this.projectService.allowedToView(project, userId) + accessible.push(channel) + } catch { + // ignore — not allowed + } + } + return accessible + } + + /** Channels usable on a given project (project-owned + project owner's user channels + project's organisation channels). */ + async getChannelsForProject( + projectId: string, + userId: string, + ): Promise { + const project = await this.projectService.getFullProject(projectId) + if (!project) throw new NotFoundException('Project not found') + this.projectService.allowedToView(project, userId) + + const candidates: NotificationChannel[] = [] + + const projectChannels = await this.channelRepository.find({ + where: { project: { id: projectId } }, + }) + candidates.push(...projectChannels) + + if (project.admin?.id) { + const ownerChannels = await this.channelRepository.find({ + where: { user: { id: project.admin.id } }, + }) + candidates.push(...ownerChannels) + } + + if (project.organisation?.id) { + const orgChannels = await this.channelRepository.find({ + where: { organisation: { id: project.organisation.id } }, + }) + candidates.push(...orgChannels) + } + + const map = new Map() + for (const c of candidates) map.set(c.id, c) + return Array.from(map.values()) + } + + /** Channels owned by an organisation, accessible by a user that is a confirmed member. */ + async getChannelsForOrganisation( + organisationId: string, + userId: string, + ): Promise { + const memberships = await this.organisationService.findMemberships({ + where: { + user: { id: userId }, + organisation: { id: organisationId }, + confirmed: true, + }, + }) + if (memberships.length === 0) { + throw new ForbiddenException('You are not a member of this organisation') + } + return this.channelRepository.find({ + where: { organisation: { id: organisationId } }, + }) + } + + /** Validate channel ids the user wants to attach to a project alert. */ + async validateChannelsForProject( + channelIds: string[], + projectId: string, + userId: string, + ): Promise { + if (channelIds.length === 0) return [] + const allowed = await this.getChannelsForProject(projectId, userId) + const allowedIds = new Set(allowed.map((c) => c.id)) + const missing = channelIds.filter((id) => !allowedIds.has(id)) + if (missing.length > 0) { + throw new ForbiddenException( + `One or more channels are not accessible for this project: ${missing.join(', ')}`, + ) + } + return allowed.filter((c) => channelIds.includes(c.id)) + } + + // --- CRUD ----------------------------------------------------------------- + + async findById(id: string) { + return this.channelRepository.findOne({ + where: { id }, + relations: ['user', 'organisation', 'project'], + }) + } + + async ensureCallerCanManage(channel: NotificationChannel, userId: string) { + if (channel.user) { + if (channel.user.id !== userId) { + throw new ForbiddenException('Not allowed to manage this channel') + } + return + } + if (channel.organisation) { + const memberships = await this.organisationService.findMemberships({ + where: { + user: { id: userId }, + organisation: { id: channel.organisation.id }, + confirmed: true, + role: In([OrganisationRole.owner, OrganisationRole.admin]), + }, + }) + if (memberships.length === 0) { + throw new ForbiddenException('Not allowed to manage this channel') + } + return + } + if (channel.project) { + const project = await this.projectService.getFullProject( + channel.project.id, + ) + if (!project) throw new NotFoundException('Project not found') + this.projectService.allowedToManage(project, userId) + return + } + throw new ForbiddenException('Channel has no scope') + } + + async create( + dto: CreateChannelDTO, + userId: string, + ): Promise { + const scopes = [ + dto.organisationId ? 1 : 0, + dto.projectId ? 1 : 0, + dto.userScoped ? 1 : 0, + ] + const scopeCount = scopes.reduce((a, b) => a + b, 0) + if (scopeCount > 1) { + throw new BadRequestException( + 'Specify exactly one scope (user / organisation / project)', + ) + } + // Default to user-scoped when nothing supplied. + const scope = dto.projectId + ? 'project' + : dto.organisationId + ? 'organisation' + : 'user' + + const partial: Partial = { + name: dto.name, + type: dto.type, + config: this.normaliseConfig(dto.type, dto.config || {}), + isVerified: false, + } + + if (scope === 'user') { + partial.user = { id: userId } as any + } else if (scope === 'organisation') { + const canManage = await this.organisationService.canManageOrganisation( + dto.organisationId!, + userId, + ) + if (!canManage) { + throw new ForbiddenException( + 'You are not allowed to create channels for this organisation', + ) + } + partial.organisation = { id: dto.organisationId! } as any + } else { + const project = await this.projectService.getFullProject(dto.projectId!) + if (!project) throw new NotFoundException('Project not found') + this.projectService.allowedToManage( + project, + userId, + 'You are not allowed to create channels for this project', + ) + partial.project = { id: dto.projectId! } as any + } + + await this.enforceScopeLimit(scope, { + userId: scope === 'user' ? userId : undefined, + organisationId: scope === 'organisation' ? dto.organisationId : undefined, + projectId: scope === 'project' ? dto.projectId : undefined, + }) + + // Default verification for channel types where the address itself is the proof + // (Slack/Discord use validated hooks; webhook still requires explicit ping). + if ( + dto.type === NotificationChannelType.SLACK || + dto.type === NotificationChannelType.DISCORD + ) { + partial.isVerified = true + } + + if (dto.type === NotificationChannelType.EMAIL) { + partial.verificationToken = randomBytes(24).toString('hex') + } + if (dto.type === NotificationChannelType.WEBHOOK) { + partial.verificationToken = randomBytes(24).toString('hex') + } + + return this.channelRepository.save(this.channelRepository.create(partial)) + } + + private async enforceScopeLimit( + scope: 'user' | 'organisation' | 'project', + ids: { userId?: string; organisationId?: string; projectId?: string }, + ) { + const where = + scope === 'user' + ? { user: { id: ids.userId! } } + : scope === 'organisation' + ? { organisation: { id: ids.organisationId! } } + : { project: { id: ids.projectId! } } + const count = await this.channelRepository.count({ where }) + if (count >= MAX_CHANNELS_PER_SCOPE) { + throw new ForbiddenException( + `Maximum number of notification channels (${MAX_CHANNELS_PER_SCOPE}) reached for this scope.`, + ) + } + } + + normaliseConfig( + type: NotificationChannelType, + raw: Record, + ): NotificationChannelConfig { + switch (type) { + case NotificationChannelType.EMAIL: { + const address = String(raw.address || '') + .trim() + .toLowerCase() + if (!EMAIL_REGEX.test(address)) { + throw new BadRequestException('Invalid email address') + } + return { address, unsubscribed: false } + } + case NotificationChannelType.TELEGRAM: { + const chatId = String(raw.chatId || '').trim() + if (!chatId) throw new BadRequestException('chatId is required') + return { chatId } + } + case NotificationChannelType.SLACK: { + const url = String(raw.url || '').trim() + if (!isValidHttpsUrl(url, ['hooks.slack.com'])) { + throw new BadRequestException('Invalid Slack webhook URL') + } + return { url } + } + case NotificationChannelType.DISCORD: { + const url = String(raw.url || '').trim() + if (!isValidHttpsUrl(url, ['discord.com'])) { + throw new BadRequestException('Invalid Discord webhook URL') + } + return { url } + } + case NotificationChannelType.WEBHOOK: { + const url = String(raw.url || '').trim() + if (!WebhookChannelService.validateUrl(url)) { + throw new BadRequestException('Invalid webhook URL') + } + return { + url, + secret: raw.secret ? String(raw.secret) : null, + } + } + case NotificationChannelType.WEBPUSH: { + const endpoint = String(raw.endpoint || '').trim() + const keys = raw.keys as { p256dh?: string; auth?: string } + if (!endpoint || !keys?.p256dh || !keys?.auth) { + throw new BadRequestException('Invalid web push subscription') + } + return { + endpoint, + keys: { p256dh: keys.p256dh, auth: keys.auth }, + userAgent: raw.userAgent ? String(raw.userAgent) : null, + } + } + default: + throw new BadRequestException(`Unsupported channel type: ${type}`) + } + } + + async update( + id: string, + dto: UpdateChannelDTO, + userId: string, + ): Promise { + const channel = await this.findById(id) + if (!channel) throw new NotFoundException('Channel not found') + await this.ensureCallerCanManage(channel, userId) + + if (dto.name) channel.name = dto.name + if (dto.config) { + channel.config = this.normaliseConfig(channel.type, dto.config) + // Re-set isVerified based on type rules + if ( + channel.type === NotificationChannelType.SLACK || + channel.type === NotificationChannelType.DISCORD || + channel.type === NotificationChannelType.WEBPUSH + ) { + channel.isVerified = true + } else if (channel.type === NotificationChannelType.EMAIL) { + channel.isVerified = false + channel.verificationToken = randomBytes(24).toString('hex') + } else if (channel.type === NotificationChannelType.WEBHOOK) { + channel.isVerified = false + channel.verificationToken = randomBytes(24).toString('hex') + } + } + return this.channelRepository.save(channel) + } + + async delete(id: string, userId: string): Promise { + const channel = await this.findById(id) + if (!channel) throw new NotFoundException('Channel not found') + await this.ensureCallerCanManage(channel, userId) + await this.channelRepository.delete(id) + } + + async markVerified(id: string) { + await this.channelRepository.update(id, { + isVerified: true, + verificationToken: null, + }) + } + + async findByVerificationToken(token: string) { + return this.channelRepository.findOne({ + where: { verificationToken: token }, + }) + } + + async setEmailUnsubscribed(id: string, unsubscribed: boolean) { + const channel = await this.findById(id) + if (!channel || channel.type !== NotificationChannelType.EMAIL) return + const cfg = channel.config as { address: string; unsubscribed?: boolean } + channel.config = { ...cfg, unsubscribed } + await this.channelRepository.save(channel) + } + + /** Used by Telegram bot start scene to upsert a channel after linking. */ + async upsertTelegramChannel(userId: string, chatId: string) { + const existing = await this.channelRepository.findOne({ + where: { + type: NotificationChannelType.TELEGRAM, + user: { id: userId }, + }, + }) + if (existing) { + existing.config = { chatId } + existing.isVerified = true + return this.channelRepository.save(existing) + } + return this.channelRepository.save( + this.channelRepository.create({ + name: 'Telegram', + type: NotificationChannelType.TELEGRAM, + config: { chatId }, + isVerified: true, + user: { id: userId } as any, + }), + ) + } + + async deleteTelegramChannelsByChatId(chatId: string) { + const channels = await this.channelRepository.find({ + where: { type: NotificationChannelType.TELEGRAM }, + }) + const matching = channels.filter( + (c) => (c.config as { chatId?: string })?.chatId === chatId, + ) + if (matching.length > 0) { + await this.channelRepository.delete(matching.map((c) => c.id)) + } + } +} diff --git a/backend/apps/cloud/src/notification-channel/template-renderer.service.ts b/backend/apps/cloud/src/notification-channel/template-renderer.service.ts new file mode 100644 index 000000000..ae0f40bab --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/template-renderer.service.ts @@ -0,0 +1,87 @@ +import { Injectable, Logger } from '@nestjs/common' +import handlebars from 'handlebars' +import { QueryMetric } from '../alert/dto/alert.dto' + +const COMMON_VARIABLES = [ + 'alert_name', + 'project_name', + 'project_id', + 'dashboard_url', + 'value', + 'threshold', + 'condition', + 'time_window', +] as const + +const METRIC_VARIABLES: Record = { + [QueryMetric.PAGE_VIEWS]: ['views'], + [QueryMetric.UNIQUE_PAGE_VIEWS]: ['unique_views'], + [QueryMetric.ONLINE_USERS]: ['online_count'], + [QueryMetric.CUSTOM_EVENTS]: [ + 'event_name', + 'event_count', + 'every_event_mode', + ], + [QueryMetric.ERRORS]: [ + 'error_count', + 'error_message', + 'error_name', + 'errors_url', + 'is_new_only', + ], +} + +const DEFAULT_ALERT_TEMPLATE = `🔔 Alert *{{alert_name}}* triggered! + +Project: [{{project_name}}]({{dashboard_url}}) +Value: *{{value}}* {{condition}} *{{threshold}}* in the last {{time_window}}.` + +const DEFAULT_ERROR_ALERT_TEMPLATE = `🐞 Error alert *{{alert_name}}* triggered! + +Project: [{{project_name}}]({{dashboard_url}}) +Error: \`{{error_name}}\` +Message: \`{{error_message}}\` + +[View error]({{errors_url}})` + +export const DEFAULT_EMAIL_SUBJECT_TEMPLATE = `[Swetrix] {{alert_name}} triggered` + +@Injectable() +export class TemplateRendererService { + private readonly logger = new Logger(TemplateRendererService.name) + + // Compiled-template cache keyed by raw template string. Bounded — alerts re-use + // the same template per fire so this stays small in practice. + private readonly cache = new Map() + + getVariablesForMetric(metric: QueryMetric): string[] { + const extras = METRIC_VARIABLES[metric] ?? [] + return [...COMMON_VARIABLES, ...extras] + } + + getDefaultTemplate(metric: QueryMetric): string { + if (metric === QueryMetric.ERRORS) { + return DEFAULT_ERROR_ALERT_TEMPLATE + } + return DEFAULT_ALERT_TEMPLATE + } + + render( + template: string | null | undefined, + context: Record, + ): string { + if (!template) return '' + try { + let compiled = this.cache.get(template) + if (!compiled) { + compiled = handlebars.compile(template, { noEscape: true }) + if (this.cache.size > 256) this.cache.clear() + this.cache.set(template, compiled) + } + return compiled(context) + } catch (reason) { + this.logger.error(`Failed to render alert template: ${reason}`) + return template + } + } +} diff --git a/backend/apps/cloud/src/task-manager/task-manager.module.ts b/backend/apps/cloud/src/task-manager/task-manager.module.ts index f889f3df6..589074d34 100644 --- a/backend/apps/cloud/src/task-manager/task-manager.module.ts +++ b/backend/apps/cloud/src/task-manager/task-manager.module.ts @@ -14,6 +14,7 @@ import { DiscordModule } from '../integrations/discord/discord.module' import { SlackModule } from '../integrations/slack/slack.module' import { GoalModule } from '../goal/goal.module' import { RevenueModule } from '../revenue/revenue.module' +import { NotificationChannelModule } from '../notification-channel/notification-channel.module' @Module({ imports: [ @@ -29,6 +30,7 @@ import { RevenueModule } from '../revenue/revenue.module' SlackModule, GoalModule, RevenueModule, + NotificationChannelModule, ], providers: [TaskManagerService, TelegramService], exports: [TaskManagerService], diff --git a/backend/apps/cloud/src/task-manager/task-manager.service.ts b/backend/apps/cloud/src/task-manager/task-manager.service.ts index ff7d25c97..126719385 100644 --- a/backend/apps/cloud/src/task-manager/task-manager.service.ts +++ b/backend/apps/cloud/src/task-manager/task-manager.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' -import { IsNull, LessThan, In, Not, Between } from 'typeorm' +import { IsNull, LessThan, Not, Between } from 'typeorm' import { ConfigService } from '@nestjs/config' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' @@ -59,6 +59,18 @@ import { RevenueService } from '../revenue/revenue.service' import { PaddleAdapter } from '../revenue/adapters/paddle.adapter' import { StripeAdapter } from '../revenue/adapters/stripe.adapter' import { ProxyDomainService } from '../project/proxy-domain.service' +import { ChannelDispatcherService } from '../notification-channel/dispatchers/channel-dispatcher.service' +import { + TemplateRendererService, + DEFAULT_EMAIL_SUBJECT_TEMPLATE, +} from '../notification-channel/template-renderer.service' +import { + AlertContext, + AlertContextErrors, + QUERY_CONDITION_LABEL, + QUERY_TIME_LABEL, +} from '../notification-channel/alert-context' +import { NotificationChannelType } from '../notification-channel/entity/notification-channel.entity' dayjs.extend(utc) @@ -325,8 +337,35 @@ export class TaskManagerService { private readonly paddleAdapter: PaddleAdapter, private readonly stripeAdapter: StripeAdapter, private readonly proxyDomainService: ProxyDomainService, + private readonly channelDispatcher: ChannelDispatcherService, + private readonly templateRenderer: TemplateRendererService, ) {} + // Build a rendered alert message from a raw AlertContext using the alert's + // per-channel templates (falling back to the metric's default template). + private renderAlertMessage( + alert: { + messageTemplate: string | null + emailSubjectTemplate: string | null + name: string + }, + context: AlertContext, + hasEmailChannel: boolean, + ) { + const template = + alert.messageTemplate?.trim() || + this.templateRenderer.getDefaultTemplate(context.metric) + const ctxRecord = context as unknown as Record + const body = this.templateRenderer.render(template, ctxRecord) + const subject = hasEmailChannel + ? this.templateRenderer.render( + alert.emailSubjectTemplate?.trim() || DEFAULT_EMAIL_SUBJECT_TEMPLATE, + ctxRecord, + ) + : alert.name + return { body, subject, context: ctxRecord } + } + /** * Build goal match condition for querying conversions */ @@ -1514,57 +1553,31 @@ export class TaskManagerService { @Cron(CronExpression.EVERY_5_MINUTES) async checkOnlineUsersAlerts() { - const projects = await this.projectService.find({ - where: [ - { - admin: { - isTelegramChatIdConfirmed: true, - planCode: Not(PlanCode.none), - dashboardBlockReason: IsNull(), - }, - }, - { - admin: { - slackWebhookUrl: Not(IsNull()), - planCode: Not(PlanCode.none), - dashboardBlockReason: IsNull(), - }, - }, - { + // Pull all online_users alerts on active accounts. Channel verification is + // enforced per-channel by the dispatcher (skip unverified/unsubscribed). + const alerts = await this.alertService.find({ + where: { + active: true, + queryMetric: QueryMetric.ONLINE_USERS, + project: { admin: { - discordWebhookUrl: Not(IsNull()), planCode: Not(PlanCode.none), dashboardBlockReason: IsNull(), }, }, - ], - relations: ['admin'], - }) - - const alerts = await this.alertService.find({ - where: { - project: In(_map(projects, 'id')), - active: true, - queryMetric: QueryMetric.ONLINE_USERS, }, - relations: ['project'], + relations: ['project', 'project.admin', 'channels'], }) const promises = _map(alerts, async (alert) => { try { - const project = _find(projects, { id: alert.project.id }) - - if (!project) { - this.logger.warn( - `[CRON WORKER](checkOnlineUsersAlerts) Alert ${alert.id} references missing project ${alert.project?.id}`, - ) - return - } + const project = alert.project + if (!project) return + if (!alert.channels || alert.channels.length === 0) return if (alert.lastTriggered !== null) { const lastTriggered = new Date(alert.lastTriggered) const now = new Date() - if (now.getTime() - lastTriggered.getTime() < 24 * 60 * 60 * 1000) { return } @@ -1573,44 +1586,35 @@ export class TaskManagerService { const online = await this.analyticsService.getOnlineUserCount( project.id, ) - const alertName = this.telegramService.escapeTelegramMarkdown( - alert.name, - ) - const projectName = this.telegramService.escapeTelegramMarkdown( - project.name, - ) - const text = `🔔 Alert *${alertName}* got triggered!\nYour project *${projectName}* has *${online}* online users right now!` if ( - checkQueryCondition(online, alert.queryValue, alert.queryCondition) + !checkQueryCondition(online, alert.queryValue, alert.queryCondition) ) { - // @ts-expect-error - await this.alertService.update(alert.id, { - lastTriggered: new Date(), - }) - if (project.admin && project.admin.isTelegramChatIdConfirmed) { - this.telegramService.addMessage( - project.admin.telegramChatId, - text, - { - parse_mode: 'Markdown', - }, - ) - } - if (project.admin.discordWebhookUrl) { - await this.discordService.sendWebhook( - project.admin.discordWebhookUrl, - text, - ) - } - - if (project.admin.slackWebhookUrl) { - await this.slackService.sendWebhook( - project.admin.slackWebhookUrl, - text, - ) - } + return } + + // @ts-expect-error TypeORM typing for partial update + await this.alertService.update(alert.id, { lastTriggered: new Date() }) + + const clientUrl = this.configService.get('CLIENT_URL') + const context: AlertContext = { + alert_name: alert.name, + project_name: project.name, + project_id: project.id, + dashboard_url: `${clientUrl}/projects/${project.id}`, + metric: QueryMetric.ONLINE_USERS, + value: online, + threshold: alert.queryValue, + condition: QUERY_CONDITION_LABEL[alert.queryCondition] || null, + time_window: 'now', + online_count: online, + } as AlertContext + + const hasEmail = alert.channels.some( + (c) => c.type === NotificationChannelType.EMAIL, + ) + const message = this.renderAlertMessage(alert, context, hasEmail) + await this.channelDispatcher.dispatch(alert.channels, message) } catch (reason) { this.logger.error( `[CRON WORKER](checkOnlineUsersAlerts) Failed to process alert ${alert.id}: ${reason}`, @@ -1632,35 +1636,25 @@ export class TaskManagerService { async checkMetricAlerts() { const CRON_INTERVAL_SECONDS = 300 - const projects = await this.projectService.find({ - where: { - admin: { - planCode: Not(PlanCode.none), - dashboardBlockReason: IsNull(), - }, - }, - relations: ['admin'], - }) - const alerts = await this.alertService.find({ where: { - project: In(_map(projects, 'id')), active: true, queryMetric: Not(QueryMetric.ONLINE_USERS), + project: { + admin: { + planCode: Not(PlanCode.none), + dashboardBlockReason: IsNull(), + }, + }, }, - relations: ['project'], + relations: ['project', 'project.admin', 'channels'], }) const promises = _map(alerts, async (alert) => { try { - const project = _find(projects, { id: alert.project.id }) - - if (!project) { - this.logger.warn( - `[CRON WORKER](checkMetricAlerts) Alert ${alert.id} references missing project ${alert.project?.id}`, - ) - return - } + const project = alert.project + if (!project) return + if (!alert.channels || alert.channels.length === 0) return if ( alert.lastTriggered !== null && @@ -1838,145 +1832,85 @@ export class TaskManagerService { lastTriggered: new Date(), }) - let queryMetricString = '' - switch (alert.queryMetric) { - case QueryMetric.CUSTOM_EVENTS: - queryMetricString = 'custom events' - break - case QueryMetric.UNIQUE_PAGE_VIEWS: - queryMetricString = 'unique page views' - break - case QueryMetric.PAGE_VIEWS: - queryMetricString = 'page views' - break - case QueryMetric.ERRORS: - queryMetricString = alert.alertOnNewErrorsOnly - ? 'new errors' - : 'errors' - break - default: - queryMetricString = alert.queryMetric - } - const effectiveQueryTimeString = alert.queryMetric === QueryMetric.ERRORS ? `${CRON_INTERVAL_SECONDS / 60} minutes` : alert.queryMetric === QueryMetric.CUSTOM_EVENTS && alert.alertOnEveryCustomEvent ? `${CRON_INTERVAL_SECONDS / 60} minutes` - : getQueryTimeString(alert.queryTime as QueryTime) - - let text = `` + : QUERY_TIME_LABEL[alert.queryTime as QueryTime] || + getQueryTimeString(alert.queryTime as QueryTime) const clientUrl = this.configService.get('CLIENT_URL') - const escapedProjectLink = this.telegramService.escapeTelegramMarkdown( - `${clientUrl}/projects/${project.id}`, - ) - - if (alert.queryMetric === QueryMetric.ERRORS) { - if (!errorDetails) { - console.error( - `[CRON WORKER](checkMetricAlerts) Error details not found for alert ${alert.id}`, - ) - console.error(queryResult) - return - } - - const escapedErrorLink = this.telegramService.escapeTelegramMarkdown( - `${clientUrl}/projects/${project.id}?tab=errors&eid=${errorDetails.eid}`, - ) - - const alertName = this.telegramService.escapeTelegramMarkdown( - alert.name, - ) - const projectName = this.telegramService.escapeTelegramMarkdown( - project.name, - ) - const errorName = this.telegramService.escapeTelegramMarkdown( - errorDetails.name || 'N/A', - ) - const errorMessage = this.telegramService.escapeTelegramMarkdown( - errorDetails.message || 'N/A', - ) - const filename = this.telegramService.escapeTelegramMarkdown( - errorDetails.filename || 'N/A', - ) + const dashboardUrl = `${clientUrl}/projects/${project.id}` - let locationInfo = 'Location: Not available' - if ( - errorDetails.filename || - errorDetails.lineno !== null || - errorDetails.colno !== null - ) { - const ln = - errorDetails.lineno !== null - ? errorDetails.lineno.toString() - : 'N/A' - const cn = - errorDetails.colno !== null - ? errorDetails.colno.toString() - : 'N/A' - locationInfo = `File: ${filename}, Line: ${ln}, Col: ${cn}` - } - - text = - `🐞 Error alert *${alertName}* triggered!\n\n` + - `Project: [${projectName}](${escapedProjectLink})\n` + - `Error: \`${errorName}\`\n` + - `Message: \`${errorMessage}\`\n\n` + - `${locationInfo}\n\n` + - `[View error](${escapedErrorLink})` - } else { - const alertName = this.telegramService.escapeTelegramMarkdown( - alert.name, - ) - const projectName = this.telegramService.escapeTelegramMarkdown( - project.name, + if (alert.queryMetric === QueryMetric.ERRORS && !errorDetails) { + this.logger.warn( + `[CRON WORKER](checkMetricAlerts) Error details not found for alert ${alert.id}`, ) - - let customEventInfo = '' - if ( - alert.queryMetric === QueryMetric.CUSTOM_EVENTS && - alert.queryCustomEvent - ) { - customEventInfo = ` "${alert.queryCustomEvent}"` - } - - if ( - alert.queryMetric === QueryMetric.CUSTOM_EVENTS && - alert.alertOnEveryCustomEvent - ) { - text = - `🔔 Alert *${alertName}* triggered!\n\n` + - `Your project [${projectName}](${escapedProjectLink}) has had *${count}${customEventInfo}* ${queryMetricString} occur in the last *${effectiveQueryTimeString}*!` - } else { - text = - `🔔 Alert *${alertName}* triggered!\n\n` + - `Your project [${projectName}](${escapedProjectLink}) has had *${count}${customEventInfo}* ${queryMetricString} in the last *${effectiveQueryTimeString}*!` - } + return } - if (project.admin?.isTelegramChatIdConfirmed) { - this.telegramService.addMessage(project.admin.telegramChatId, text, { - parse_mode: 'Markdown', - // @ts-expect-error It's not typed - disable_web_page_preview: true, - }) - } + let context: AlertContext - if (project.admin?.discordWebhookUrl) { - await this.discordService.sendWebhook( - project.admin.discordWebhookUrl, - text, - ) + if (alert.queryMetric === QueryMetric.ERRORS) { + const errors_url = `${dashboardUrl}?tab=errors&eid=${errorDetails!.eid}` + context = { + alert_name: alert.name, + project_name: project.name, + project_id: project.id, + dashboard_url: dashboardUrl, + metric: QueryMetric.ERRORS, + value: count, + threshold: null, + condition: null, + time_window: effectiveQueryTimeString, + error_count: count, + error_message: errorDetails!.message || '', + error_name: errorDetails!.name || '', + errors_url, + is_new_only: !!alert.alertOnNewErrorsOnly, + } as AlertContextErrors + } else if (alert.queryMetric === QueryMetric.CUSTOM_EVENTS) { + context = { + alert_name: alert.name, + project_name: project.name, + project_id: project.id, + dashboard_url: dashboardUrl, + metric: QueryMetric.CUSTOM_EVENTS, + value: count, + threshold: alert.queryValue ?? null, + condition: alert.queryCondition + ? QUERY_CONDITION_LABEL[alert.queryCondition] + : null, + time_window: effectiveQueryTimeString, + event_name: alert.queryCustomEvent || '', + event_count: count, + every_event_mode: !!alert.alertOnEveryCustomEvent, + } as AlertContext + } else { + const isUnique = alert.queryMetric === QueryMetric.UNIQUE_PAGE_VIEWS + context = { + alert_name: alert.name, + project_name: project.name, + project_id: project.id, + dashboard_url: dashboardUrl, + metric: alert.queryMetric, + value: count, + threshold: alert.queryValue ?? null, + condition: alert.queryCondition + ? QUERY_CONDITION_LABEL[alert.queryCondition] + : null, + time_window: effectiveQueryTimeString, + ...(isUnique ? { unique_views: count } : { views: count }), + } as AlertContext } - if (project.admin?.slackWebhookUrl) { - await this.slackService.sendWebhook( - project.admin.slackWebhookUrl, - text, - ) - } + const hasEmail = alert.channels.some( + (c) => c.type === NotificationChannelType.EMAIL, + ) + const message = this.renderAlertMessage(alert, context, hasEmail) + await this.channelDispatcher.dispatch(alert.channels, message) } catch (reason) { this.logger.error( `[CRON WORKER](checkMetricAlerts) Failed to process alert ${alert.id}: ${reason}`, diff --git a/backend/migrations/mysql/2026_04_23_notification_channels.sql b/backend/migrations/mysql/2026_04_23_notification_channels.sql new file mode 100644 index 000000000..187650fbc --- /dev/null +++ b/backend/migrations/mysql/2026_04_23_notification_channels.sql @@ -0,0 +1,78 @@ +-- Notification channels: polymorphic (user/org/project) channel registry, +-- many-to-many to alerts, plus per-alert message templates. +CREATE TABLE IF NOT EXISTS `notification_channel` ( + `id` varchar(36) NOT NULL, + `name` varchar(100) NOT NULL, + `type` enum('email','telegram','discord','slack','webhook','webpush') NOT NULL, + `config` json NOT NULL, + `isVerified` tinyint(1) NOT NULL DEFAULT 0, + `verificationToken` varchar(64) DEFAULT NULL, + `created` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `userId` varchar(36) DEFAULT NULL, + `organisationId` varchar(36) DEFAULT NULL, + `projectId` varchar(12) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `IDX_notification_channel_user` (`userId`), + KEY `IDX_notification_channel_org` (`organisationId`), + KEY `IDX_notification_channel_project` (`projectId`), + CONSTRAINT `channel_scope_check` CHECK ( + ((`userId` IS NOT NULL) + (`organisationId` IS NOT NULL) + (`projectId` IS NOT NULL)) = 1 + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `alert_channels` ( + `alertId` varchar(36) NOT NULL, + `channelId` varchar(36) NOT NULL, + PRIMARY KEY (`alertId`, `channelId`), + KEY `IDX_alert_channels_channel` (`channelId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE `alert` + ADD COLUMN `messageTemplate` text DEFAULT NULL, + ADD COLUMN `emailSubjectTemplate` varchar(255) DEFAULT NULL; + +-- Backfill: create user-scoped channels from existing user.* notification fields. +INSERT INTO `notification_channel` (`id`, `name`, `type`, `config`, `isVerified`, `userId`) +SELECT + UUID(), + 'Telegram', + 'telegram', + JSON_OBJECT('chatId', `telegramChatId`), + 1, + `id` +FROM `user` +WHERE `telegramChatId` IS NOT NULL AND `isTelegramChatIdConfirmed` = 1; + +INSERT INTO `notification_channel` (`id`, `name`, `type`, `config`, `isVerified`, `userId`) +SELECT + UUID(), + 'Slack', + 'slack', + JSON_OBJECT('url', `slackWebhookUrl`), + 1, + `id` +FROM `user` +WHERE `slackWebhookUrl` IS NOT NULL AND `slackWebhookUrl` <> ''; + +INSERT INTO `notification_channel` (`id`, `name`, `type`, `config`, `isVerified`, `userId`) +SELECT + UUID(), + 'Discord', + 'discord', + JSON_OBJECT('url', `discordWebhookUrl`), + 1, + `id` +FROM `user` +WHERE `discordWebhookUrl` IS NOT NULL AND `discordWebhookUrl` <> ''; + +-- Link every existing alert to all of its project owner's freshly-created channels +-- so current broadcast behaviour is preserved. +INSERT IGNORE INTO `alert_channels` (`alertId`, `channelId`) +SELECT a.`id`, nc.`id` +FROM `alert` a +JOIN `project` p + ON p.`id` = a.`projectId` COLLATE utf8mb4_0900_ai_ci +JOIN `notification_channel` nc + ON nc.`userId` COLLATE utf8mb4_0900_ai_ci = p.`adminId` +WHERE nc.`userId` IS NOT NULL; diff --git a/backend/migrations/mysql/_pending_drop_legacy_user_notification_columns.sql b/backend/migrations/mysql/_pending_drop_legacy_user_notification_columns.sql new file mode 100644 index 000000000..0e2bda905 --- /dev/null +++ b/backend/migrations/mysql/_pending_drop_legacy_user_notification_columns.sql @@ -0,0 +1,25 @@ +-- Follow-up migration (DO NOT RUN until at least one release after +-- 2026_04_23_notification_channels.sql is in production). +-- +-- Phase 1 of the alerts rework backfills user.telegramChatId / +-- user.slackWebhookUrl / user.discordWebhookUrl (and the matching confirmed +-- flag) into rows in `notification_channel`. The legacy columns are kept +-- intact for one release so we can roll back without losing data. +-- +-- Once we are confident the new path is healthy in production, run this +-- migration to remove the legacy plumbing: +-- +-- ALTER TABLE `user` +-- DROP COLUMN `telegramChatId`, +-- DROP COLUMN `isTelegramChatIdConfirmed`, +-- DROP COLUMN `slackWebhookUrl`, +-- DROP COLUMN `discordWebhookUrl`; +-- +-- Also remove the corresponding columns from the User entity, the legacy +-- `remove-tg-integration` action in web/app/routes/user-settings.tsx, and +-- the helpers in backend/apps/cloud/src/integrations that still write to +-- those columns. The Telegram /start scene should be updated to create a +-- `notification_channel` row directly instead of `user.telegramChatId`. +-- +-- Rename this file to a real `YYYY_MM_DD_drop_legacy_user_notification_columns.sql` +-- when you're ready to ship it. diff --git a/backend/package-lock.json b/backend/package-lock.json index 482cfd40b..a92b48d6e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -30,6 +30,7 @@ "@sentry/nestjs": "^10.47.0", "@sentry/profiling-node": "^10.47.0", "@swetrix/node": "^3.2.0", + "@types/web-push": "^3.6.4", "@ua-parser-js/pro-business": "^2.0.9", "ai": "^6.0.153", "bcrypt": "^6.0.0", @@ -73,6 +74,7 @@ "telegraf": "^4.16.3", "tldts": "^7.0.28", "typeorm": "^0.3.28", + "web-push": "^3.6.7", "zod": "^4.3.6" }, "devDependencies": { @@ -4936,6 +4938,15 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ua-parser-js/pro-business": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/@ua-parser-js/pro-business/-/pro-business-2.0.9.tgz", @@ -5487,6 +5498,18 @@ "license": "MIT", "optional": true }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assert-never": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", @@ -5623,6 +5646,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -8467,6 +8496,15 @@ "entities": "^4.4.0" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -9760,6 +9798,12 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -14429,6 +14473,25 @@ "defaults": "^1.0.3" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/web-resource-inliner": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-8.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 87decc3fb..8aca40fa2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -51,6 +51,7 @@ "@sentry/nestjs": "^10.47.0", "@sentry/profiling-node": "^10.47.0", "@swetrix/node": "^3.2.0", + "@types/web-push": "^3.6.4", "@ua-parser-js/pro-business": "^2.0.9", "ai": "^6.0.153", "bcrypt": "^6.0.0", @@ -94,6 +95,7 @@ "telegraf": "^4.16.3", "tldts": "^7.0.28", "typeorm": "^0.3.28", + "web-push": "^3.6.7", "zod": "^4.3.6" }, "devDependencies": { diff --git a/web/app/components/NotificationChannels/EnableWebPushButton.tsx b/web/app/components/NotificationChannels/EnableWebPushButton.tsx new file mode 100644 index 000000000..2cf915c9c --- /dev/null +++ b/web/app/components/NotificationChannels/EnableWebPushButton.tsx @@ -0,0 +1,166 @@ +import { BellRingingIcon } from '@phosphor-icons/react' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' +import { toast } from 'sonner' + +import type { NotificationChannelActionData } from '~/routes/notification-channel' +import Button from '~/ui/Button' + +const SW_PATH = '/sw.js' + +const urlBase64ToUint8Array = (base64String: string): Uint8Array => { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4) + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') + const rawData = window.atob(base64) + const out = new Uint8Array(rawData.length) + for (let i = 0; i < rawData.length; i++) { + out[i] = rawData.charCodeAt(i) + } + return out +} + +const arrayBufferToBase64 = (buffer: ArrayBuffer | null): string => { + if (!buffer) return '' + const bytes = new Uint8Array(buffer) + let binary = '' + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]) + } + return window.btoa(binary) +} + +interface EnableWebPushButtonProps { + onSubscribed?: () => void +} + +const EnableWebPushButton = ({ onSubscribed }: EnableWebPushButtonProps) => { + const { t } = useTranslation('common') + const keyFetcher = useFetcher() + const subscribeFetcher = useFetcher() + const lastSubscribeData = useRef(null) + const [busy, setBusy] = useState(false) + const [supported, setSupported] = useState(true) + + useEffect(() => { + if (typeof window === 'undefined') return + const ok = + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window + setSupported(ok) + }, []) + + useEffect(() => { + if (subscribeFetcher.state !== 'idle' || !subscribeFetcher.data) return + if (lastSubscribeData.current === subscribeFetcher.data) return + lastSubscribeData.current = subscribeFetcher.data + setBusy(false) + if (subscribeFetcher.data.error) { + toast.error(subscribeFetcher.data.error) + return + } + if (subscribeFetcher.data.success) { + toast.success(t('notificationChannels.webpush.enabled')) + onSubscribed?.() + } + }, [subscribeFetcher.state, subscribeFetcher.data, t, onSubscribed]) + + const onClick = async () => { + if (!supported) { + toast.error(t('notificationChannels.webpush.unsupported')) + return + } + setBusy(true) + try { + const permission = await Notification.requestPermission() + if (permission !== 'granted') { + toast.error(t('notificationChannels.webpush.permissionDenied')) + setBusy(false) + return + } + + const registration = + (await navigator.serviceWorker.getRegistration(SW_PATH)) || + (await navigator.serviceWorker.register(SW_PATH)) + + const keyData = new FormData() + keyData.set('intent', 'webpush-public-key') + const keyResp = await fetch('/notification-channel', { + method: 'POST', + body: keyData, + }) + const keyJson = (await keyResp.json()) as { + publicKey?: string | null + error?: string + } + if (!keyJson.publicKey) { + toast.error( + keyJson.error || t('notificationChannels.webpush.notConfigured'), + ) + setBusy(false) + return + } + + const appServerKey = urlBase64ToUint8Array(keyJson.publicKey) + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: appServerKey.buffer.slice( + appServerKey.byteOffset, + appServerKey.byteOffset + appServerKey.byteLength, + ) as ArrayBuffer, + }) + + const sub = subscription.toJSON() as { + endpoint?: string + keys?: { p256dh?: string; auth?: string } + } + const endpoint = sub.endpoint || subscription.endpoint + const p256dh = + sub.keys?.p256dh || + arrayBufferToBase64(subscription.getKey?.('p256dh') as ArrayBuffer) + const auth = + sub.keys?.auth || + arrayBufferToBase64(subscription.getKey?.('auth') as ArrayBuffer) + + if (!endpoint || !p256dh || !auth) { + toast.error(t('notificationChannels.webpush.subscribeFailed')) + setBusy(false) + return + } + + const formData = new FormData() + formData.set('intent', 'webpush-subscribe') + formData.set('name', `Browser (${navigator.platform || 'web'})`) + formData.set('endpoint', endpoint) + formData.set('keys', JSON.stringify({ p256dh, auth })) + formData.set('userAgent', navigator.userAgent || '') + subscribeFetcher.submit(formData, { + method: 'POST', + action: '/notification-channel', + }) + } catch (e) { + console.error(e) + toast.error(t('notificationChannels.webpush.subscribeFailed')) + setBusy(false) + } + } + + if (!supported) return null + + return ( + + ) +} + +export default EnableWebPushButton diff --git a/web/app/components/NotificationChannels/NotificationChannels.tsx b/web/app/components/NotificationChannels/NotificationChannels.tsx new file mode 100644 index 000000000..856716bf1 --- /dev/null +++ b/web/app/components/NotificationChannels/NotificationChannels.tsx @@ -0,0 +1,611 @@ +import { + CheckCircleIcon, + ClockIcon, + PaperPlaneTiltIcon, + PencilSimpleIcon, + PlusIcon, + TrashIcon, + XCircleIcon, +} from '@phosphor-icons/react' +import _map from 'lodash/map' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' +import { toast } from 'sonner' + +import type { + NotificationChannel, + NotificationChannelType, +} from '~/lib/models/NotificationChannel' +import type { NotificationChannelActionData } from '~/routes/notification-channel' +import Button from '~/ui/Button' +import Discord from '~/ui/icons/Discord' +import Slack from '~/ui/icons/Slack' +import Telegram from '~/ui/icons/Telegram' +import Input from '~/ui/Input' +import Loader from '~/ui/Loader' +import Modal from '~/ui/Modal' +import Select from '~/ui/Select' +import { Text } from '~/ui/Text' + +import EnableWebPushButton from './EnableWebPushButton' + +type ChannelScope = 'user' | 'organisation' | 'project' + +interface NotificationChannelsProps { + scope: ChannelScope + organisationId?: string + projectId?: string + /** Title above the section. */ + title?: string + /** Helper text under the title. */ + description?: string + /** Limit which channel types can be created in this scope. */ + allowedTypes?: NotificationChannelType[] +} + +const ALL_TYPES: NotificationChannelType[] = [ + 'email', + 'telegram', + 'discord', + 'slack', + 'webhook', + 'webpush', +] + +const TG_BOT_URL = 'https://t.me/swetrixbot' + +const ChannelTypeIcon = ({ type }: { type: NotificationChannelType }) => { + if (type === 'telegram') return + if (type === 'discord') return + if (type === 'slack') return + return null +} + +interface ChannelFormState { + name: string + type: NotificationChannelType + email: string + chatId: string + url: string + secret: string +} + +const blankForm = ( + type: NotificationChannelType = 'email', +): ChannelFormState => ({ + name: '', + type, + email: '', + chatId: '', + url: '', + secret: '', +}) + +const buildConfig = (form: ChannelFormState) => { + switch (form.type) { + case 'email': + return { address: form.email.trim() } + case 'telegram': + return { chatId: form.chatId.trim() } + case 'slack': + case 'discord': + return { url: form.url.trim() } + case 'webhook': + return { + url: form.url.trim(), + secret: form.secret.trim() || null, + } + default: + return {} + } +} + +const ChannelStatusPill = ({ channel }: { channel: NotificationChannel }) => { + const { t } = useTranslation('common') + const config = (channel.config || {}) as { unsubscribed?: boolean } + const isUnsubscribed = + channel.type === 'email' && config.unsubscribed === true + + if (isUnsubscribed) { + return ( + + + {t('notificationChannels.statusUnsubscribed')} + + ) + } + if (channel.isVerified) { + return ( + + + {t('notificationChannels.statusVerified')} + + ) + } + return ( + + + {t('notificationChannels.statusPending')} + + ) +} + +const summariseConfig = (channel: NotificationChannel): string => { + const cfg = channel.config as Record + switch (channel.type) { + case 'email': + return cfg?.address || '' + case 'telegram': + return cfg?.chatId ? `chat ${cfg.chatId}` : '' + case 'slack': + case 'discord': + case 'webhook': + return cfg?.url || '' + case 'webpush': + return cfg?.userAgent || cfg?.endpoint || '' + default: + return '' + } +} + +const NotificationChannels = ({ + scope, + organisationId, + projectId, + title, + description, + allowedTypes = ALL_TYPES, +}: NotificationChannelsProps) => { + const { t } = useTranslation('common') + + const listFetcher = useFetcher() + const mutateFetcher = useFetcher() + const lastMutateData = useRef(null) + + const [channels, setChannels] = useState([]) + const [isLoaded, setIsLoaded] = useState(false) + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form, setForm] = useState(() => + blankForm(allowedTypes[0]), + ) + const [pendingDelete, setPendingDelete] = + useState(null) + + const triggerList = useCallback(() => { + const formData = new FormData() + formData.set('intent', 'list-channels') + formData.set('scope', scope) + if (projectId) formData.set('projectId', projectId) + if (organisationId) formData.set('organisationId', organisationId) + listFetcher.submit(formData, { + method: 'POST', + action: '/notification-channel', + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scope, organisationId, projectId]) + + useEffect(() => { + triggerList() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scope, organisationId, projectId]) + + useEffect(() => { + if (listFetcher.state !== 'idle') return + if (!listFetcher.data) return + if (listFetcher.data.error) { + toast.error(listFetcher.data.error) + setIsLoaded(true) + return + } + let next = listFetcher.data.channels || [] + // The /notification-channel/list endpoint returns user/own/shared channels. + // For "user" scope, we filter to only user-owned to avoid mixing scopes. + if (scope === 'user') { + next = next.filter((c) => c.scope === 'user') + } + setChannels(next) + setIsLoaded(true) + }, [listFetcher.state, listFetcher.data, scope]) + + useEffect(() => { + if (mutateFetcher.state !== 'idle' || !mutateFetcher.data) return + if (lastMutateData.current === mutateFetcher.data) return + lastMutateData.current = mutateFetcher.data + const { intent, success, error } = mutateFetcher.data + + if (error) { + toast.error(error) + return + } + if (!success) return + + if (intent === 'create-channel') { + toast.success(t('notificationChannels.created')) + setCreating(false) + setForm(blankForm(allowedTypes[0])) + triggerList() + } else if (intent === 'update-channel') { + toast.success(t('notificationChannels.updated')) + setEditing(null) + triggerList() + } else if (intent === 'delete-channel') { + toast.success(t('notificationChannels.deleted')) + setPendingDelete(null) + triggerList() + } else if (intent === 'test-channel') { + toast.success(t('notificationChannels.testSent')) + } else if (intent === 'verify-channel') { + toast.success(t('notificationChannels.verifyKickedOff')) + triggerList() + } + }, [mutateFetcher.state, mutateFetcher.data, t, allowedTypes, triggerList]) + + const submitMutate = (formData: FormData) => + mutateFetcher.submit(formData, { + method: 'POST', + action: '/notification-channel', + }) + + const onCreate = () => { + if (!form.name.trim()) { + toast.error(t('notificationChannels.nameRequired')) + return + } + const formData = new FormData() + formData.set('intent', 'create-channel') + formData.set('name', form.name.trim()) + formData.set('type', form.type) + formData.set('config', JSON.stringify(buildConfig(form))) + if (scope === 'user') formData.set('userScoped', 'true') + if (scope === 'organisation' && organisationId) + formData.set('organisationId', organisationId) + if (scope === 'project' && projectId) formData.set('projectId', projectId) + submitMutate(formData) + } + + const onUpdate = () => { + if (!editing) return + const formData = new FormData() + formData.set('intent', 'update-channel') + formData.set('channelId', editing.id) + formData.set('name', form.name.trim()) + formData.set('config', JSON.stringify(buildConfig(form))) + submitMutate(formData) + } + + const onDelete = () => { + if (!pendingDelete) return + const formData = new FormData() + formData.set('intent', 'delete-channel') + formData.set('channelId', pendingDelete.id) + submitMutate(formData) + } + + const onTest = (channel: NotificationChannel) => { + const formData = new FormData() + formData.set('intent', 'test-channel') + formData.set('channelId', channel.id) + submitMutate(formData) + } + + const onVerify = (channel: NotificationChannel) => { + const formData = new FormData() + formData.set('intent', 'verify-channel') + formData.set('channelId', channel.id) + submitMutate(formData) + } + + const onEdit = (channel: NotificationChannel) => { + const cfg = (channel.config || {}) as Record + setEditing(channel) + setForm({ + name: channel.name, + type: channel.type, + email: cfg.address || '', + chatId: cfg.chatId || '', + url: cfg.url || '', + secret: '', + }) + } + + const typeOptions = useMemo( + () => + allowedTypes.map((type) => ({ + type, + label: t(`notificationChannels.types.${type}` as any) as string, + })), + [allowedTypes, t], + ) + + const showWebpushButton = allowedTypes.includes('webpush') && scope === 'user' + + const headingTitle = title || t('notificationChannels.heading') + const headingDescription = + description || t('notificationChannels.description') + + const isMutating = mutateFetcher.state !== 'idle' + const isFormOpen = creating || !!editing + + return ( +
+
+
+ + {headingTitle} + + + {headingDescription} + +
+
+ {showWebpushButton ? ( + + ) : null} + +
+
+ + {!isLoaded ? ( +
+ +
+ ) : null} + + {isLoaded && channels.length === 0 && !isFormOpen ? ( + + {t('notificationChannels.empty')} + + ) : null} + + {isLoaded && channels.length > 0 ? ( +
+ + + + + + + + + + + {_map(channels, (channel) => ( + + + + + + + + ))} + +
+ {t('common.name')} + + {t('common.type')} + + {t('common.details')} + + {t('common.status')} + +
+
+ + + {channel.name} + +
+
+ {t(`notificationChannels.types.${channel.type}` as any) || + channel.type} + + + {summariseConfig(channel)} + + + + +
+ + {!channel.isVerified && + (channel.type === 'email' || + channel.type === 'webhook') ? ( + + ) : null} + + +
+
+
+ ) : null} + + {isFormOpen ? ( +
+ + {editing + ? t('notificationChannels.editTitle') + : t('notificationChannels.createTitle')} + +
+ + setForm((prev) => ({ ...prev, name: e.target.value })) + } + placeholder='My alerts channel' + /> +
+ + setForm((prev) => ({ ...prev, email: e.target.value })) + } + hint={t('notificationChannels.email.hint')} + placeholder='alerts@example.com' + /> + ) : null} + {form.type === 'telegram' ? ( + <> + + setForm((prev) => ({ ...prev, chatId: e.target.value })) + } + hint={t('notificationChannels.telegram.hint', { + bot: TG_BOT_URL, + })} + /> + + ) : null} + {form.type === 'slack' || + form.type === 'discord' || + form.type === 'webhook' ? ( + <> + + setForm((prev) => ({ ...prev, url: e.target.value })) + } + placeholder='https://...' + /> + {form.type === 'webhook' ? ( + + setForm((prev) => ({ ...prev, secret: e.target.value })) + } + hint={t('notificationChannels.webhook.secretHint')} + /> + ) : null} + + ) : null} + {form.type === 'webpush' ? ( + + {t('notificationChannels.webpush.useButton')} + + ) : null} +
+ +
+ + {form.type !== 'webpush' ? ( + + ) : null} +
+
+ ) : null} + + setPendingDelete(null)} + onSubmit={onDelete} + title={t('notificationChannels.deleteTitle')} + message={t('notificationChannels.deleteHint', { + name: pendingDelete?.name || '', + })} + submitText={t('common.delete')} + closeText={t('common.close')} + type='error' + submitType='danger' + /> +
+ ) +} + +export default memo(NotificationChannels) diff --git a/web/app/lib/models/Alerts.ts b/web/app/lib/models/Alerts.ts index 8538ca59d..80557f936 100644 --- a/web/app/lib/models/Alerts.ts +++ b/web/app/lib/models/Alerts.ts @@ -1,4 +1,5 @@ import { QUERY_METRIC, QUERY_CONDITION, QUERY_TIME } from '../constants' +import type { NotificationChannel } from './NotificationChannel' export interface Alerts { id: string @@ -14,4 +15,8 @@ export interface Alerts { alertOnNewErrorsOnly?: boolean alertOnEveryCustomEvent?: boolean created: string + channels?: NotificationChannel[] + channelIds?: string[] + messageTemplate?: string | null + emailSubjectTemplate?: string | null } diff --git a/web/app/lib/models/NotificationChannel.ts b/web/app/lib/models/NotificationChannel.ts new file mode 100644 index 000000000..b1a2e40a9 --- /dev/null +++ b/web/app/lib/models/NotificationChannel.ts @@ -0,0 +1,23 @@ +export type NotificationChannelType = + | 'email' + | 'telegram' + | 'discord' + | 'slack' + | 'webhook' + | 'webpush' + +export type NotificationChannelScope = 'user' | 'organisation' | 'project' + +export interface NotificationChannel { + id: string + name: string + type: NotificationChannelType + config: Record + isVerified: boolean + scope: NotificationChannelScope + userId?: string | null + organisationId?: string | null + projectId?: string | null + created: string + updated: string +} diff --git a/web/app/pages/Organisations/Settings/index.tsx b/web/app/pages/Organisations/Settings/index.tsx index d376bc59e..4345a6ca8 100644 --- a/web/app/pages/Organisations/Settings/index.tsx +++ b/web/app/pages/Organisations/Settings/index.tsx @@ -3,6 +3,7 @@ import _isEmpty from 'lodash/isEmpty' import _keys from 'lodash/keys' import _size from 'lodash/size' import { + BellRingingIcon, CaretLeftIcon, FolderSimpleIcon, SlidersHorizontalIcon, @@ -37,12 +38,14 @@ import { TabHeader } from '~/ui/TabHeader' import Select from '~/ui/Select' import routes from '~/utils/routes' +import NotificationChannels from '~/components/NotificationChannels/NotificationChannels' + import People from './People' import { Projects } from './Projects' const MAX_NAME_LENGTH = 50 -type SettingsTab = 'general' | 'people' | 'projects' | 'danger' +type SettingsTab = 'general' | 'people' | 'projects' | 'channels' | 'danger' const OrganisationSettings = () => { const { t } = useTranslation('common') @@ -148,6 +151,14 @@ const OrganisationSettings = () => { iconColor: 'text-emerald-500', visible: true, }, + { + id: 'channels', + label: t('organisations.settings.tabs.channels'), + description: t('organisations.settings.tabs.channelsDesc'), + icon: BellRingingIcon, + iconColor: 'text-pink-500', + visible: true, + }, { id: 'danger', label: t('organisations.settings.tabs.danger'), @@ -401,6 +412,27 @@ const OrganisationSettings = () => { ) : null} + {activeTab === 'channels' && activeTabConfig ? ( + <> + + + + ) : null} {activeTab === 'danger' && activeTabConfig ? ( <> +> + +interface AlertTemplateEditorProps { + projectId: string + metric: string + body: string + subject: string + onBodyChange: (body: string) => void + onSubjectChange: (subject: string) => void + showSubject: boolean + sampleValues?: AlertTemplateSampleValues +} + +const PLACEHOLDER_DEFAULTS: Record = { + alert_name: 'High traffic alert', + project_name: 'My website', + project_id: 'abcdef', + dashboard_url: 'https://swetrix.com/projects/abcdef', + value: 1234, + threshold: 1000, + condition: 'greater than', + time_window: 'last 1 hour', + error_count: 3, + error_message: 'Cannot read properties of undefined', + error_name: 'TypeError', + errors_url: 'https://swetrix.com/projects/abcdef?tab=errors', + is_new_only: 'yes', + event_name: 'signup', + event_count: 17, + every_event_mode: 'no', + views: 4321, + unique_views: 2100, + online_count: 42, +} + +const buildSampleContext = ( + variables: string[], + overrides: AlertTemplateSampleValues = {}, +): Record => { + const sample: Record = { ...PLACEHOLDER_DEFAULTS } + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined || value === null || value === '') continue + sample[key] = value as string | number + } + for (const name of variables) { + if (sample[name] === undefined) sample[name] = `<${name}>` + } + return sample +} + +const interpolate = ( + template: string, + sample: Record, +): string => { + if (!template) return '' + return template.replace(/\{\{\s*([\w_]+)\s*\}\}/g, (_, name) => { + return sample[name] !== undefined ? String(sample[name]) : `{{${name}}}` + }) +} + +marked.setOptions({ breaks: true, gfm: true }) + +// Channels like Telegram/Slack treat single `*text*` as bold (not italic). +// Convert it to CommonMark-compatible `**text**` so the live preview matches +// what end-users will actually see in the destination channel. +const normalizeChannelMarkdown = (input: string): string => + input.replace( + /(^|[^*])\*(?!\s)([^*\n]+?)(? `${prefix}**${content}**`, + ) + +const renderMarkdown = (markdown: string): string => { + const html = marked.parse(normalizeChannelMarkdown(markdown)) as string + return sanitizeHtml(html, { + allowedTags: [ + 'a', + 'b', + 'strong', + 'i', + 'em', + 'u', + 's', + 'del', + 'code', + 'pre', + 'br', + 'p', + 'ul', + 'ol', + 'li', + 'blockquote', + 'hr', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'span', + ], + allowedAttributes: { + a: ['href', 'title', 'target', 'rel'], + code: ['class'], + pre: ['class'], + span: ['class'], + }, + allowedSchemes: ['http', 'https', 'mailto', 'tel'], + transformTags: { + a: (tagName, attribs) => ({ + tagName, + attribs: { + ...attribs, + target: '_blank', + rel: 'noopener noreferrer nofollow', + }, + }), + }, + }) +} + +const AlertTemplateEditor = ({ + projectId, + metric, + body, + subject, + onBodyChange, + onSubjectChange, + showSubject, + sampleValues, +}: AlertTemplateEditorProps) => { + const { t } = useTranslation('common') + const fetcher = useFetcher() + const lastIntent = useRef(null) + const [variables, setVariables] = useState([]) + const [defaultTemplate, setDefaultTemplate] = useState('') + const [showPreview, setShowPreview] = useState(false) + const bodyRef = useRef(null) + + useEffect(() => { + if (!metric) return + const fd = new FormData() + fd.set('intent', 'get-alert-template-variables') + fd.set('metric', metric) + fetcher.submit(fd, { + method: 'POST', + action: `/projects/${projectId}`, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [metric, projectId]) + + useEffect(() => { + if ( + fetcher.state !== 'idle' || + !fetcher.data || + fetcher.data.intent !== 'get-alert-template-variables' + ) + return + if (lastIntent.current === fetcher.data.intent + metric) return + lastIntent.current = fetcher.data.intent + metric + if (fetcher.data.success && fetcher.data.data) { + const payload = fetcher.data.data as TemplateVariablesResponse + setVariables(payload.variables || []) + setDefaultTemplate(payload.defaultTemplate || '') + } + }, [fetcher.state, fetcher.data, metric]) + + const insertAtCursor = (token: string) => { + const ta = bodyRef.current + if (!ta) { + onBodyChange((body || '') + token) + return + } + const start = ta.selectionStart ?? body.length + const end = ta.selectionEnd ?? body.length + const next = body.slice(0, start) + token + body.slice(end) + onBodyChange(next) + requestAnimationFrame(() => { + ta.focus() + const pos = start + token.length + ta.setSelectionRange(pos, pos) + }) + } + + const sample = useMemo( + () => buildSampleContext(variables, sampleValues), + [variables, sampleValues], + ) + + const previewBodyHtml = useMemo( + () => renderMarkdown(interpolate(body || defaultTemplate || '', sample)), + [body, defaultTemplate, sample], + ) + const previewSubject = useMemo( + () => interpolate(subject, sample), + [subject, sample], + ) + + return ( +
+
+
+ + {t('alert.template.heading')} + + + {t('alert.template.description')} + +
+ +
+ + {showSubject ? ( +
+ onSubjectChange(e.target.value)} + /> +
+ ) : null} + + {!showPreview ? ( + <> +
+