feat(mana-notify): add central notification service

NestJS notification microservice for email, push, Matrix, and webhook
notifications across all ManaCore apps.

Features:
- Multi-channel delivery (email, push, Matrix, webhook)
- Handlebars template engine with defaults
- User notification preferences
- BullMQ async job processing
- Delivery tracking and logging
- Prometheus metrics

Includes @manacore/notify-client package for NestJS integration.
This commit is contained in:
Till-JS 2026-01-29 22:07:38 +01:00
parent 1495dbe476
commit b5fa0f42b6
66 changed files with 4824 additions and 0 deletions

View file

@ -0,0 +1,354 @@
import type {
SendEmailOptions,
SendPushOptions,
SendMatrixOptions,
SendWebhookOptions,
ScheduleOptions,
NotificationResponse,
BatchNotificationResponse,
Template,
RenderedTemplate,
} from './types';
export interface NotifyClientOptions {
serviceUrl: string;
serviceKey: string;
appId: string;
timeout?: number;
}
export class NotifyClient {
private readonly serviceUrl: string;
private readonly serviceKey: string;
private readonly appId: string;
private readonly timeout: number;
constructor(options: NotifyClientOptions) {
this.serviceUrl = options.serviceUrl.replace(/\/$/, '');
this.serviceKey = options.serviceKey;
this.appId = options.appId;
this.timeout = options.timeout || 30000;
}
// ==================== Notifications ====================
/**
* Send an email notification
*/
async sendEmail(options: SendEmailOptions): Promise<NotificationResponse> {
return this.send({
channel: 'email',
appId: this.appId,
recipient: options.to,
template: options.template,
subject: options.subject,
body: options.body,
data: options.data,
emailOptions: {
from: options.from,
replyTo: options.replyTo,
},
priority: options.priority,
externalId: options.externalId,
});
}
/**
* Send a push notification
*/
async sendPush(options: SendPushOptions): Promise<NotificationResponse> {
return this.send({
channel: 'push',
appId: this.appId,
userId: options.userId,
recipient: options.token,
recipients: options.tokens,
subject: options.title,
body: options.body,
data: options.data,
pushOptions: {
sound: options.sound,
badge: options.badge,
channelId: options.channelId,
},
priority: options.priority,
externalId: options.externalId,
});
}
/**
* Send a Matrix message
*/
async sendMatrix(options: SendMatrixOptions): Promise<NotificationResponse> {
return this.send({
channel: 'matrix',
appId: this.appId,
recipient: options.roomId,
body: options.body,
matrixOptions: {
formattedBody: options.formattedBody,
msgtype: options.msgtype,
},
priority: options.priority,
externalId: options.externalId,
});
}
/**
* Send a webhook notification
*/
async sendWebhook(options: SendWebhookOptions): Promise<NotificationResponse> {
return this.send({
channel: 'webhook',
appId: this.appId,
recipient: options.url,
data: options.body,
webhookOptions: {
method: options.method,
headers: options.headers,
timeout: options.timeout,
},
priority: options.priority,
externalId: options.externalId,
});
}
/**
* Schedule an email notification
*/
async scheduleEmail(options: SendEmailOptions & ScheduleOptions): Promise<NotificationResponse> {
return this.schedule({
channel: 'email',
appId: this.appId,
recipient: options.to,
template: options.template,
subject: options.subject,
body: options.body,
data: options.data,
emailOptions: {
from: options.from,
replyTo: options.replyTo,
},
priority: options.priority,
externalId: options.externalId,
scheduledFor:
options.scheduledFor instanceof Date
? options.scheduledFor.toISOString()
: options.scheduledFor,
});
}
/**
* Schedule a push notification
*/
async schedulePush(options: SendPushOptions & ScheduleOptions): Promise<NotificationResponse> {
return this.schedule({
channel: 'push',
appId: this.appId,
userId: options.userId,
recipient: options.token,
recipients: options.tokens,
subject: options.title,
body: options.body,
data: options.data,
pushOptions: {
sound: options.sound,
badge: options.badge,
channelId: options.channelId,
},
priority: options.priority,
externalId: options.externalId,
scheduledFor:
options.scheduledFor instanceof Date
? options.scheduledFor.toISOString()
: options.scheduledFor,
});
}
/**
* Send multiple notifications in batch
*/
async sendBatch(
notifications: Array<
| ({ type: 'email' } & SendEmailOptions)
| ({ type: 'push' } & SendPushOptions)
| ({ type: 'matrix' } & SendMatrixOptions)
| ({ type: 'webhook' } & SendWebhookOptions)
>
): Promise<BatchNotificationResponse> {
const mapped = notifications.map((n) => {
if (n.type === 'email') {
return {
channel: 'email' as const,
appId: this.appId,
recipient: n.to,
template: n.template,
subject: n.subject,
body: n.body,
data: n.data,
priority: n.priority,
externalId: n.externalId,
};
} else if (n.type === 'push') {
return {
channel: 'push' as const,
appId: this.appId,
userId: n.userId,
recipient: n.token,
recipients: n.tokens,
subject: n.title,
body: n.body,
data: n.data,
priority: n.priority,
externalId: n.externalId,
};
} else if (n.type === 'matrix') {
return {
channel: 'matrix' as const,
appId: this.appId,
recipient: n.roomId,
body: n.body,
priority: n.priority,
externalId: n.externalId,
};
} else {
return {
channel: 'webhook' as const,
appId: this.appId,
recipient: n.url,
data: n.body,
priority: n.priority,
externalId: n.externalId,
};
}
});
const response = await this.request<BatchNotificationResponse>('/notifications/batch', {
method: 'POST',
body: JSON.stringify({ notifications: mapped }),
});
return response;
}
/**
* Get notification status
*/
async getNotification(id: string): Promise<NotificationResponse | null> {
const response = await this.request<{ notification: NotificationResponse | null }>(
`/notifications/${id}`
);
return response.notification;
}
/**
* Cancel a pending notification
*/
async cancelNotification(id: string): Promise<NotificationResponse> {
const response = await this.request<{ notification: NotificationResponse }>(
`/notifications/${id}`,
{ method: 'DELETE' }
);
return response.notification;
}
// ==================== Templates ====================
/**
* List all templates
*/
async listTemplates(appId?: string): Promise<Template[]> {
const url = appId ? `/templates?appId=${encodeURIComponent(appId)}` : '/templates';
const response = await this.request<{ templates: Template[] }>(url);
return response.templates;
}
/**
* Get a template by slug
*/
async getTemplate(slug: string, locale = 'de-DE'): Promise<Template | null> {
const response = await this.request<{ template: Template | null }>(
`/templates/${encodeURIComponent(slug)}?locale=${encodeURIComponent(locale)}`
);
return response.template;
}
/**
* Preview a template with data
*/
async previewTemplate(
slug: string,
data: Record<string, unknown>,
locale = 'de-DE'
): Promise<RenderedTemplate | null> {
const response = await this.request<{ preview: RenderedTemplate | null }>(
`/templates/${encodeURIComponent(slug)}/preview?locale=${encodeURIComponent(locale)}`,
{
method: 'POST',
body: JSON.stringify({ data }),
}
);
return response.preview;
}
// ==================== Private Methods ====================
private async send(payload: Record<string, unknown>): Promise<NotificationResponse> {
const response = await this.request<{ notification: NotificationResponse }>(
'/notifications/send',
{
method: 'POST',
body: JSON.stringify(payload),
}
);
return response.notification;
}
private async schedule(payload: Record<string, unknown>): Promise<NotificationResponse> {
const response = await this.request<{ notification: NotificationResponse }>(
'/notifications/schedule',
{
method: 'POST',
body: JSON.stringify(payload),
}
);
return response.notification;
}
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${this.serviceUrl}/api/v1${path}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Service-Key': this.serviceKey,
...options.headers,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const message =
(errorData as { error?: { message?: string } }).error?.message ||
`HTTP ${response.status}`;
throw new Error(`NotifyClient error: ${message}`);
}
return response.json() as Promise<T>;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('NotifyClient error: Request timeout');
}
throw error;
}
}
}

