Skip to content
Open
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
31 changes: 31 additions & 0 deletions docs/NOTIFICATION_ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Notification & Event Delivery Architecture

## Overview
The Notification & Event Delivery API serves as the central communication backbone for the TruthBounty ecosystem. It handles real-time and asynchronous notifications across all platform modules (claims, verifications, disputes, governance, rewards).

## Core Components

### 1. Event Queue
Built on **BullMQ (Redis)**, ensuring fault-tolerant, asynchronous processing of events.
- **Queue Name**: `notifications`
- **Retry Mechanism**: Exponential backoff (max 5 attempts, delays starting at 2s, 4s, 8s, 16s).

### 2. Preference Engine
Users define their notification preferences (`NotificationPreference` entity):
- **Channels**: `IN_APP`, `EMAIL`, `PUSH`, `WEBHOOK`
- **Categories**: `CLAIM`, `VERIFICATION`, `DISPUTE`, `GOVERNANCE`, `REWARD`, etc.
- **Quiet Hours & Digest Mode**: Settings stored for intelligent delivery windows.

### 3. Delivery Tracking
The `Notification` entity tracks the lifecycle of every generated event:
- `QUEUED`, `DELIVERED`, `FAILED`, `READ`, `DISMISSED`
- Includes retry counts and detailed metadata for auditability.

### 4. Metrics & Monitoring
Accessible via `GET /notifications/metrics` to expose:
- Delivery success rate
- Total processed notifications
- Queued / Failed events

## Future Extensibility
The modular architecture permits drop-in integrations for SMS, Slack, Discord, Telegram, and AI-generated summaries without modifying the core queue processor.
9 changes: 6 additions & 3 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,14 @@ import { LoggingInterceptor } from './logger/logging.interceptor';
import { AuthModule } from './auth/auth.module';
import { GlobalAuthGuard } from './auth/global-auth.guard';
import { MetricsModule } from './metrics/metrics.module';
import { NotificationsModule } from './notifications/notifications.module';
import { Notification } from './notifications/entities/notification.entity';
import { NotificationPreference } from './notifications/entities/notification-preference.entity';
import { GovernanceModule } from './governance/governance.module';

// In-memory storage for development (no Redis needed)
class ThrottlerMemoryStorage {
private storage = new Map<
private storage = new Map
string,
{
totalHits: number;
Expand Down Expand Up @@ -284,6 +287,7 @@ async function createThrottlerStorage(configService: ConfigService): Promise<any
AuditModule,
ThemeModule,
MetricsModule,
NotificationsModule,
GovernanceModule,
],
controllers: [AppController],
Expand All @@ -307,5 +311,4 @@ async function createThrottlerStorage(configService: ConfigService): Promise<any
},
],
})
export class AppModule { }

export class AppModule { }
36 changes: 36 additions & 0 deletions src/notifications/dto/create-notification.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { IsEnum, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
import { NotificationCategory } from '../enums/notification-category.enum';
import { NotificationChannel } from '../enums/notification-channel.enum';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateNotificationDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
userId: string;

@ApiProperty({ enum: NotificationCategory })
@IsEnum(NotificationCategory)
@IsNotEmpty()
category: NotificationCategory;

@ApiPropertyOptional({ enum: NotificationChannel })
@IsEnum(NotificationChannel)
@IsOptional()
channel?: NotificationChannel;

@ApiProperty()
@IsString()
@IsNotEmpty()
title: string;

@ApiProperty()
@IsString()
@IsNotEmpty()
content: string;

@ApiPropertyOptional()
@IsObject()
@IsOptional()
metadata?: Record<string, any>;
}
43 changes: 43 additions & 0 deletions src/notifications/dto/update-preference.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
import { NotificationChannel } from '../enums/notification-channel.enum';
import { NotificationCategory } from '../enums/notification-category.enum';
import { ApiPropertyOptional } from '@nestjs/swagger';

export class UpdatePreferenceDto {
@ApiPropertyOptional({ type: [String], enum: NotificationChannel })
@IsArray()
@IsEnum(NotificationChannel, { each: true })
@IsOptional()
enabledChannels?: string[];

@ApiPropertyOptional({ type: [String], enum: NotificationCategory })
@IsArray()
@IsEnum(NotificationCategory, { each: true })
@IsOptional()
disabledCategories?: string[];

@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
digestMode?: boolean;

@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
quietHoursEnabled?: boolean;

@ApiPropertyOptional()
@IsString()
@IsOptional()
quietHoursStart?: string;

@ApiPropertyOptional()
@IsString()
@IsOptional()
quietHoursEnd?: string;

@ApiPropertyOptional()
@IsString()
@IsOptional()
language?: string;
}
38 changes: 38 additions & 0 deletions src/notifications/entities/notification-preference.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';

@Entity('notification_preferences')
export class NotificationPreference {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ type: 'varchar', unique: true })
@Index()
userId: string;

@Column({ type: 'simple-json', default: '["IN_APP", "EMAIL"]' })
enabledChannels: string[];

@Column({ type: 'simple-json', default: '[]' })
disabledCategories: string[];

@Column({ type: 'boolean', default: false })
digestMode: boolean;

@Column({ type: 'boolean', default: false })
quietHoursEnabled: boolean;

@Column({ type: 'varchar', nullable: true })
quietHoursStart: string;

@Column({ type: 'varchar', nullable: true })
quietHoursEnd: string;

@Column({ type: 'varchar', default: 'en' })
language: string;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
43 changes: 43 additions & 0 deletions src/notifications/entities/notification.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { NotificationChannel } from '../enums/notification-channel.enum';
import { NotificationCategory } from '../enums/notification-category.enum';
import { NotificationStatus } from '../enums/notification-status.enum';

@Entity('notifications')
export class Notification {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ type: 'varchar' })
userId: string;

