Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
export class AppModule {}
38 changes: 38 additions & 0 deletions apps/backend/src/modules/health/README.md
Original file line number Diff line number Diff line change
@@ -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<DependencyHealthResult>` method to `HealthService`.
2. Call it inside `check()` and merge the result into `dependencies`.

## Closes

GitHub issue #84 — Add Health Check Endpoint
39 changes: 39 additions & 0 deletions apps/backend/src/modules/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -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<HealthCheckResult> {
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()) };
}
}
10 changes: 10 additions & 0 deletions apps/backend/src/modules/health/health.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
52 changes: 52 additions & 0 deletions apps/backend/src/modules/health/health.service.ts
Original file line number Diff line number Diff line change
@@ -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<HealthCheckResult> {
const dependencies: Record<string, DependencyHealthResult> = {};

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<DependencyHealthResult> {
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),
};
}
}
}
Original file line number Diff line number Diff line change
@@ -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<string, DependencyHealthResult>;
}
47 changes: 47 additions & 0 deletions apps/backend/src/modules/notifications/README.md
Original file line number Diff line number Diff line change
@@ -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<void>;
isHealthy(): Promise<boolean>;
}
```

## 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
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

/**
* 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<void>;

/**
* Verify the provider is reachable and correctly configured.
* Returns true when the provider is healthy.
*/
isHealthy(): Promise<boolean>;
}
42 changes: 42 additions & 0 deletions apps/backend/src/modules/notifications/notifications.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
61 changes: 61 additions & 0 deletions apps/backend/src/modules/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<Record<string, boolean>> {
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);
}
}
Loading
Loading