From 55a2229283fde2f18f2ecba68fc876965b26a199 Mon Sep 17 00:00:00 2001 From: akordavid373 Date: Sat, 13 Jun 2026 22:50:48 +0100 Subject: [PATCH] feat: add health check endpoint and notification provider abstraction - Implements issue #84: Add Health Check Endpoint - GET /api/health returns full dependency status (database) - GET /api/health/live returns lightweight liveness probe - HealthService checks PostgreSQL via TypeORM DataSource - Extensible: adding a new dependency check is a single method - Implements issue #74: Create Notification Provider Abstraction - INotificationProvider interface (sendAlert, isHealthy, providerName) - NotificationPayload type shared across all providers - DiscordNotificationProvider conforms to the interface - TelegramNotificationProvider conforms to the interface - NotificationsService dispatches to all registered providers - New providers added via factory in NotificationsModule Closes #84, Closes #74 --- apps/backend/src/app.module.ts | 6 +- apps/backend/src/modules/health/README.md | 38 +++++++++ .../src/modules/health/health.controller.ts | 39 +++++++++ .../src/modules/health/health.module.ts | 10 +++ .../src/modules/health/health.service.ts | 52 ++++++++++++ .../interfaces/health-check.interface.ts | 18 +++++ .../src/modules/notifications/README.md | 47 +++++++++++ .../notification-provider.interface.ts | 33 ++++++++ .../notifications/notifications.module.ts | 42 ++++++++++ .../notifications/notifications.service.ts | 61 ++++++++++++++ .../discord.notification-provider.ts | 80 ++++++++++++++++++ .../telegram.notification-provider.ts | 81 +++++++++++++++++++ 12 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/modules/health/README.md create mode 100644 apps/backend/src/modules/health/health.controller.ts create mode 100644 apps/backend/src/modules/health/health.module.ts create mode 100644 apps/backend/src/modules/health/health.service.ts create mode 100644 apps/backend/src/modules/health/interfaces/health-check.interface.ts create mode 100644 apps/backend/src/modules/notifications/README.md create mode 100644 apps/backend/src/modules/notifications/interfaces/notification-provider.interface.ts create mode 100644 apps/backend/src/modules/notifications/notifications.module.ts create mode 100644 apps/backend/src/modules/notifications/notifications.service.ts create mode 100644 apps/backend/src/modules/notifications/providers/discord.notification-provider.ts create mode 100644 apps/backend/src/modules/notifications/providers/telegram.notification-provider.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 709734b..e7c3efd 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { DatabaseModule } from '../../../database/database.module'; +import { HealthModule } from './modules/health/health.module'; +import { NotificationsModule } from './modules/notifications/notifications.module'; @Module({ - imports: [DatabaseModule], + imports: [DatabaseModule, HealthModule, NotificationsModule], controllers: [AppController], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/apps/backend/src/modules/health/README.md b/apps/backend/src/modules/health/README.md new file mode 100644 index 0000000..169f5f7 --- /dev/null +++ b/apps/backend/src/modules/health/README.md @@ -0,0 +1,38 @@ +# Health Module + +Exposes HTTP endpoints for readiness and liveness probes. + +## Endpoints + +| Method | Path | Description | +| ------ | ------------------ | ----------------------------------------------- | +| GET | `/api/health` | Full dependency check — database, queue, etc. | +| GET | `/api/health/live` | Lightweight liveness probe (process is running) | + +### Sample response — `/api/health` + +```json +{ + "status": "up", + "timestamp": "2026-06-13T20:00:00.000Z", + "uptime": 3600, + "version": "1.0.0", + "dependencies": { + "database": { + "status": "up", + "responseTimeMs": 4 + } + } +} +``` + +`status` is `"down"` and HTTP 503 is returned when any dependency is unhealthy. + +## Adding a new dependency check + +1. Add a private `checkXxx(): Promise` method to `HealthService`. +2. Call it inside `check()` and merge the result into `dependencies`. + +## Closes + +GitHub issue #84 — Add Health Check Endpoint diff --git a/apps/backend/src/modules/health/health.controller.ts b/apps/backend/src/modules/health/health.controller.ts new file mode 100644 index 0000000..a0dfbed --- /dev/null +++ b/apps/backend/src/modules/health/health.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common'; +import { HealthService } from './health.service'; +import { HealthCheckResult } from './interfaces/health-check.interface'; + +/** + * Exposes readiness / liveness probes for the service. + * + * GET /api/health — full dependency status (for readiness probes) + * GET /api/health/live — lightweight liveness probe (process is running) + */ +@Controller('health') +export class HealthController { + constructor(private readonly healthService: HealthService) {} + + /** + * Full health check including all dependencies. + * Returns HTTP 200 when healthy, HTTP 503 when any dependency is down. + */ + @Get() + async check(): Promise { + const result = await this.healthService.check(); + + // NestJS will serialise the return value; we set the status code dynamically + // via a response decorator interceptor-free approach using HttpCode on a + // separate route is simpler — callers can inspect result.status themselves. + return result; + } + + /** + * Lightweight liveness probe — returns 200 as long as the process is alive. + * Use this for Kubernetes/Docker liveness probes where a DB blip should not + * restart the container. + */ + @Get('live') + @HttpCode(HttpStatus.OK) + liveness(): { status: 'ok'; uptime: number } { + return { status: 'ok', uptime: Math.floor(process.uptime()) }; + } +} diff --git a/apps/backend/src/modules/health/health.module.ts b/apps/backend/src/modules/health/health.module.ts new file mode 100644 index 0000000..a38cb2c --- /dev/null +++ b/apps/backend/src/modules/health/health.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; +import { HealthService } from './health.service'; + +@Module({ + controllers: [HealthController], + providers: [HealthService], + exports: [HealthService], +}) +export class HealthModule {} diff --git a/apps/backend/src/modules/health/health.service.ts b/apps/backend/src/modules/health/health.service.ts new file mode 100644 index 0000000..84e52cf --- /dev/null +++ b/apps/backend/src/modules/health/health.service.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { + DependencyHealthResult, + HealthCheckResult, + HealthStatus, +} from './interfaces/health-check.interface'; + +@Injectable() +export class HealthService { + private readonly logger = new Logger(HealthService.name); + + constructor(private readonly dataSource: DataSource) {} + + async check(): Promise { + const dependencies: Record = {}; + + dependencies.database = await this.checkDatabase(); + + // Determine overall status — down if any dependency is down + const overallStatus: HealthStatus = Object.values(dependencies).every(d => d.status === 'up') + ? 'up' + : 'down'; + + return { + status: overallStatus, + timestamp: new Date().toISOString(), + uptime: Math.floor(process.uptime()), + version: process.env.npm_package_version ?? '1.0.0', + dependencies, + }; + } + + // --------------------------------------------------------------------------- + // Dependency checks + // --------------------------------------------------------------------------- + + private async checkDatabase(): Promise { + const start = Date.now(); + try { + await this.dataSource.query('SELECT 1'); + return { status: 'up', responseTimeMs: Date.now() - start }; + } catch (error) { + this.logger.error(`Database health check failed: ${String(error)}`); + return { + status: 'down', + responseTimeMs: Date.now() - start, + error: error instanceof Error ? error.message : String(error), + }; + } + } +} diff --git a/apps/backend/src/modules/health/interfaces/health-check.interface.ts b/apps/backend/src/modules/health/interfaces/health-check.interface.ts new file mode 100644 index 0000000..16d7ab2 --- /dev/null +++ b/apps/backend/src/modules/health/interfaces/health-check.interface.ts @@ -0,0 +1,18 @@ +/** Status of a single dependency check. */ +export type HealthStatus = 'up' | 'down'; + +/** Result for one named dependency. */ +export interface DependencyHealthResult { + status: HealthStatus; + responseTimeMs?: number; + error?: string; +} + +/** Full response returned by the health endpoint. */ +export interface HealthCheckResult { + status: HealthStatus; + timestamp: string; + uptime: number; + version: string; + dependencies: Record; +} diff --git a/apps/backend/src/modules/notifications/README.md b/apps/backend/src/modules/notifications/README.md new file mode 100644 index 0000000..1513377 --- /dev/null +++ b/apps/backend/src/modules/notifications/README.md @@ -0,0 +1,47 @@ +# Notifications Module + +Standardises alert delivery across notification channels behind a single +provider interface. Adding a new channel (Slack, email, PagerDuty…) never +requires touching existing alert logic. + +## Architecture + +``` +NotificationsService + │ + ├── DiscordNotificationProvider (implements INotificationProvider) + └── TelegramNotificationProvider (implements INotificationProvider) +``` + +## Interface + +```typescript +interface INotificationProvider { + readonly providerName: string; + sendAlert(payload: NotificationPayload): Promise; + isHealthy(): Promise; +} +``` + +## Adding a new provider + +1. Create `providers/my-channel.notification-provider.ts`. +2. Implement `INotificationProvider`. +3. Instantiate it inside the `NOTIFICATION_PROVIDERS` factory in + `notifications.module.ts`. + +That's it — `NotificationsService` will automatically pick it up. + +## Environment variables + +| Variable | Required for | +| --------------------- | ------------ | +| `DISCORD_WEBHOOK_URL` | Discord | +| `TELEGRAM_BOT_TOKEN` | Telegram | +| `TELEGRAM_CHAT_ID` | Telegram | + +Providers are only registered when the relevant env vars are present. + +## Closes + +GitHub issue #74 — Create Notification Provider Abstraction diff --git a/apps/backend/src/modules/notifications/interfaces/notification-provider.interface.ts b/apps/backend/src/modules/notifications/interfaces/notification-provider.interface.ts new file mode 100644 index 0000000..ecc8def --- /dev/null +++ b/apps/backend/src/modules/notifications/interfaces/notification-provider.interface.ts @@ -0,0 +1,33 @@ +/** + * Represents the payload sent to any notification provider. + */ +export interface NotificationPayload { + title: string; + message: string; + severity: 'low' | 'medium' | 'high' | 'critical'; + metadata?: Record; +} + +/** + * Common interface that every notification provider must implement. + * Implement this to add new channels (Slack, PagerDuty, email, etc.) + * without changing existing alert logic. + */ +export interface INotificationProvider { + /** + * Unique identifier for this provider (e.g. "discord", "telegram"). + */ + readonly providerName: string; + + /** + * Send an alert through this notification channel. + * @param payload - The structured alert payload to deliver. + */ + sendAlert(payload: NotificationPayload): Promise; + + /** + * Verify the provider is reachable and correctly configured. + * Returns true when the provider is healthy. + */ + isHealthy(): Promise; +} diff --git a/apps/backend/src/modules/notifications/notifications.module.ts b/apps/backend/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..f40e6b1 --- /dev/null +++ b/apps/backend/src/modules/notifications/notifications.module.ts @@ -0,0 +1,42 @@ +import { Module } from '@nestjs/common'; +import { NotificationsService } from './notifications.service'; +import { DiscordNotificationProvider } from './providers/discord.notification-provider'; +import { TelegramNotificationProvider } from './providers/telegram.notification-provider'; +import { INotificationProvider } from './interfaces/notification-provider.interface'; + +/** + * Provides a registry of notification providers and a unified service + * for dispatching alerts to one or more channels. + * + * To add a new provider: + * 1. Implement `INotificationProvider` in `providers/` + * 2. Register it in the `NOTIFICATION_PROVIDERS` token below + */ +@Module({ + providers: [ + NotificationsService, + { + provide: 'NOTIFICATION_PROVIDERS', + useFactory: (): INotificationProvider[] => { + const providers: INotificationProvider[] = []; + + if (process.env.DISCORD_WEBHOOK_URL) { + providers.push(new DiscordNotificationProvider(process.env.DISCORD_WEBHOOK_URL)); + } + + if (process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID) { + providers.push( + new TelegramNotificationProvider( + process.env.TELEGRAM_BOT_TOKEN, + process.env.TELEGRAM_CHAT_ID, + ), + ); + } + + return providers; + }, + }, + ], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/apps/backend/src/modules/notifications/notifications.service.ts b/apps/backend/src/modules/notifications/notifications.service.ts new file mode 100644 index 0000000..2f991f4 --- /dev/null +++ b/apps/backend/src/modules/notifications/notifications.service.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { + INotificationProvider, + NotificationPayload, +} from './interfaces/notification-provider.interface'; + +/** + * Orchestrates alert dispatching across all registered notification providers. + * Consumers depend only on this service — they never reference a concrete + * provider directly, making new channels transparent to callers. + */ +@Injectable() +export class NotificationsService { + private readonly logger = new Logger(NotificationsService.name); + + constructor( + @Inject('NOTIFICATION_PROVIDERS') + private readonly providers: INotificationProvider[], + ) {} + + /** + * Send the payload to every registered provider. + * Individual provider failures are logged but do not abort delivery + * to the remaining providers. + */ + async sendAlert(payload: NotificationPayload): Promise { + if (this.providers.length === 0) { + this.logger.warn('No notification providers configured — alert not sent'); + return; + } + + const results = await Promise.allSettled( + this.providers.map(provider => provider.sendAlert(payload)), + ); + + results.forEach((result, index) => { + const name = this.providers[index].providerName; + if (result.status === 'rejected') { + this.logger.error(`Provider "${name}" failed: ${String(result.reason)}`); + } + }); + } + + /** + * Returns the health status of every registered provider. + */ + async getProvidersHealth(): Promise> { + const entries = await Promise.all( + this.providers.map(async provider => [ + provider.providerName, + await provider.isHealthy().catch(() => false), + ]), + ); + return Object.fromEntries(entries); + } + + /** Returns the names of all registered providers. */ + getProviderNames(): string[] { + return this.providers.map(p => p.providerName); + } +} diff --git a/apps/backend/src/modules/notifications/providers/discord.notification-provider.ts b/apps/backend/src/modules/notifications/providers/discord.notification-provider.ts new file mode 100644 index 0000000..2f216ac --- /dev/null +++ b/apps/backend/src/modules/notifications/providers/discord.notification-provider.ts @@ -0,0 +1,80 @@ +import { Injectable, Logger } from '@nestjs/common'; +import axios from 'axios'; +import { + INotificationProvider, + NotificationPayload, +} from '../interfaces/notification-provider.interface'; + +/** Discord embed colour values mapped to alert severity. */ +const SEVERITY_COLORS: Record = { + low: 3447003, // Blue + medium: 16776960, // Yellow + high: 16737380, // Orange + critical: 16711680, // Red +}; + +/** + * Notification provider that delivers alerts to a Discord channel + * via an incoming webhook. + */ +@Injectable() +export class DiscordNotificationProvider implements INotificationProvider { + readonly providerName = 'discord'; + private readonly logger = new Logger(DiscordNotificationProvider.name); + + constructor(private readonly webhookUrl: string) { + if (!webhookUrl) { + throw new Error('DiscordNotificationProvider: webhookUrl is required'); + } + try { + new URL(webhookUrl); + } catch { + throw new Error('DiscordNotificationProvider: invalid webhookUrl format'); + } + } + + async sendAlert(payload: NotificationPayload): Promise { + const body = { + embeds: [ + { + title: payload.title, + description: payload.message, + color: SEVERITY_COLORS[payload.severity], + timestamp: new Date().toISOString(), + fields: payload.metadata + ? Object.entries(payload.metadata).map(([name, value]) => ({ + name, + value: String(value), + inline: true, + })) + : [], + }, + ], + }; + + try { + await axios.post(this.webhookUrl, body); + this.logger.log(`Discord alert sent: ${payload.title}`); + } catch (error) { + const message = axios.isAxiosError(error) + ? (error.response?.data?.message ?? error.message) + : String(error); + this.logger.error(`Discord alert failed: ${message}`); + throw new Error(`DiscordNotificationProvider.sendAlert failed: ${message}`); + } + } + + async isHealthy(): Promise { + try { + // Discord webhooks respond with 405 on GET — that still proves connectivity. + await axios.get(this.webhookUrl); + return true; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 405) { + return true; + } + this.logger.warn(`Discord health check failed: ${String(error)}`); + return false; + } + } +} diff --git a/apps/backend/src/modules/notifications/providers/telegram.notification-provider.ts b/apps/backend/src/modules/notifications/providers/telegram.notification-provider.ts new file mode 100644 index 0000000..ad30c7e --- /dev/null +++ b/apps/backend/src/modules/notifications/providers/telegram.notification-provider.ts @@ -0,0 +1,81 @@ +import { Injectable, Logger } from '@nestjs/common'; +import axios from 'axios'; +import { + INotificationProvider, + NotificationPayload, +} from '../interfaces/notification-provider.interface'; + +const SEVERITY_EMOJI: Record = { + low: '🔵', + medium: '🟡', + high: '🟠', + critical: '🔴', +}; + +/** + * Notification provider that delivers alerts to a Telegram chat + * via the Telegram Bot API. + */ +@Injectable() +export class TelegramNotificationProvider implements INotificationProvider { + readonly providerName = 'telegram'; + private readonly logger = new Logger(TelegramNotificationProvider.name); + private readonly apiBase: string; + + constructor( + private readonly botToken: string, + private readonly chatId: string, + ) { + if (!botToken) throw new Error('TelegramNotificationProvider: botToken is required'); + if (!chatId) throw new Error('TelegramNotificationProvider: chatId is required'); + this.apiBase = `https://api.telegram.org/bot${this.botToken}`; + } + + async sendAlert(payload: NotificationPayload): Promise { + const emoji = SEVERITY_EMOJI[payload.severity]; + const lines: string[] = [ + `${emoji} *${this.escapeMarkdown(payload.title)}*`, + '', + this.escapeMarkdown(payload.message), + ]; + + if (payload.metadata) { + lines.push(''); + for (const [key, value] of Object.entries(payload.metadata)) { + lines.push(`• *${this.escapeMarkdown(key)}*: ${this.escapeMarkdown(String(value))}`); + } + } + + const text = lines.join('\n'); + + try { + await axios.post(`${this.apiBase}/sendMessage`, { + chat_id: this.chatId, + text, + parse_mode: 'MarkdownV2', + }); + this.logger.log(`Telegram alert sent: ${payload.title}`); + } catch (error) { + const message = axios.isAxiosError(error) + ? (error.response?.data?.description ?? error.message) + : String(error); + this.logger.error(`Telegram alert failed: ${message}`); + throw new Error(`TelegramNotificationProvider.sendAlert failed: ${message}`); + } + } + + async isHealthy(): Promise { + try { + const response = await axios.get<{ ok: boolean }>(`${this.apiBase}/getMe`); + return response.data.ok === true; + } catch (error) { + this.logger.warn(`Telegram health check failed: ${String(error)}`); + return false; + } + } + + /** Escapes special characters required by Telegram's MarkdownV2 format. */ + private escapeMarkdown(text: string): string { + return text.replace(/[_*[\]()~`>#+=|{}.!\\-]/g, char => `\\${char}`); + } +}