View file

@ -0,0 +1,2 @@
export { NotifyClient, NotifyClientOptions } from './client';
export * from './types';

View file

@ -0,0 +1,2 @@
export const NOTIFY_CLIENT = 'NOTIFY_CLIENT';
export const NOTIFY_MODULE_OPTIONS = 'NOTIFY_MODULE_OPTIONS';

View file

@ -0,0 +1,2 @@
export { NotifyModule, NotifyModuleOptions, NotifyModuleAsyncOptions } from './notify.module';
export { NOTIFY_CLIENT } from './constants';

View file

@ -0,0 +1,106 @@
import {
type DynamicModule,
Module,
type Provider,
type Type,
type InjectionToken,
type OptionalFactoryDependency,
} from '@nestjs/common';
import { NotifyClient, type NotifyClientOptions } from '../client';
import { NOTIFY_CLIENT, NOTIFY_MODULE_OPTIONS } from './constants';
export type NotifyModuleOptions = NotifyClientOptions;
export interface NotifyModuleAsyncOptions {
imports?: DynamicModule[];
useFactory?: (...args: unknown[]) => Promise<NotifyModuleOptions> | NotifyModuleOptions;
inject?: (InjectionToken | OptionalFactoryDependency)[];
useClass?: Type<NotifyModuleOptionsFactory>;
useExisting?: Type<NotifyModuleOptionsFactory>;
}
export interface NotifyModuleOptionsFactory {
createNotifyOptions(): Promise<NotifyModuleOptions> | NotifyModuleOptions;
}
@Module({})
export class NotifyModule {
/**
* Register the module with static options
*/
static forRoot(options: NotifyModuleOptions): DynamicModule {
const clientProvider: Provider = {
provide: NOTIFY_CLIENT,
useValue: new NotifyClient(options),
};
return {
module: NotifyModule,
global: true,
providers: [clientProvider],
exports: [clientProvider],
};
}
/**
* Register the module with async options (e.g., from ConfigService)
*/
static forRootAsync(options: NotifyModuleAsyncOptions): DynamicModule {
const providers = this.createAsyncProviders(options);
return {
module: NotifyModule,
global: true,
imports: options.imports || [],
providers: [
...providers,
{
provide: NOTIFY_CLIENT,
useFactory: (opts: NotifyModuleOptions) => new NotifyClient(opts),
inject: [NOTIFY_MODULE_OPTIONS],
},
],
exports: [NOTIFY_CLIENT],
};
}
private static createAsyncProviders(options: NotifyModuleAsyncOptions): Provider[] {
if (options.useFactory) {
return [
{
provide: NOTIFY_MODULE_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || [],
},
];
}
if (options.useClass) {
return [
{
provide: NOTIFY_MODULE_OPTIONS,
useFactory: async (optionsFactory: NotifyModuleOptionsFactory) =>
optionsFactory.createNotifyOptions(),
inject: [options.useClass],
},
{
provide: options.useClass,
useClass: options.useClass,
},
];
}
if (options.useExisting) {
return [
{
provide: NOTIFY_MODULE_OPTIONS,
useFactory: async (optionsFactory: NotifyModuleOptionsFactory) =>
optionsFactory.createNotifyOptions(),
inject: [options.useExisting],
},
];
}
return [];
}
}