@Column({ type: 'varchar' })
category: NotificationCategory;

@Column({ type: 'varchar' })
channel: NotificationChannel;

@Column({ type: 'varchar' })
title: string;

@Column({ type: 'text' })
content: string;

@Column({ type: 'simple-json', nullable: true })
metadata: Record<string, any>;

@Column({ type: 'varchar', default: NotificationStatus.QUEUED })
status: NotificationStatus;

@Column({ type: 'int', default: 0 })
retryCount: number;

@Column({ type: 'varchar', nullable: true })
errorMessage: string;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
10 changes: 10 additions & 0 deletions src/notifications/enums/notification-category.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export enum NotificationCategory {
CLAIM = 'CLAIM',
VERIFICATION = 'VERIFICATION',
DISPUTE = 'DISPUTE',
GOVERNANCE = 'GOVERNANCE',
REWARD = 'REWARD',
REPUTATION = 'REPUTATION',
MODERATION = 'MODERATION',
SYSTEM = 'SYSTEM',
}
6 changes: 6 additions & 0 deletions src/notifications/enums/notification-channel.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export enum NotificationChannel {
IN_APP = 'IN_APP',
EMAIL = 'EMAIL',
PUSH = 'PUSH',
WEBHOOK = 'WEBHOOK',
}
7 changes: 7 additions & 0 deletions src/notifications/enums/notification-status.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export enum NotificationStatus {
QUEUED = 'QUEUED',
DELIVERED = 'DELIVERED',
FAILED = 'FAILED',
READ = 'READ',
DISMISSED = 'DISMISSED',
}
48 changes: 48 additions & 0 deletions src/notifications/notifications.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { NotificationCategory } from './enums/notification-category.enum';

describe('NotificationsController', () => {
let controller: NotificationsController;
let mockService: any;

beforeEach(async () => {
mockService = {
queueNotification: jest.fn().mockResolvedValue({ id: '1' }),
getUserPreferences: jest.fn().mockResolvedValue({ userId: 'user-1' }),
updateUserPreferences: jest.fn().mockResolvedValue({ userId: 'user-1' }),
getDeliveryHistory: jest.fn().mockResolvedValue([[], 0]),
getMetrics: jest.fn().mockResolvedValue({ total: 0 }),
markAsRead: jest.fn().mockResolvedValue({ id: '1' }),
dismiss: jest.fn().mockResolvedValue({ id: '1' }),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [NotificationsController],
providers: [
{
provide: NotificationsService,
useValue: mockService,
},
],
}).compile();

controller = module.get<NotificationsController>(NotificationsController);
});

it('should call queueNotification', async () => {
await controller.queueEvent({
userId: '1',
category: NotificationCategory.CLAIM,
title: 'Test',
content: 'Test content',
});
expect(mockService.queueNotification).toHaveBeenCalled();
});

it('should call getPreferences', async () => {
await controller.getPreferences({ user: { id: 'user-1' } });
expect(mockService.getUserPreferences).toHaveBeenCalledWith('user-1');
});
});
73 changes: 73 additions & 0 deletions src/notifications/notifications.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Controller, Post, Body, Get, Param, Patch, Query, UseGuards, Request } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { CreateNotificationDto } from './dto/create-notification.dto';
import { UpdatePreferenceDto } from './dto/update-preference.dto';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { GlobalAuthGuard } from '../auth/global-auth.guard';

@ApiTags('notifications')
@ApiBearerAuth()
@UseGuards(GlobalAuthGuard)
@Controller('notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}

@Post('event')
@ApiOperation({ summary: 'Queue a new protocol event notification' })
@ApiResponse({ status: 201, description: 'The notification has been queued.' })
async queueEvent(@Body() createDto: CreateNotificationDto) {
return this.notificationsService.queueNotification(createDto);
}

@Get('preferences')
@ApiOperation({ summary: 'Get current user notification preferences' })
async getPreferences(@Request() req) {
const userId = req.user?.id || req.user?.walletAddress || 'anonymous';
return this.notificationsService.getUserPreferences(userId);
}

@Patch('preferences')
@ApiOperation({ summary: 'Update user notification preferences' })
async updatePreferences(@Request() req, @Body() updateDto: UpdatePreferenceDto) {
const userId = req.user?.id || req.user?.walletAddress || 'anonymous';
return this.notificationsService.updateUserPreferences(userId, updateDto);
}

@Get('history')
@ApiOperation({ summary: 'Get notification delivery history' })
@ApiQuery({ name: 'skip', required: false, type: Number })
@ApiQuery({ name: 'take', required: false, type: Number })
async getHistory(
@Request() req,
@Query('skip') skip?: number,
@Query('take') take?: number,
) {
const userId = req.user?.id || req.user?.walletAddress || 'anonymous';
const [data, total] = await this.notificationsService.getDeliveryHistory(
userId,
skip ? Number(skip) : 0,
take ? Number(take) : 50,
);
return { data, total, skip: skip || 0, take: take || 50 };
}

@Get('metrics')
@ApiOperation({ summary: 'Get notification delivery metrics' })
async getMetrics() {
return this.notificationsService.getMetrics();
}

@Patch(':id/read')
@ApiOperation({ summary: 'Mark a notification as read' })
async markAsRead(@Request() req, @Param('id') id: string) {
const userId = req.user?.id || req.user?.walletAddress || 'anonymous';
return this.notificationsService.markAsRead(id, userId);
}

@Patch(':id/dismiss')
@ApiOperation({ summary: 'Dismiss a notification' })
async dismiss(@Request() req, @Param('id') id: string) {
const userId = req.user?.id || req.user?.walletAddress || 'anonymous';
return this.notificationsService.dismiss(id, userId);
}
}
Loading
Loading