diff --git a/README.md b/README.md index fc5f01e3a..30d1c40b9 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. @@ -86,7 +86,7 @@ Cloud vs Community Edition | **Core analytics (traffic, events, sessions, funnels, performance, errors)** | ✅ Included | ✅ Included | | **Advanced features (Revenue, Experiments, AI)** | ✅ Included | ⚠️ Not included | | **Teams & sharing** | ✅ Organisations to manage multiple projects and users with permissions setup; invite people to your projects directly, or share a public or password protected link with people. | ⚠️ Only direct project invites, password protected links and public projects are supported. | -| **Alerts & notifications** | ✅ Yes (Slack/Telegram/Discord) | ⚠️ Not included | +| **Alerts & notifications** | ✅ Yes (Email, Slack, Telegram, Discord, webhook, web push) | ⚠️ Not included | | **Email reports** | ✅ Yes (weekly/monthly/quarterly) | ⚠️ Not included | | **Geo analytics** | ✅ Swetrix Cloud uses premium GeoIP database to provide consistent and accurate country and city level geolocation data. | ⚠️ Less accurate, DB-IP City Lite Database; you need to pay for the full database if you want better accuracy. | | **Release schedule** | ✅ Continuously developed with updates deployed as soon as they are ready | ℹ️ Periodic open‑source releases, latest features are not available immediately. | 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..f3864c5d7 100644 --- a/backend/apps/cloud/src/alert/alert.controller.ts +++ b/backend/apps/cloud/src/alert/alert.controller.ts @@ -41,6 +41,12 @@ 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 { + DEFAULT_EMAIL_SUBJECT_TEMPLATE, + 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 +58,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 +87,7 @@ export class AlertController { const alert = await this.alertService.findOne({ where: { id: alertId }, - relations: ['project'], + relations: ['project', 'channels'], }) if (_isEmpty(alert)) { @@ -109,7 +130,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 +213,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 + ) { + alertDTO.emailSubjectTemplate = DEFAULT_EMAIL_SUBJECT_TEMPLATE + } + try { const alert: Partial = { name: alertDTO.name, @@ -203,6 +239,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 +329,8 @@ export class AlertController { 'alertOnNewErrorsOnly', 'alertOnEveryCustomEvent', 'active', + 'messageTemplate', + 'emailSubjectTemplate', ]), } @@ -323,10 +364,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..ccdff5724 100644 --- a/backend/apps/cloud/src/alert/dto/alert.dto.ts +++ b/backend/apps/cloud/src/alert/dto/alert.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, PartialType } from '@nestjs/swagger' +import { Transform } from 'class-transformer' import { PID_REGEX } from '../../common/constants' import { IsEnum, @@ -9,6 +10,9 @@ import { IsString, IsNumber, Matches, + IsArray, + IsUUID, + MaxLength, } from 'class-validator' export enum QueryMetric { @@ -35,6 +39,12 @@ export enum QueryTime { LAST_48_HOURS = 'last_48_hours', } +const trimBlankToNull = ({ value }: { value: unknown }) => { + if (typeof value !== 'string') return value + const trimmed = value.trim() + return trimmed === '' ? null : trimmed +} + class AlertBaseDTO { @ApiProperty() @IsNotEmpty() @@ -80,6 +90,26 @@ class AlertBaseDTO { @IsBoolean() @IsOptional() alertOnEveryCustomEvent?: boolean + + @ApiProperty({ type: [String] }) + @IsArray() + @IsUUID('all', { each: true }) + @IsOptional() + channelIds?: string[] + + @ApiProperty({ nullable: true }) + @Transform(trimBlankToNull) + @IsString() + @IsOptional() + @MaxLength(5000) + messageTemplate?: string | null + + @ApiProperty({ nullable: true }) + @Transform(trimBlankToNull) + @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/integrations/telegram/telegram.module.ts b/backend/apps/cloud/src/integrations/telegram/telegram.module.ts index 5d0566e65..c2116a3af 100644 --- a/backend/apps/cloud/src/integrations/telegram/telegram.module.ts +++ b/backend/apps/cloud/src/integrations/telegram/telegram.module.ts @@ -1,4 +1,10 @@ -import { Module, OnModuleInit, Logger, Optional } from '@nestjs/common' +import { + forwardRef, + Module, + OnModuleInit, + Logger, + Optional, +} from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { TelegrafModule, InjectBot } from 'nestjs-telegraf' import { session, Telegraf } from 'telegraf' @@ -15,6 +21,7 @@ import { ProjectModule } from '../../project/project.module' import { AnalyticsModule } from '../../analytics/analytics.module' import { isPrimaryNode, isPrimaryClusterNode } from '../../common/utils' import { Context } from './interface/context.interface' +import { NotificationChannelModule } from '../../notification-channel/notification-channel.module' const shouldBotBeLaunched = isPrimaryNode() && isPrimaryClusterNode() const hasTelegramToken = !!process.env.TELEGRAM_BOT_TOKEN @@ -35,6 +42,7 @@ const hasTelegramToken = !!process.env.TELEGRAM_BOT_TOKEN UserModule, ProjectModule, AnalyticsModule, + forwardRef(() => NotificationChannelModule), ].filter((m) => !!m), providers: [ ...(shouldBotBeLaunched && hasTelegramToken diff --git a/backend/apps/cloud/src/integrations/telegram/telegram.service.ts b/backend/apps/cloud/src/integrations/telegram/telegram.service.ts index c30e0f41e..ab39f2468 100644 --- a/backend/apps/cloud/src/integrations/telegram/telegram.service.ts +++ b/backend/apps/cloud/src/integrations/telegram/telegram.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Optional } from '@nestjs/common' +import { forwardRef, Inject, Injectable, Optional } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { InjectBot } from 'nestjs-telegraf' import { Markup, Telegraf } from 'telegraf' @@ -8,6 +8,7 @@ import { Context } from './interface/context.interface' import { Message } from './entities/message.entity' import { UserService } from '../../user/user.service' import { ExtraReplyMessage } from 'telegraf/typings/telegram-types' +import { NotificationChannelService } from '../../notification-channel/notification-channel.service' @Injectable() export class TelegramService { @@ -17,6 +18,9 @@ export class TelegramService { private readonly configService: ConfigService, @InjectRepository(Message) private readonly messageRepository: Repository, + @Optional() + @Inject(forwardRef(() => NotificationChannelService)) + private readonly notificationChannelService?: NotificationChannelService, ) {} async getStartMessage(telegramId: number) { @@ -78,6 +82,10 @@ export class TelegramService { } await this.userService.updateUserTelegramId(userId, chatId, true) + await this.getNotificationChannelService().upsertTelegramChannel( + userId, + String(chatId), + ) } async cancelLinkAccount(userId: string, chatId: number) { @@ -113,9 +121,20 @@ export class TelegramService { if (!user) { return } + await this.getNotificationChannelService().deleteTelegramChannelsByChatId( + chatId.toString(), + user.id, + ) await this.userService.updateUserTelegramId(user.id, null) } + private getNotificationChannelService() { + if (!this.notificationChannelService) { + throw new Error('NotificationChannelService is not available') + } + return this.notificationChannelService + } + async addMessage(chatId: string, text: string, extra?: ExtraReplyMessage) { await this.messageRepository.save({ chatId, text, extra }) } diff --git a/backend/apps/cloud/src/mailer/mailer.service.ts b/backend/apps/cloud/src/mailer/mailer.service.ts index 05d31f53a..e432bcb34 100644 --- a/backend/apps/cloud/src/mailer/mailer.service.ts +++ b/backend/apps/cloud/src/mailer/mailer.service.ts @@ -177,6 +177,25 @@ export class MailerService { private readonly nodeMailerService: NodeMailerService, ) {} + async sendRawEmail( + email: string, + subject: string, + html: string, + ): Promise { + 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) + } + } + 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..a55381653 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/channel-dispatcher.service.ts @@ -0,0 +1,70 @@ +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, + options: { ignoreVerification?: boolean } = {}, + ): Promise { + const tasks = channels + .filter((c) => options.ignoreVerification || 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..aafc2a2e4 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/email-channel.service.ts @@ -0,0 +1,141 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { createHmac, timingSafeEqual } from 'crypto' +import { MailerService } from '../../mailer/mailer.service' +import { + NotificationChannel, + NotificationChannelType, +} from '../entity/notification-channel.entity' +import { ChannelDispatcher, RenderedAlertMessage } from './types' +import { buildNotificationChannelUnsubscribeUrl } from '../notification-channel.paths' +import { JWT_ACCESS_TOKEN_SECRET } from '../../common/constants' + +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 isAllowedLinkHref = (href: string) => { + try { + const { protocol } = new URL(href) + return ( + protocol === 'http:' || protocol === 'https:' || protocol === 'mailto:' + ) + } catch { + return false + } +} + +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, (_match, text, href) => + isAllowedLinkHref(href) + ? `${text}` + : text, + ) + 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, + ) {} + + private getUnsubscribeSecret(): string { + const secret = JWT_ACCESS_TOKEN_SECRET + + if (!secret) { + throw new Error( + 'JWT_ACCESS_TOKEN_SECRET is required for email unsubscribe tokens', + ) + } + + return secret + } + + 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.getUnsubscribeSecret() + 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 secret = this.getUnsubscribeSecret() + const expected = createHmac('sha256', secret) + .update(channelId) + .digest('hex') + if (sig.length !== expected.length) return null + const sigBuffer = Buffer.from(sig, 'utf8') + const expectedBuffer = Buffer.from(expected, 'utf8') + return timingSafeEqual(sigBuffer, expectedBuffer) ? 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 = buildNotificationChannelUnsubscribeUrl( + clientUrl, + 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..b37ae2c1e --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/telegram-channel.service.ts @@ -0,0 +1,39 @@ +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.telegramBody || 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..035c00212 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/types.ts @@ -0,0 +1,24 @@ +import { + NotificationChannel, + NotificationChannelType, +} from '../entity/notification-channel.entity' + +export interface RenderedAlertMessage { + /** Plain/markdown body, rendered from the alert's messageTemplate. */ + body: string + /** Telegram Markdown body with interpolated values escaped for Telegram. */ + telegramBody?: 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..c7d82565d --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/webhook-channel.service.ts @@ -0,0 +1,114 @@ +import { Injectable, Logger } from '@nestjs/common' +import { createHmac } from 'crypto' +// using node-fetch instead of undici because the native fetch does not support +// 'agent' option (to prevent SSRF) +import fetch from 'node-fetch' +import { useAgent } from 'request-filtering-agent' +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 webhookUrl = cfg.url + const res = await fetch(webhookUrl, { + method: 'POST', + headers, + body: bodyStr, + agent: useAgent(webhookUrl), + signal: AbortSignal.timeout(10_000), + redirect: 'error', + }) + if (!res.ok) { + this.logger.warn( + `Outbound webhook ${webhookUrl} 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, + agent: useAgent(url), + 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..916f5256b --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dispatchers/webpush-channel.service.ts @@ -0,0 +1,100 @@ +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 context = message.context as { + dashboard_url?: string + errors_url?: string + } + const url = context?.errors_url || context?.dashboard_url + + const payload = JSON.stringify({ + title: message.subject || channel.name || 'Swetrix alert', + body: message.body.replace(/[*_`]/g, '').slice(0, 240), + url, + tag: `${channel.id}:${url || message.subject || 'alert'}`, + }) + + try { + await webpush.sendNotification(subscription, payload) + } catch (reason: any) { + // 404/410 mean the subscription is dead; keep alert links intact. + if (reason?.statusCode === 404 || reason?.statusCode === 410) { + this.logger.warn( + `Disabling expired webpush channel ${channel.id} (status ${reason.statusCode})`, + ) + await this.channelRepository.update(channel.id, { + isVerified: false, + disabledReason: `Expired: ${reason.statusCode} from WebPush server`, + }) + 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..8a53cd158 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/dto/notification-channel.dto.ts @@ -0,0 +1,106 @@ +import { ApiProperty } from '@nestjs/swagger' +import { + IsBoolean, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + Length, + IsObject, + IsUUID, + Matches, + MaxLength, + ValidateNested, +} from 'class-validator' +import { Type } from 'class-transformer' +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() + @IsBoolean() + userScoped?: boolean +} + +export class UpdateChannelDTO { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @Length(1, 100) + name?: string + + @ApiProperty({ required: false }) + @IsOptional() + @IsObject() + config?: Record +} + +class WebpushKeysDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(512) + p256dh: string + + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(512) + auth: string +} + +export class WebpushSubscribeDTO { + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(2048) + endpoint: string + + @ApiProperty() + @IsNotEmpty() + @ValidateNested() + @Type(() => WebpushKeysDto) + keys: WebpushKeysDto + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(512) + 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..2bf5c31a3 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/entity/notification-channel.entity.ts @@ -0,0 +1,98 @@ +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 + + @Column('varchar', { length: 255, nullable: true, default: null }) + disabledReason: 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..49131c55c --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/notification-channel.controller.ts @@ -0,0 +1,357 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Res, + HttpException, + 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' +import { buildNotificationChannelVerifyUrl } from './notification-channel.paths' + +const escapeHtml = (value: string) => + value.replace(/[&<>"']/g, (char) => + char === '&' + ? '&' + : char === '<' + ? '<' + : char === '>' + ? '>' + : char === '"' + ? '"' + : ''', + ) + +@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()) 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()) 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()) 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 }, + }, + { ignoreVerification: true }, + ) + return { ok: true } + } + + @ApiBearerAuth() + @Post('/:id/verify') + @Auth() + async kickoffVerification( + @Param('id', new ParseUUIDPipe()) 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) { + let channelId: string | null + try { + channelId = this.emailDispatcher.verifyUnsubscribeToken(token) + } catch (reason) { + if (reason instanceof Error) { + throw new HttpException( + { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: reason.message, + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ) + } + throw reason + } + 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 = buildNotificationChannelVerifyUrl( + clientUrl, + channel.verificationToken, + ) + const safeAddress = escapeHtml(cfg.address) + const safeUrl = escapeHtml(url) + const html = ` +

Confirm your Swetrix notification channel

+

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

+

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, + disabledReason: channel.disabledReason, + 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.length > 64 + ? cfg.endpoint.slice(0, 64) + '…' + : cfg.endpoint, + 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..56c04bedf --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/notification-channel.module.ts @@ -0,0 +1,68 @@ +import { forwardRef, Module, OnModuleInit } 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' +import { JWT_ACCESS_TOKEN_SECRET } from '../common/constants' + +const telegramImports = + process.env.ENABLE_TELEGRAM_INTEGRATION === 'true' + ? [forwardRef(() => 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 implements OnModuleInit { + onModuleInit() { + if (!JWT_ACCESS_TOKEN_SECRET) { + throw new Error( + 'JWT_ACCESS_TOKEN_SECRET must be set for email unsubscribe tokens', + ) + } + } +} diff --git a/backend/apps/cloud/src/notification-channel/notification-channel.paths.ts b/backend/apps/cloud/src/notification-channel/notification-channel.paths.ts new file mode 100644 index 000000000..904cdfad2 --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/notification-channel.paths.ts @@ -0,0 +1,22 @@ +const NOTIFICATION_CHANNEL_API_PATH = '/api/notification-channel' + +const joinClientUrl = (clientUrl: string, path: string) => + `${clientUrl.replace(/\/$/, '')}${path}` + +export const buildNotificationChannelVerifyUrl = ( + clientUrl: string, + token: string, +) => + joinClientUrl( + clientUrl, + `${NOTIFICATION_CHANNEL_API_PATH}/verify/${encodeURIComponent(token)}`, + ) + +export const buildNotificationChannelUnsubscribeUrl = ( + clientUrl: string, + token: string, +) => + joinClientUrl( + clientUrl, + `${NOTIFICATION_CHANNEL_API_PATH}/unsubscribe/${encodeURIComponent(token)}`, + ) 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..97ee54acf --- /dev/null +++ b/backend/apps/cloud/src/notification-channel/notification-channel.service.ts @@ -0,0 +1,574 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { EntityManager, In, Repository } from 'typeorm' +import { randomBytes } from 'crypto' +import { isDeepStrictEqual } from 'util' + +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 { Organisation } from '../organisation/entity/organisation.entity' +import { Project } from '../project/entity/project.entity' +import { User } from '../user/entities/user.entity' +import { + CreateChannelDTO, + UpdateChannelDTO, +} from './dto/notification-channel.dto' +import { WebhookChannelService } from './dispatchers/webhook-channel.service' + +const MAX_CHANNELS_PER_SCOPE = 50 +const MAX_EMAIL_LENGTH = 254 +const MAX_EMAIL_LOCAL_PART_LENGTH = 64 +const WEB_PUSH_ALLOWED_EXACT_HOSTS = new Set([ + 'fcm.googleapis.com', + 'updates.push.services.mozilla.com', + 'web.push.apple.com', +]) +const WEB_PUSH_ALLOWED_SUFFIX_HOSTS = ['notify.windows.com'] + +const hasWhitespace = (value: string) => { + for (const char of value) { + if (char.trim() === '') return true + } + return false +} + +const isValidEmailAddress = (address: string) => { + if (!address || address.length > MAX_EMAIL_LENGTH || hasWhitespace(address)) { + return false + } + + const atIndex = address.indexOf('@') + if (atIndex <= 0 || atIndex !== address.lastIndexOf('@')) return false + if (atIndex > MAX_EMAIL_LOCAL_PART_LENGTH) return false + + const domain = address.slice(atIndex + 1) + const dotIndex = domain.indexOf('.') + return dotIndex > 0 && dotIndex < domain.length - 1 +} + +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 + } +} + +const isAllowedWebPushEndpoint = (endpoint: string) => { + if (!isValidHttpsUrl(endpoint)) return false + + const host = new URL(endpoint).hostname.toLowerCase() + return ( + WEB_PUSH_ALLOWED_EXACT_HOSTS.has(host) || + WEB_PUSH_ALLOWED_SUFFIX_HOSTS.some((suffix) => host.endsWith(`.${suffix}`)) + ) +} + +@Injectable() +export class NotificationChannelService { + constructor( + @InjectRepository(NotificationChannel) + private readonly channelRepository: Repository, + private readonly projectService: ProjectService, + private readonly organisationService: OrganisationService, + ) {} + + // --- Scope helpers -------------------------------------------------------- + + /** All channel ids manageable by the caller across user/orgs/projects. */ + async getVisibleChannels(userId: string): Promise { + const [userOwned, ownedProjectChannels, orgChannels] = await Promise.all([ + this.channelRepository.find({ where: { user: { id: userId } } }), + this.getProjectChannelsForUserOwnedProjects(userId), + this.getOrgChannelsForManagedOrganisations(userId), + ]) + + const map = new Map() + for (const c of [...userOwned, ...ownedProjectChannels, ...orgChannels]) { + map.set(c.id, c) + } + return Array.from(map.values()) + } + + private async getProjectChannelsForUserOwnedProjects(userId: string) { + const pids = await this.projectService.getProjectIdsViewableByUser(userId) + if (pids.length === 0) return [] + return this.channelRepository.find({ + where: { project: { id: In(pids) } }, + }) + } + + private async getOrgChannelsForManagedOrganisations(userId: string) { + const memberships = await this.organisationService.findMemberships({ + where: { + user: { id: userId }, + confirmed: true, + role: In([OrganisationRole.owner, OrganisationRole.admin]), + }, + 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) } }, + }) + } + + /** Channels manageable and usable on a given project. */ + async getChannelsForProject( + projectId: string, + userId: string, + ): Promise { + const project = await this.projectService.getFullProject(projectId) + if (!project) throw new NotFoundException('Project not found') + this.projectService.allowedToManage(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 organisation managers. */ + async getChannelsForOrganisation( + organisationId: string, + userId: string, + ): Promise { + const memberships = await this.organisationService.findMemberships({ + where: { + user: { id: userId }, + organisation: { id: organisationId }, + confirmed: true, + role: In([OrganisationRole.owner, OrganisationRole.admin]), + }, + }) + if (memberships.length === 0) { + throw new ForbiddenException( + 'You are not allowed to manage 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 + } + + // 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 || + dto.type === NotificationChannelType.TELEGRAM || + dto.type === NotificationChannelType.WEBPUSH + ) { + 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.saveWithinScopeLimit( + scope, + { + userId: scope === 'user' ? userId : undefined, + organisationId: + scope === 'organisation' ? dto.organisationId : undefined, + projectId: scope === 'project' ? dto.projectId : undefined, + }, + partial, + ) + } + + private getScopeWhere( + scope: 'user' | 'organisation' | 'project', + ids: { userId?: string; organisationId?: string; projectId?: string }, + ) { + return scope === 'user' + ? { user: { id: ids.userId! } } + : scope === 'organisation' + ? { organisation: { id: ids.organisationId! } } + : { project: { id: ids.projectId! } } + } + + private async saveWithinScopeLimit( + scope: 'user' | 'organisation' | 'project', + ids: { userId?: string; organisationId?: string; projectId?: string }, + partial: Partial, + ) { + return this.channelRepository.manager.transaction(async (manager) => { + await this.lockScopeParent(manager, scope, ids) + + const channelRepository = manager.getRepository(NotificationChannel) + const count = await channelRepository.count({ + where: this.getScopeWhere(scope, ids), + }) + if (count >= MAX_CHANNELS_PER_SCOPE) { + throw new ForbiddenException( + `Maximum number of notification channels (${MAX_CHANNELS_PER_SCOPE}) reached for this scope.`, + ) + } + + return channelRepository.save(channelRepository.create(partial)) + }) + } + + private async lockScopeParent( + manager: EntityManager, + scope: 'user' | 'organisation' | 'project', + ids: { userId?: string; organisationId?: string; projectId?: string }, + ) { + const lock = { mode: 'pessimistic_write' as const } + const parent = + scope === 'user' + ? await manager.findOne(User, { + where: { id: ids.userId! }, + lock, + }) + : scope === 'organisation' + ? await manager.findOne(Organisation, { + where: { id: ids.organisationId! }, + lock, + }) + : await manager.findOne(Project, { + where: { id: ids.projectId! }, + lock, + }) + + if (!parent) throw new NotFoundException('Scope not found') + } + + normaliseConfig( + type: NotificationChannelType, + raw: Record, + ): NotificationChannelConfig { + switch (type) { + case NotificationChannelType.EMAIL: { + const address = String(raw.address || '') + .trim() + .toLowerCase() + if (!isValidEmailAddress(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', 'discordapp.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 || + !isAllowedWebPushEndpoint(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) { + const config = + channel.type === NotificationChannelType.WEBHOOK && + !Object.prototype.hasOwnProperty.call(dto.config, 'secret') + ? { + ...dto.config, + secret: + (channel.config as { secret?: string | null }).secret ?? null, + } + : dto.config + + const normalizedConfig = this.normaliseConfig(channel.type, config) + + if (!isDeepStrictEqual(normalizedConfig, channel.config)) { + channel.config = normalizedConfig + // Re-set isVerified based on type rules + if ( + channel.type === NotificationChannelType.SLACK || + channel.type === NotificationChannelType.DISCORD || + channel.type === NotificationChannelType.TELEGRAM || + channel.type === NotificationChannelType.WEBPUSH + ) { + channel.isVerified = true + channel.disabledReason = null + } else if (channel.type === NotificationChannelType.EMAIL) { + channel.isVerified = false + channel.verificationToken = randomBytes(24).toString('hex') + channel.disabledReason = null + } else if (channel.type === NotificationChannelType.WEBHOOK) { + channel.isVerified = false + channel.verificationToken = randomBytes(24).toString('hex') + channel.disabledReason = null + } + } + } + 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, + disabledReason: null, + verificationToken: null, + }) + } + + async findByVerificationToken(token: string) { + if (!token || token.trim() === '') return null + + 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 + existing.disabledReason = null + 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, userId: string) { + await this.channelRepository + .createQueryBuilder() + .delete() + .where('type = :type', { type: NotificationChannelType.TELEGRAM }) + .andWhere("JSON_UNQUOTE(JSON_EXTRACT(config, '$.chatId')) = :chatId", { + chatId, + }) + .andWhere('userId = :userId', { userId }) + .execute() + } +} 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/project/project.service.ts b/backend/apps/cloud/src/project/project.service.ts index b4c5cfd23..5e5c29c63 100644 --- a/backend/apps/cloud/src/project/project.service.ts +++ b/backend/apps/cloud/src/project/project.service.ts @@ -427,6 +427,33 @@ export class ProjectService { return _map(projects, 'id') } + async getProjectIdsViewableByUser(userId: string): Promise { + const projects = await this.projectsRepository + .createQueryBuilder('project') + .select('project.id', 'id') + .distinct(true) + .leftJoin('project.admin', 'admin') + .leftJoin('project.share', 'share') + .leftJoin('share.user', 'sharedUser') + .leftJoin('project.organisation', 'organisation') + .leftJoin('organisation.members', 'organisationMembers') + .leftJoin('organisationMembers.user', 'organisationUser') + .where( + new Brackets((qb) => { + qb.where('project.public = true') + .orWhere('admin.id = :userId') + .orWhere('sharedUser.id = :userId AND share.confirmed = true') + .orWhere( + 'organisationUser.id = :userId AND organisationMembers.confirmed = true', + ) + }), + ) + .setParameter('userId', userId) + .getRawMany<{ id: string }>() + + return _map(projects, 'id') + } + async create(project: DeepPartial) { return this.projectsRepository.save(project) } 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..6296d5dba 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,8 @@ 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' +import { NotificationChannel } from '../notification-channel/entity/notification-channel.entity' @Module({ imports: [ @@ -24,11 +26,12 @@ import { RevenueModule } from '../revenue/revenue.module' AlertModule, forwardRef(() => AnalyticsModule), AppLoggerModule, - TypeOrmModule.forFeature([Message]), + TypeOrmModule.forFeature([Message, NotificationChannel]), DiscordModule, 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..83b53f2e3 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) @@ -106,6 +118,7 @@ const CHUNK_SIZE = 5000 const REPORTS_USERS_CONCURRENCY = 3 const REPORTS_PROJECTS_CONCURRENCY = 5 const NO_EVENTS_REMINDER_DELAY_DAYS = 2 +const TELEGRAM_MARKDOWN_URL_KEYS = new Set(['dashboard_url', 'errors_url']) const mapLimit = async ( items: T[], @@ -325,8 +338,44 @@ 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 telegramContext = Object.fromEntries( + Object.entries(ctxRecord).map(([key, value]) => { + if (typeof value !== 'string' || TELEGRAM_MARKDOWN_URL_KEYS.has(key)) { + return [key, value] + } + return [key, this.telegramService.escapeTelegramMarkdown(value)] + }), + ) + const telegramBody = this.templateRenderer.render(template, telegramContext) + const subject = hasEmailChannel + ? this.templateRenderer.render( + alert.emailSubjectTemplate?.trim() || DEFAULT_EMAIL_SUBJECT_TEMPLATE, + ctxRecord, + ) + : alert.name + return { body, telegramBody, subject, context: ctxRecord } + } + /** * Build goal match condition for querying conversions */ @@ -1514,57 +1563,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 +1596,36 @@ 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') || 'https://swetrix.com' + 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 +1647,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 && @@ -1833,150 +1838,92 @@ export class TaskManagerService { } } - // @ts-expect-error - await this.alertService.update(alert.id, { - 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) + : QUERY_TIME_LABEL[alert.queryTime as QueryTime] || + getQueryTimeString(alert.queryTime as QueryTime) - let text = `` + const clientUrl = + this.configService.get('CLIENT_URL') || 'https://swetrix.com' + const dashboardUrl = `${clientUrl}/projects/${project.id}` - 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', + if (alert.queryMetric === QueryMetric.ERRORS && !errorDetails) { + this.logger.warn( + `[CRON WORKER](checkMetricAlerts) Error details not found for alert ${alert.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}` - } + let context: AlertContext - text = - `🐞 Error alert *${alertName}* triggered!\n\n` + - `Project: [${projectName}](${escapedProjectLink})\n` + - `Error: \`${errorName}\`\n` + - `Message: \`${errorMessage}\`\n\n` + - `${locationInfo}\n\n` + - `[View error](${escapedErrorLink})` + if (alert.queryMetric === QueryMetric.ERRORS) { + const errors_url = errorDetails?.eid + ? `${dashboardUrl}?tab=errors&eid=${errorDetails.eid}` + : `${dashboardUrl}?tab=errors` + 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 alertName = this.telegramService.escapeTelegramMarkdown( - alert.name, - ) - const projectName = this.telegramService.escapeTelegramMarkdown( - project.name, - ) - - 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}*!` - } - } - - 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, - }) + 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?.discordWebhookUrl) { - await this.discordService.sendWebhook( - project.admin.discordWebhookUrl, - text, - ) - } + const hasEmail = alert.channels.some( + (c) => c.type === NotificationChannelType.EMAIL, + ) + // @ts-expect-error TypeORM typing for partial update + await this.alertService.update(alert.id, { + lastTriggered: new Date(), + }) - if (project.admin?.slackWebhookUrl) { - await this.slackService.sendWebhook( - project.admin.slackWebhookUrl, - text, - ) - } + 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/2026_04_26_notification_channel_disabled_reason.sql b/backend/migrations/mysql/2026_04_26_notification_channel_disabled_reason.sql new file mode 100644 index 000000000..e9dba9556 --- /dev/null +++ b/backend/migrations/mysql/2026_04_26_notification_channel_disabled_reason.sql @@ -0,0 +1,2 @@ +ALTER TABLE `notification_channel` + ADD COLUMN `disabledReason` varchar(255) DEFAULT NULL AFTER `verificationToken`; 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..2bb8fa42b 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", @@ -59,6 +60,7 @@ "mysql2": "^3.20.0", "nestjs-i18n": "^10.6.0", "nestjs-telegraf": "^2.9.1", + "node-fetch": "^2.7.0", "nodemailer": "^8.0.5", "otplib": "^13.4.0", "passport": "^0.7.0", @@ -66,6 +68,7 @@ "passport-jwt": "^4.0.1", "php-serialize": "^5.1.3", "reflect-metadata": "^0.2.2", + "request-filtering-agent": "^3.2.0", "rimraf": "^6.1.3", "rxjs": "^7.8.2", "sns-payload-validator": "^2.1.0", @@ -73,6 +76,7 @@ "telegraf": "^4.16.3", "tldts": "^7.0.28", "typeorm": "^0.3.28", + "web-push": "^3.6.7", "zod": "^4.3.6" }, "devDependencies": { @@ -85,6 +89,7 @@ "@types/lodash": "^4.17.24", "@types/multer": "^2.1.0", "@types/node": "^25.5.2", + "@types/node-fetch": "^2.6.13", "@types/nodemailer": "^8.0.0", "@types/passport-jwt": "^4.0.1", "knip": "^6.3.1", @@ -4803,6 +4808,17 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/nodemailer": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", @@ -4936,6 +4952,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 +5512,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", @@ -5494,6 +5531,13 @@ "license": "MIT", "optional": true }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5623,6 +5667,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", @@ -6310,6 +6360,19 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -6852,6 +6915,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -7338,6 +7411,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7817,6 +7906,46 @@ "node": "*" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/formatly": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", @@ -8467,6 +8596,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 +9898,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", @@ -12466,6 +12610,27 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/request-filtering-agent": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/request-filtering-agent/-/request-filtering-agent-3.2.0.tgz", + "integrity": "sha512-tKPrKdsmTFuGG1/pBEpzTB66mDZ2lZLW8kjW4N6jj4QjnxUTKrIfv5p2zuJRfztOos86jRPD41lRaGjh+1QqDw==", + "license": "MIT", + "dependencies": { + "ipaddr.js": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/request-filtering-agent/node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -14429,6 +14594,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..79ff1edfd 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", @@ -80,6 +81,7 @@ "mysql2": "^3.20.0", "nestjs-i18n": "^10.6.0", "nestjs-telegraf": "^2.9.1", + "node-fetch": "^2.7.0", "nodemailer": "^8.0.5", "otplib": "^13.4.0", "passport": "^0.7.0", @@ -87,6 +89,7 @@ "passport-jwt": "^4.0.1", "php-serialize": "^5.1.3", "reflect-metadata": "^0.2.2", + "request-filtering-agent": "^3.2.0", "rimraf": "^6.1.3", "rxjs": "^7.8.2", "sns-payload-validator": "^2.1.0", @@ -94,6 +97,7 @@ "telegraf": "^4.16.3", "tldts": "^7.0.28", "typeorm": "^0.3.28", + "web-push": "^3.6.7", "zod": "^4.3.6" }, "devDependencies": { @@ -106,6 +110,7 @@ "@types/lodash": "^4.17.24", "@types/multer": "^2.1.0", "@types/node": "^25.5.2", + "@types/node-fetch": "^2.6.13", "@types/nodemailer": "^8.0.0", "@types/passport-jwt": "^4.0.1", "knip": "^6.3.1", diff --git a/docs/components/channel-icons.tsx b/docs/components/channel-icons.tsx new file mode 100644 index 000000000..0584d8d5a --- /dev/null +++ b/docs/components/channel-icons.tsx @@ -0,0 +1,79 @@ +import { BellRingingIcon, EnvelopeSimpleIcon, GlobeIcon } from "@phosphor-icons/react/dist/ssr"; + +interface IconProps { + className?: string; +} + +const DEFAULT_CLASSNAME = "inline-block size-4 align-[-0.125em] shrink-0"; + +export const EmailChannelIcon = ({ className }: IconProps) => ( + +); + +export const TelegramChannelIcon = ({ className }: IconProps) => ( + + + + + + + + + + + + +); + +export const DiscordChannelIcon = ({ className }: IconProps) => ( + + + +); + +export const SlackChannelIcon = ({ className }: IconProps) => ( + + + + + + +); + +export const WebhookChannelIcon = ({ className }: IconProps) => ( + +); + +export const WebpushChannelIcon = ({ className }: IconProps) => ( + +); diff --git a/docs/content/docs/accountsettings/teams-and-integrations.mdx b/docs/content/docs/accountsettings/teams-and-integrations.mdx index f1794b7df..75ab0c47b 100644 --- a/docs/content/docs/accountsettings/teams-and-integrations.mdx +++ b/docs/content/docs/accountsettings/teams-and-integrations.mdx @@ -29,19 +29,19 @@ To manage your team, go to the Organisations page and click the **Settings** (ge You can assign a project to an organisation in the **Project Settings -> Access** tab. Once assigned, all members of the organisation will have access to the project according to their organisation role. -## Integrations +## Notification channels -Swetrix can send you reports and alerts directly to your favourite communication tools. +Swetrix can send alerts directly to your favourite communication tools through **notification channels**. A channel describes _where_ a message is sent — your inbox, a Slack workspace, a Telegram chat, your own webhook, or a browser push subscription. -1. Go to your **[Account Settings](https://swetrix.com/user-settings)**. -2. Open the **Communications** tab. -3. Scroll down to the **Integrations** section. +You can connect channels at three different scopes: -Supported integrations: +- **Personal** — Available across all of your projects. Manage them in **[Account Settings → Communications → Notification channels](https://swetrix.com/user-settings?tab=communications)**. +- **Organisation** — Shared with everyone in an organisation (configured by owners and admins from the organisation's settings page). +- **Project** — Scoped to a single project, configured under **Project Settings → Notification channels**. -- **Telegram**: Receive alerts via the Swetrix Bot. -- **Slack**: Send notifications to a Slack channel via Webhook. -- **Discord**: Send notifications to a Discord channel via Webhook. +Supported channel types: **Email**, **Telegram** (via [@swetrixbot](https://t.me/swetrixbot)), **Discord** (incoming webhook), **Slack** (incoming webhook), **custom Webhook** (with optional HMAC signature), and **browser Web push** notifications. + +For the full setup walkthrough, payload format, message-template variables, and per-alert channel selection, see the [Alerts & Notifications](/analytics-dashboard/alerts) guide. ## Interface Settings diff --git a/docs/content/docs/analytics-dashboard/alerts.mdx b/docs/content/docs/analytics-dashboard/alerts.mdx index 35bcb058b..bbb158100 100644 --- a/docs/content/docs/analytics-dashboard/alerts.mdx +++ b/docs/content/docs/analytics-dashboard/alerts.mdx @@ -1,67 +1,438 @@ --- id: alerts -title: Alerts +title: Alerts & Notifications sidebar_label: Alerts --- -The Alerts feature is exclusively available on **Swetrix Cloud**. It is not currently available for self-hosted instances at the moment. +Alerts and notification channels are exclusively available on **Swetrix Cloud**. They are not currently available for self-hosted instances. -Alerts allow you to monitor your website's performance in real-time and receive notifications when specific metrics cross a defined threshold. This helps you stay on top of traffic spikes, server errors, or important user interactions without constantly checking the dashboard. +Alerts let you monitor your website in real-time and get notified the moment a metric crosses a threshold you care about — traffic spikes, drops in active users, JavaScript errors after a deployment, or important custom events like signups and purchases. You stay on top of what matters without keeping a tab open on the dashboard all day. -Alerts Dashboard +Each alert is wired to one or more **notification channels** that decide _where_ the message is sent (email, Telegram, Discord, Slack, browser push, or your own webhook). -## Prerequisites +Alerts overview -Before setting up alerts, you must connect a notification channel to your account. Swetrix supports the following channels: +## Notification channels -- **Telegram** -- **Discord** -- **Slack** +Before you can create an alert, you need at least one notification channel. Channels are managed independently of alerts so you can re-use the same destination across many alerts (or send a single alert to several places at once). -To configure these, go to your **Account Settings** and follow the instructions for each integration. If no channel is connected, you will see a warning on the Alerts page. +### Supported channel types -Alert communication channels +| Channel | What it does | Verification | +| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------- | +| **Email** | Delivers a rich HTML email with a one-click unsubscribe link. | Confirmation email | +| **Telegram** | Sends messages via the [@swetrixbot](https://t.me/swetrixbot) bot to your private chat. | Linked through the bot | +| **Discord** | Posts to a Discord channel via an incoming webhook URL. | Validated on save | +| **Slack** | Posts to a Slack channel via an incoming webhook URL. | Validated on save | +| **Webhook** | Sends a JSON `POST` to a URL of your choice with an optional HMAC-SHA256 signature. | Webhook ping | +| **Web push** | Native browser push notifications for the device you subscribe from (desktop or mobile). | Verified on subscribe | -## Creating an Alert +### Channel scopes -1. Navigate to your project's **Settings** and click on the **Alerts** tab. -2. Click the **Add Alert** button. -3. Configure the alert settings: - - **Name**: A descriptive name for your alert (e.g., "High Traffic Warning"). - - **Metric**: The data point you want to monitor (see below). - - **Condition**: The logic to trigger the alert (e.g., Greater than, Less than). - - **Value**: The threshold number. - - **Timeframe**: The period over which the data is analyzed (e.g., "Last 15 minutes"). +Channels live at one of three scopes, which controls who can use them and where they appear: + +- **User** — Personal channels you own. Available across all of your projects (and any project you have admin rights on). Manage them in **Account Settings → Communications → Notification channels**. +- **Organisation** — Shared with everyone in an organisation. Useful for a team-wide #alerts Slack channel or an on-call email distribution list. Managed in **Organisation Settings → Notification channels** by organisation owners and admins. +- **Project** — Specific to a single project. Handy when one site needs to notify a different audience than the rest. Managed in **Project Settings → Notification channels**. + +When you build an alert, you can pick from any channel that is in scope for that project — your personal channels, the project's organisation channels, and the project's own channels are all available in the picker. + +### Connecting a channel + + + + + +### Open the right Notification channels page + +Decide which scope you want the channel to live at (User / Organisation / Project — see above) and open that scope's **Notification channels** section. + + + + + +### Click "Add channel" and pick a type + +Connect a channel + +Each type has slightly different details. Pick the tab below that matches the destination you want to wire up. + + + + Email + Telegram + Discord + Slack + Webhook + Web push + + + + +Enter the destination address. Swetrix sends a confirmation email with a one-click verification link — the channel stays in the **Pending verification** state until that link is clicked. + +Every alert email includes a one-click **Unsubscribe** link in the footer. If a recipient clicks it, the channel is marked **Unsubscribed** and Swetrix stops sending alerts to that address until you re-verify it. + + + + + +1. Open [@swetrixbot](https://t.me/swetrixbot) on Telegram. +2. Send `/start`. The bot replies with your numeric chat ID. +3. Paste that chat ID into the **Telegram chat ID** field. + +The channel is verified automatically once linked through the bot. + + + + + +Paste the **incoming webhook URL** from your Discord server's channel settings (Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL). + +Only `https://discord.com/...` URLs are accepted. The channel is validated and marked verified on save. + + + + + +Paste the **incoming webhook URL** from your Slack workspace ([Slack apps → Incoming Webhooks → Add New Webhook to Workspace](https://api.slack.com/messaging/webhooks)). + +Only `https://hooks.slack.com/...` URLs are accepted. The channel is validated and marked verified on save. + + + + + +Enter any `https://` URL that should receive the JSON payload. Optionally add a **signing secret** — when set, every request includes an `X-Swetrix-Signature: sha256=` header so you can verify the body wasn't tampered with. + +Webhook channels stay **Pending verification** until you click **Verify** in the channel list — Swetrix sends a one-off ping that your endpoint must respond to with a `2xx` status within 5 seconds. See [Webhook payload](#webhook-payload) below for the exact JSON shape. + + + + + +Web push is a **User-scope** channel only. From your account's notification channels page, click **Enable browser notifications**: + +1. Your browser asks for notification permission. +2. Swetrix registers a service worker (`/sw.js`). +3. The current device is registered as a channel and verified immediately. + +You can enable web push on multiple devices — each one becomes its own channel. + + + +Push notifications only fire while the browser is running (or, on supported platforms, in the background). They are not a substitute for email or webhook delivery if you need guaranteed receipt. + + + + + + + + + + + +### Name the channel and save + +Give it a descriptive name (e.g. "Engineering Slack" or "On-call email") and click **Create**. Channels that need verification will show a **Pending verification** badge until they're confirmed. + + + + + +### Send a test message (optional) + +Use the **Send test** button (paper-plane icon) on the channel row to dispatch a sample message right away — it works even before verification, so you can confirm the destination is wired up correctly. + + + + + +### Webhook payload + +Custom webhook channels receive a JSON `POST` with this shape: + +```json +{ + "type": "alert", + "subject": "[Swetrix] High traffic alert triggered", + "body": "🔔 Alert *High traffic alert* triggered!\n\nProject: ...", + "context": { + "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": "1 hour", + "metric": "page_views" + }, + "timestamp": "2026-04-24T10:00:00.000Z" +} +``` + +If you configured a signing secret, every request also carries this header: + +``` +X-Swetrix-Signature: sha256= +``` + + + +Always verify the signature on your side. Compute `HMAC-SHA256(secret, raw_request_body)` and compare it to the value after `sha256=` using a constant-time comparison — this guarantees the request really came from Swetrix and wasn't replayed or modified in transit. + + + +The verification ping uses the same headers but a smaller payload: `{ "type": "verification", "timestamp": "..." }`. + +## Creating an alert + + + + + +### Open the project's alerts page + +In your project, go to **Settings → Alerts** and click **Add alert**. + + + + + +### Fill in the basics + +Give the alert a short, human-readable name (e.g. "Homepage traffic dip") and decide whether it should be **Enabled**. Disabled alerts never fire — useful for temporarily silencing an alert without deleting it. + + + + + +### Configure the trigger + +Pick **what** fires the alert: + +- **Metric** — Which data point to monitor (see [Supported metrics](#supported-metrics) below). +- **Condition** — `greater than`, `greater than or equal to`, `less than`, or `less than or equal to`. +- **Threshold** — The number the metric is compared against. +- **Time window** — `15 minutes`, `30 minutes`, `1 hour`, `4 hours`, `24 hours`, or `48 hours`. Swetrix evaluates the metric over this rolling window. + +Two metric-specific options change the form: + +- **Custom events** — Provide the event name (e.g. `signup`). Tick **Alert on every custom event** to fire on _every_ occurrence and skip the threshold/time-window settings. +- **Errors** — Tick **Alert on new errors only** to be notified just for the first occurrence of each new error (great for catching regressions after a release). + + + + + +### Pick the notification channels + +The picker shows every channel in scope for the project — personal, organisational, and project-specific — with their current verification status. Tick as many as you like (e.g. send to your team's Slack and your personal Telegram at the same time). + +If no channels are connected yet, the picker links straight to **Project Settings → Notification channels** so you can add one. + + + + + +### Customise the message (optional) + +Decide _what_ Swetrix says when the alert fires. Leave the message blank to use the sensible default for the chosen metric, or write your own using **Markdown** and **Handlebars** variables — see the next section. + + + + Create an alert -### Supported Metrics +## Message templates + +Type `{` in the editor (or click any chip below it) to insert a variable. The body supports a common subset of Markdown — bold, italics, links, inline `code`, lists, and line breaks. Use the **Preview** button to see exactly how the message will render with sample values. + + + +Different destinations format Markdown a little differently — Telegram and Slack treat single `*text*` as **bold**, while CommonMark treats it as italic. Swetrix normalises this so the preview matches what your recipients actually see. + + + +### Common variables + +Available for every alert: + +| Variable | Meaning | +| ------------------- | ---------------------------------------------- | +| `{{alert_name}}` | The alert's name, as configured in the form. | +| `{{project_name}}` | The project this alert belongs to. | +| `{{project_id}}` | The project's unique identifier. | +| `{{dashboard_url}}` | Direct link to the project dashboard. | +| `{{value}}` | The metric value that triggered the alert. | +| `{{threshold}}` | The threshold the value was compared against. | +| `{{condition}}` | The comparison operator (e.g. "greater than"). | +| `{{time_window}}` | The window the metric was measured over. | + +### Metric-specific variables + +Each metric exposes a few extras of its own: + + + + + +| Variable | Meaning | +| ----------- | ----------------------------------- | +| `{{views}}` | Number of page views in the window. | + + + + + +| Variable | Meaning | +| ------------------ | ---------------------------------------- | +| `{{unique_views}}` | Number of unique sessions in the window. | + + + + + +| Variable | Meaning | +| ------------------ | ----------------------- | +| `{{online_count}}` | Currently online users. | + + + + + +| Variable | Meaning | +| ---------------------- | ---------------------------------------------- | +| `{{event_name}}` | The custom event's name. | +| `{{event_count}}` | Number of times the event fired in the window. | +| `{{every_event_mode}}` | `yes` if the alert fires on every occurrence. | + + + + + +| Variable | Meaning | +| ------------------- | ---------------------------------------------------- | +| `{{error_count}}` | Number of errors that triggered the alert. | +| `{{error_message}}` | The error message text. | +| `{{error_name}}` | The error's class or name. | +| `{{errors_url}}` | Direct link to the project's errors view. | +| `{{is_new_only}}` | `yes` if the alert only fires for first-seen errors. | + + + + + +### Default templates + +Swetrix ships with two defaults that use these variables: + +``` +🔔 Alert *{{alert_name}}* triggered! + +Project: [{{project_name}}]({{dashboard_url}}) +Value: *{{value}}* {{condition}} *{{threshold}}* in the last {{time_window}}. +``` + +For error alerts: + +``` +🐞 Error alert *{{alert_name}}* triggered! + +Project: [{{project_name}}]({{dashboard_url}}) +Error: `{{error_name}}` +Message: `{{error_message}}` + +[View error]({{errors_url}}) +``` + +### Email subject + +If at least one of the alert's selected channels is an **Email** channel, an **Email subject** field appears above the body. Variables work here too. The default is: + +``` +[Swetrix] {{alert_name}} triggered +``` + +Alerts overview + +## Supported metrics + +You can create alerts based on any of these metrics: + +- **Page views** — Total page views in the time window. +- **Unique sessions** — Number of distinct sessions in the time window. +- **Online users** — How many visitors are currently active on your site. +- **Custom events** — Watch a specific event by name, or alert on _every_ custom event. +- **Errors** — Track JavaScript errors. Choose between every error or only **new** ones (handy after a deployment). + +## Managing alerts + +All of a project's alerts are listed under **Project Settings → Alerts**. From there you can: + +- **Edit** — Click the pencil icon (or anywhere on the row) to change thresholds, channels, or the message template. +- **Delete** — Click the trash icon to remove an alert permanently. +- **Disable** — Toggle the **Enabled** switch in the alert form to silence it without deleting. +- **See the last triggered time** — Each row displays when the alert most recently fired, so you can spot noisy alerts quickly. + +## Example use cases + + + + + +Pick the **Online users** metric, condition **greater than**, threshold `100`, time window **15 minutes**. Attach an organisation-scope **Slack** channel. The team gets pinged the moment your site goes viral. + + + + + +Pick **Unique sessions**, condition **less than**, threshold `50`, time window **1 hour**. Attach a personal **Email** channel. You'll know immediately if a deployment took the site down or a tracking script broke. + + + + + +Pick the **Custom events** metric, set the event name to `signup`, and tick **Alert on every custom event**. Attach a **Webhook** channel pointing to your CRM/Zapier endpoint to record each new signup the moment it happens. + + + + + +Pick the **Errors** metric and tick **Alert on new errors only**. Attach a personal **Telegram** channel. After every release you'll only hear about errors that have never been seen before — perfect for catching regressions without noise. + + + + + +Pick **Custom events**, event name `purchase`, condition **greater than**, threshold `10`, time window **24 hours**. Attach a **Discord** channel pointing at your `#revenue` server channel. + + -You can create alerts based on the following metrics: + -- **Pageviews**: Total number of pages viewed. -- **Unique Pageviews**: Number of unique visitors. -- **Online Users**: Current number of active users on your site. -- **Custom Events**: Monitor specific interactions (e.g., "Signup Button Click"). You can track a specific event by name or alert on _every_ custom event. -- **Errors**: Track JavaScript errors occurring on your site. You can choose to be notified about all errors or only **new** errors that haven't been seen before. +## Related -## Managing Alerts + -Once created, your alerts will appear in a list on the Alerts tab in your project settings. + + Pair error alerts with the dedicated error dashboard for stack traces, affected sessions, and + resolution workflow. + -- **Edit**: Click the **Pencil** icon to modify the alert's threshold or settings. -- **Delete**: Click the **Trash** icon to remove the alert permanently. -- **Status**: Alerts show their last triggered time, helping you understand their frequency. + + Recurring traffic summaries delivered to your inbox — complementary to real-time alerts. + -## Example Use Cases + + Set up organisation-scope channels so your whole team gets the same alerts. + -- **Traffic Spike**: Notify me if **Online Users** is greater than **100**. -- **Error Monitoring**: Notify me on **New Errors** (useful for post-deployment monitoring). -- **Goal Tracking**: Notify me if **Custom Event** "Purchase" count is greater than **10** in the last **1 hour**. + diff --git a/docs/content/docs/sitesettings/get-traffic-alerts.mdx b/docs/content/docs/sitesettings/get-traffic-alerts.mdx deleted file mode 100644 index 3f75ebc00..000000000 --- a/docs/content/docs/sitesettings/get-traffic-alerts.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: Get traffic alerts -slug: /get-traffic-alerts ---- - -You can set up alerts to get notified when your website's traffic exceeds a certain threshold, or when a certain custom event occurs (like sale, or a signup). You can also set up alerts to be notified when there are client-side errors on your website. - -You can set up integrations with Telegram, Slack or Discord to get alert notifications in real-time there. - -### Connect your Slack, Telegram or Discord - -On the [account settings page](https://swetrix.com/user-settings), go to the "Communication" section. There you will see the list of currently supported integrations by Swetrix, and their status (connected or not). - -To connect an integration, click on the "Add integration" button and follow the instructions. - -Connect integrations screen - -### How to set up traffic alerts - -After you connected your integration, all the alerts will be sent there. You can set up alerts for different projects (websites) and they all will trigger independent of each other. - -To set up an alert, - -1. Go to your website's analytics page. -2. Open the project **Settings** (gear icon in the sidebar). -3. Navigate to the **Alerts** tab. -4. Click on the "Add alert" button. - -Alerts screen - -When you click on the "Add alert" button, you will see a screen where you can create a new alert. You can set it up to trigger for different metrics, for example: - -- when the number of pageviews exceeds a certain threshold (traffic spikes) -- when the number of online users is less than 10 -- on every custom event (like a sale, or a signup) -- on every new error on your website -- when the number of users on your website is less than 5 during the last 24 hours -- ... and more - -From the same "Alerts" tab in project settings you can also see the list of all the alerts you have set up and the last time they triggered. You can edit or delete them by clicking on the "Edit" or "Delete" buttons. diff --git a/docs/content/docs/sitesettings/meta.json b/docs/content/docs/sitesettings/meta.json index 9474523e4..03cd1b2e8 100644 --- a/docs/content/docs/sitesettings/meta.json +++ b/docs/content/docs/sitesettings/meta.json @@ -7,7 +7,6 @@ "transfer-ownership", "embed-your-analytics-dashboard-into-your-website", "get-analytics-email-reports", - "get-traffic-alerts", "reset-sites-data", "annotations" ] diff --git a/docs/lib/source.ts b/docs/lib/source.ts index 343ca86b5..af95104e9 100644 --- a/docs/lib/source.ts +++ b/docs/lib/source.ts @@ -27,7 +27,6 @@ const SLUG_MAP: Record = { "sitesettings/transfer-ownership": ["how-to-transfer-ownership-of-your-website"], "sitesettings/embed-your-analytics-dashboard-into-your-website": ["how-to-embed"], "sitesettings/get-analytics-email-reports": ["get-analytics-email-reports"], - "sitesettings/get-traffic-alerts": ["get-traffic-alerts"], "sitesettings/reset-sites-data": ["reset-sites-data"], "sitesettings/annotations": ["annotations"], diff --git a/docs/mdx-components.tsx b/docs/mdx-components.tsx index aef610b48..2307cb6bb 100644 --- a/docs/mdx-components.tsx +++ b/docs/mdx-components.tsx @@ -10,6 +10,14 @@ import { PlausibleIcon, NpmIcon, } from "@/components/provider-icons"; +import { + EmailChannelIcon, + TelegramChannelIcon, + DiscordChannelIcon, + SlackChannelIcon, + WebhookChannelIcon, + WebpushChannelIcon, +} from "@/components/channel-icons"; import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; import { File, Folder, Files } from "fumadocs-ui/components/files"; import { CodeIcon } from "@phosphor-icons/react/dist/ssr"; @@ -32,6 +40,12 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { FathomIcon, PlausibleIcon, NpmIcon, + EmailChannelIcon, + TelegramChannelIcon, + DiscordChannelIcon, + SlackChannelIcon, + WebhookChannelIcon, + WebpushChannelIcon, CodeIcon, Accordion, Accordions, diff --git a/docs/public/img/analytics-dashboard/alert-connect-a-channel.png b/docs/public/img/analytics-dashboard/alert-connect-a-channel.png new file mode 100644 index 000000000..cee7267f4 Binary files /dev/null and b/docs/public/img/analytics-dashboard/alert-connect-a-channel.png differ diff --git a/docs/public/img/analytics-dashboard/alert-template-editor.png b/docs/public/img/analytics-dashboard/alert-template-editor.png new file mode 100644 index 000000000..0eea77f6a Binary files /dev/null and b/docs/public/img/analytics-dashboard/alert-template-editor.png differ diff --git a/docs/public/img/analytics-dashboard/alerts-list.png b/docs/public/img/analytics-dashboard/alerts-list.png index 0d6ae1e24..5ce35702a 100644 Binary files a/docs/public/img/analytics-dashboard/alerts-list.png and b/docs/public/img/analytics-dashboard/alerts-list.png differ diff --git a/docs/public/img/analytics-dashboard/create-an-alert.png b/docs/public/img/analytics-dashboard/create-an-alert.png index c2250fdff..5256da892 100644 Binary files a/docs/public/img/analytics-dashboard/create-an-alert.png and b/docs/public/img/analytics-dashboard/create-an-alert.png differ 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..56c46afcc --- /dev/null +++ b/web/app/components/NotificationChannels/NotificationChannels.tsx @@ -0,0 +1,667 @@ +import { + ArrowSquareOutIcon, + BellSimpleRingingIcon, + PaperPlaneTiltIcon, + PencilSimpleIcon, + PlusIcon, + TrashIcon, +} 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 { Badge } from '~/ui/Badge' +import Button from '~/ui/Button' +import Input from '~/ui/Input' +import Loader from '~/ui/Loader' +import Modal from '~/ui/Modal' +import { Text } from '~/ui/Text' +import Tooltip from '~/ui/Tooltip' + +import EnableWebPushButton from './EnableWebPushButton' +import { ChannelTypeIcon, summariseConfig } from './utils' + +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' + +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 isRedactedUrl = (url: string) => url.includes('…') || url.includes('...') + +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 form.url.trim() && !isRedactedUrl(form.url.trim()) + ? { url: form.url.trim() } + : {} + case 'webhook': + return { + url: form.url.trim(), + ...(form.secret.trim() ? { secret: form.secret.trim() } : {}), + } + 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 ( + + ) + } + if (channel.isVerified) { + return ( + + ) + } + 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 name = form.name.trim() + if (!name) { + toast.error(t('notificationChannels.nameRequired')) + return + } + const formData = new FormData() + formData.set('intent', 'update-channel') + formData.set('channelId', editing.id) + formData.set('name', name) + if (editing.type !== 'webpush') { + const config = buildConfig(form) + if ( + (editing.type !== 'slack' && editing.type !== 'discord') || + Object.prototype.hasOwnProperty.call(config, 'url') + ) { + formData.set('config', JSON.stringify(config)) + } + } + 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 + const canSubmitForm = !!editing || form.type !== 'webpush' + + return ( +
+
+
+ + {headingTitle} + + + {headingDescription} + +
+
+ {showWebpushButton ? ( + + ) : null} + +
+
+ + {!isLoaded ? ( +
+ +
+ ) : null} + + {isLoaded && channels.length === 0 && !isFormOpen ? ( +
+
+ +
+ + {t('notificationChannels.emptyTitle')} + + + {t('notificationChannels.emptyDescription')} + +
+ +
+
+ ) : null} + + {isLoaded && channels.length > 0 ? ( +
    + {_map(channels, (channel) => { + const summary = summariseConfig(channel) + const canVerify = + !channel.isVerified && + (channel.type === 'email' || channel.type === 'webhook') + + return ( +
  • +
    + +
    +
    +
    + + {channel.name} + + + {t(`notificationChannels.types.${channel.type}` as any) || + channel.type} + +
    +
    + {summary ? ( + + {summary} + + ) : null} + +
    +
    +
    + {canVerify ? ( + + ) : null} + onTest(channel)} + disabled={isMutating} + className='inline-flex size-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50 dark:text-gray-400 dark:hover:bg-slate-800 dark:hover:text-gray-100' + > + + + } + /> + onEdit(channel)} + disabled={isMutating} + className='inline-flex size-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50 dark:text-gray-400 dark:hover:bg-slate-800 dark:hover:text-gray-100' + > + + + } + /> + setPendingDelete(channel)} + disabled={isMutating} + className='inline-flex size-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 disabled:opacity-50 dark:text-gray-400 dark:hover:bg-red-500/10 dark:hover:text-red-400' + > + + + } + /> +
    +
  • + ) + })} +
+ ) : null} + + { + setCreating(false) + setEditing(null) + }} + size='medium' + title={ + editing + ? t('notificationChannels.editTitle') + : t('notificationChannels.createTitle') + } + message={ +
+ {!editing ? ( +
+ + {t('notificationChannels.typeLabel')} + +
+ {typeOptions.map((opt) => { + const isActive = form.type === opt.type + return ( + + ) + })} +
+
+ ) : null} + +
+ + setForm((prev) => ({ ...prev, name: e.target.value })) + } + placeholder='My alerts channel' + /> +
+ +
+ {form.type === 'email' ? ( + + 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, + })} + /> + + + {t('notificationChannels.telegram.openBot')} + +
+ ) : null} + {form.type === 'slack' || + form.type === 'discord' || + form.type === 'webhook' ? ( + <> + + setForm((prev) => ({ ...prev, url: e.target.value })) + } + placeholder={ + form.type === 'slack' + ? 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' + : form.type === 'discord' + ? 'https://discord.com/api/webhooks/000000000000000000/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + : 'https://example.com/webhook' + } + /> + {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} +
+
+ } + closeText={t('common.cancel')} + submitText={ + canSubmitForm + ? editing + ? t('common.save') + : t('common.create') + : undefined + } + onSubmit={canSubmitForm ? (editing ? onUpdate : onCreate) : undefined} + isLoading={isMutating} + submitDisabled={isMutating} + /> + + 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/components/NotificationChannels/utils.tsx b/web/app/components/NotificationChannels/utils.tsx new file mode 100644 index 000000000..9b0ebabb0 --- /dev/null +++ b/web/app/components/NotificationChannels/utils.tsx @@ -0,0 +1,73 @@ +import { + BellRingingIcon, + EnvelopeSimpleIcon, + GlobeIcon, +} from '@phosphor-icons/react' + +import type { + NotificationChannel, + NotificationChannelType, +} from '~/lib/models/NotificationChannel' +import Discord from '~/ui/icons/Discord' +import Slack from '~/ui/icons/Slack' +import Telegram from '~/ui/icons/Telegram' + +interface ChannelTypeIconProps { + type: NotificationChannelType + className?: string +} + +export const ChannelTypeIcon = ({ + type, + className = 'size-5 shrink-0', +}: ChannelTypeIconProps) => { + switch (type) { + case 'telegram': + return + case 'discord': + return + case 'slack': + return + case 'email': + return ( + + ) + case 'webpush': + return ( + + ) + case 'webhook': + return ( + + ) + default: + return null + } +} + +export 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 '' + } +} 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..3eb85b2a4 --- /dev/null +++ b/web/app/lib/models/NotificationChannel.ts @@ -0,0 +1,24 @@ +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 + disabledReason?: string | null + 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}}}` + }) +} + +const markdownParser = new Marked({ 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 = markdownParser.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 variableInfos = useMemo( + () => + variables.map((name) => { + const key = `alert.template.variables.${name}` as const + const translated = t(key as any) + return { + name, + description: + translated && translated !== key ? translated : undefined, + } + }), + [variables, t], + ) + + 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 ? ( + <> +
+ +
+ + {variableInfos.length > 0 ? ( +
+ + {t('alert.template.insertVariable')} + +
+ {variableInfos.map((variable) => ( + + ))} +
+
+ ) : null} + + ) : ( +
+ {showSubject && previewSubject ? ( +
+ + {t('alert.template.emailSubject')} + +
+ {previewSubject} +
+
+ ) : null} +
+ + {t('alert.template.preview')} + +
+
+
+ )} +
+ ) +} + +export default AlertTemplateEditor diff --git a/web/app/pages/Project/Settings/Alerts/ProjectAlertsSettings.tsx b/web/app/pages/Project/Settings/Alerts/ProjectAlertsSettings.tsx index 8c2cf82fe..221652945 100644 --- a/web/app/pages/Project/Settings/Alerts/ProjectAlertsSettings.tsx +++ b/web/app/pages/Project/Settings/Alerts/ProjectAlertsSettings.tsx @@ -1,4 +1,9 @@ -import { WarningOctagonIcon, XCircleIcon } from '@phosphor-icons/react' +import { + CheckCircleIcon, + ClockIcon, + WarningOctagonIcon, + XCircleIcon, +} from '@phosphor-icons/react' import _findKey from 'lodash/findKey' import _isEmpty from 'lodash/isEmpty' import _keys from 'lodash/keys' @@ -15,8 +20,9 @@ import { toast } from 'sonner' import { QUERY_CONDITION, QUERY_METRIC, QUERY_TIME } from '~/lib/constants' import { Alerts } from '~/lib/models/Alerts' -import DashboardHeader from '~/pages/Project/View/components/DashboardHeader' +import type { NotificationChannel } from '~/lib/models/NotificationChannel' import { useAuth } from '~/providers/AuthProvider' +import type { NotificationChannelActionData } from '~/routes/notification-channel' import type { ProjectViewActionData } from '~/routes/projects.$id' import Button from '~/ui/Button' import Checkbox from '~/ui/Checkbox' @@ -27,29 +33,42 @@ import Select from '~/ui/Select' import { Text } from '~/ui/Text' import routes from '~/utils/routes' -const INTEGRATIONS_LINK = `${routes.user_settings}#integrations` +import { + ChannelTypeIcon, + summariseConfig, +} from '~/components/NotificationChannels/utils' + +import AlertTemplateEditor from './AlertTemplateEditor' +import { BackButton } from '../../View/components/BackButton' + +const CHANNELS_TAB = `?tab=channels` +const PROJECT_CHANNELS_LINK = (projectId: string) => + `/projects/${projectId}/settings${CHANNELS_TAB}` interface ProjectAlertsSettingsProps { alertId?: string | null projectId: string + projectName?: string isSettings?: boolean onClose?: () => void onSave?: () => void - backLink?: string + backLink: string } const ProjectAlertsSettings = ({ alertId, projectId, + projectName, isSettings, onClose, onSave, backLink, }: ProjectAlertsSettingsProps) => { - const { user, isLoading: authLoading } = useAuth() + const { isLoading: authLoading } = useAuth() const { t } = useTranslation('common') const fetcher = useFetcher() + const channelsFetcher = useFetcher() const lastHandledData = useRef(null) const [form, setForm] = useState>({ @@ -63,6 +82,9 @@ const ProjectAlertsSettings = ({ queryCustomEvent: '', alertOnNewErrorsOnly: true, alertOnEveryCustomEvent: false, + channelIds: [], + messageTemplate: '', + emailSubjectTemplate: '', }) const [errors, setErrors] = useState>({}) const [beenSubmitted, setBeenSubmitted] = useState(false) @@ -71,18 +93,29 @@ const ProjectAlertsSettings = ({ const [alert, setAlert] = useState(null) const [isLoading, setIsLoading] = useState(null) const [error, setError] = useState(null) + const [availableChannels, setAvailableChannels] = useState< + NotificationChannel[] + >([]) - const isIntegrationLinked = useMemo(() => { - if (_isEmpty(user)) { - return false - } + useEffect(() => { + const fd = new FormData() + fd.set('intent', 'list-channels') + fd.set('projectId', projectId) + channelsFetcher.submit(fd, { + method: 'POST', + action: '/notification-channel', + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]) - return Boolean( - (user.telegramChatId && user.isTelegramChatIdConfirmed) || - user.slackWebhookUrl || - user.discordWebhookUrl, - ) - }, [user]) + useEffect(() => { + if (channelsFetcher.state !== 'idle' || !channelsFetcher.data) return + if (channelsFetcher.data.error) { + toast.error(channelsFetcher.data.error) + return + } + setAvailableChannels(channelsFetcher.data.channels || []) + }, [channelsFetcher.state, channelsFetcher.data]) // Handle fetcher responses useEffect(() => { @@ -96,7 +129,13 @@ const ProjectAlertsSettings = ({ if (intent === 'get-alert' && data) { const alertData = data as Alerts setAlert(alertData) - setForm(alertData) + setForm({ + ...alertData, + channelIds: + alertData.channelIds ?? alertData.channels?.map((c) => c.id) ?? [], + messageTemplate: alertData.messageTemplate || '', + emailSubjectTemplate: alertData.emailSubjectTemplate || '', + }) setIsLoading(false) } else if (intent === 'create-alert') { toast.success(t('alertsSettings.alertCreated')) @@ -195,7 +234,12 @@ const ProjectAlertsSettings = ({ useEffect(() => { if (!_isEmpty(alert)) { - setForm(alert) + setForm({ + ...alert, + channelIds: alert.channelIds ?? alert.channels?.map((c) => c.id) ?? [], + messageTemplate: alert.messageTemplate || '', + emailSubjectTemplate: alert.emailSubjectTemplate || '', + }) } }, [alert]) @@ -384,13 +428,7 @@ const ProjectAlertsSettings = ({ return (
- + ) : null} - {!authLoading && !isIntegrationLinked ? ( -
- - - ), - }} - /> + {!authLoading && availableChannels.length === 0 ? ( +
+ +

+ + ), + }} + /> +

) : null} - ( + + ))} + {(form.channelIds || []).length === 0 ? ( + + ) : null} + + - - setForm((prev) => ({ - ...prev, - active: checked, - })) - } - classes={{ - label: 'mt-4', - }} - label={t('alert.enabled')} - hint={t('alert.enabledHint')} + -
- - ) : null} - {form.queryMetric === QUERY_METRIC.CUSTOM_EVENTS ? ( + +
+ + {t('alert.sections.basics')} + + + {t('alert.sections.basicsDescription')} + +
+ +
- setForm((prev) => ({ - ...prev, - alertOnEveryCustomEvent: checked, - })) - } classes={{ label: 'mt-4', }} - label={t('alert.alertOnEveryCustomEvent')} - hint={t('alert.alertOnEveryCustomEventHint')} - /> - ) : null} - {form.queryMetric === QUERY_METRIC.ERRORS ? ( - setForm((prev) => ({ ...prev, - alertOnNewErrorsOnly: checked, + active: checked, })) } - classes={{ - label: 'mt-4', - }} - label={t('alert.newErrorsOnly')} - hint={t('alert.newErrorsOnlyHint')} + label={t('alert.enabled')} /> - ) : null} - {form.queryMetric !== QUERY_METRIC.ERRORS && - !( - form.queryMetric === QUERY_METRIC.CUSTOM_EVENTS && - form.alertOnEveryCustomEvent - ) ? ( - <> -
+
+ +
+ + {t('alert.sections.trigger')} + + + {t('alert.sections.triggerDescription')} + + +
+ + ) : null} + {form.queryMetric === QUERY_METRIC.CUSTOM_EVENTS ? ( + + setForm((prev) => ({ + ...prev, + alertOnEveryCustomEvent: checked, + })) + } + classes={{ + label: 'mt-4', + }} + label={t('alert.alertOnEveryCustomEvent')} + hint={t('alert.alertOnEveryCustomEventHint')} + /> + ) : null} + {form.queryMetric === QUERY_METRIC.ERRORS ? ( + + setForm((prev) => ({ + ...prev, + alertOnNewErrorsOnly: checked, + })) + } + classes={{ + label: 'mt-4', + }} + label={t('alert.newErrorsOnly')} + hint={t('alert.newErrorsOnlyHint')} + /> + ) : null} + {form.queryMetric !== QUERY_METRIC.ERRORS && + !( + form.queryMetric === QUERY_METRIC.CUSTOM_EVENTS && + form.alertOnEveryCustomEvent + ) ? ( +
-
+