View file

@ -0,0 +1,104 @@
export type NotificationChannel = 'email' | 'push' | 'matrix' | 'webhook';
export type NotificationPriority = 'low' | 'normal' | 'high' | 'critical';
export type NotificationStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'cancelled';
export interface SendEmailOptions {
to: string;
template?: string;
subject?: string;
body?: string;
data?: Record<string, unknown>;
from?: string;
replyTo?: string;
priority?: NotificationPriority;
externalId?: string;
}
export interface SendPushOptions {
userId?: string;
token?: string;
tokens?: string[];
title: string;
body: string;
data?: Record<string, unknown>;
sound?: 'default' | null;
badge?: number;
channelId?: string;
priority?: NotificationPriority;
externalId?: string;
}
export interface SendMatrixOptions {
roomId: string;
body: string;
formattedBody?: string;
msgtype?: 'text' | 'notice';
priority?: NotificationPriority;
externalId?: string;
}
export interface SendWebhookOptions {
url: string;
method?: 'POST' | 'PUT';
headers?: Record<string, string>;
body: Record<string, unknown>;
timeout?: number;
priority?: NotificationPriority;
externalId?: string;
}
export interface ScheduleOptions {
scheduledFor: Date | string;
}
export interface NotificationResponse {
id: string;
status: NotificationStatus;
channel: NotificationChannel;
createdAt: Date;
}
export interface BatchNotificationResponse {
results: NotificationResponse[];
succeeded: number;
failed: number;
}
export interface Template {
id: string;
slug: string;
channel: NotificationChannel;
subject?: string;
bodyTemplate: string;
locale: string;
isActive: boolean;
isSystem: boolean;
variables?: Record<string, string>;
}
export interface RenderedTemplate {
subject: string;
body: string;
}
export interface Device {
id: string;
userId: string;
pushToken: string;
tokenType: string;
platform: string;
deviceName?: string;
isActive: boolean;
}
export interface Preferences {
id: string;
userId: string;
emailEnabled: boolean;
pushEnabled: boolean;
quietHoursEnabled: boolean;
quietHoursStart?: string;
quietHoursEnd?: string;
timezone: string;
categoryPreferences?: Record<string, Record<string, boolean>>;
}