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,2 @@
tsup.config.ts
dist/

View file

@ -0,0 +1,149 @@
# @manacore/notify-client
Client SDK for the mana-notify notification service.
## Installation
```bash
pnpm add @manacore/notify-client
```
## Usage
### Basic Usage
```typescript
import { NotifyClient } from '@manacore/notify-client';
const notify = new NotifyClient({
serviceUrl: 'http://localhost:3040',
serviceKey: process.env.MANA_NOTIFY_SERVICE_KEY,
appId: 'your-app-id',
});
// Send email
await notify.sendEmail({
to: 'user@example.com',
template: 'auth-password-reset',
data: { resetUrl: 'https://...', userName: 'Max' },
});
// Send push notification
await notify.sendPush({
userId: 'user-uuid',
title: 'New Message',
body: 'You have a new message',
data: { messageId: 'xxx' },
});
// Send to specific token
await notify.sendPush({
token: 'ExponentPushToken[xxx]',
title: 'Hello',
body: 'World',
});
// Schedule notification
await notify.scheduleEmail({
to: 'user@example.com',
template: 'calendar-reminder',
data: { eventTitle: 'Meeting' },
scheduledFor: new Date('2024-12-20T13:45:00Z'),
});
```
### NestJS Integration
```typescript
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { NotifyModule } from '@manacore/notify-client/nestjs';
@Module({
imports: [
ConfigModule.forRoot(),
NotifyModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
serviceUrl: config.get('MANA_NOTIFY_URL', 'http://localhost:3040'),
serviceKey: config.get('MANA_NOTIFY_SERVICE_KEY'),
appId: config.get('APP_ID'),
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
```
Then inject the client:
```typescript
import { Injectable, Inject } from '@nestjs/common';
import { NOTIFY_CLIENT, NotifyClient } from '@manacore/notify-client/nestjs';
@Injectable()
export class NotificationService {
constructor(@Inject(NOTIFY_CLIENT) private readonly notify: NotifyClient) {}
async sendWelcomeEmail(email: string, name: string) {
await this.notify.sendEmail({
to: email,
template: 'auth-welcome',
data: { userName: name },
});
}
}
```
## API Reference
### NotifyClient
#### Constructor
```typescript
new NotifyClient({
serviceUrl: string; // mana-notify service URL
serviceKey: string; // Service authentication key
appId: string; // Your application ID
timeout?: number; // Request timeout in ms (default: 30000)
});
```
#### Methods
##### Email
- `sendEmail(options)` - Send an email immediately
- `scheduleEmail(options)` - Schedule an email for later
##### Push Notifications
- `sendPush(options)` - Send a push notification
- `schedulePush(options)` - Schedule a push notification
##### Other Channels
- `sendMatrix(options)` - Send a Matrix message
- `sendWebhook(options)` - Send a webhook
##### Batch & Management
- `sendBatch(notifications)` - Send multiple notifications
- `getNotification(id)` - Get notification status
- `cancelNotification(id)` - Cancel a pending notification
##### Templates
- `listTemplates(appId?)` - List available templates
- `getTemplate(slug, locale?)` - Get a template
- `previewTemplate(slug, data, locale?)` - Preview a rendered template
## Environment Variables
| Variable | Description |
|----------|-------------|
| `MANA_NOTIFY_URL` | mana-notify service URL |
| `MANA_NOTIFY_SERVICE_KEY` | Service authentication key |
| `APP_ID` | Your application ID |

View file

@ -0,0 +1,44 @@
{
"name": "@manacore/notify-client",
"version": "1.0.0",
"description": "Client SDK for mana-notify notification service",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./nestjs": {
"types": "./dist/nestjs/index.d.ts",
"import": "./dist/nestjs/index.mjs",
"require": "./dist/nestjs/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {},
"devDependencies": {
"@nestjs/common": "^10.4.17",
"@types/node": "^22.10.5",
"tsup": "^8.3.5",
"typescript": "^5.7.2"
},
"peerDependencies": {
"@nestjs/common": ">=10.0.0"
},
"peerDependenciesMeta": {
"@nestjs/common": {
"optional": true
}
}
}

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>>;
}

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,15 @@
/* eslint-disable */
import { defineConfig } from 'tsup';
export default defineConfig({
entry: {
index: 'src/index.ts',
'nestjs/index': 'src/nestjs/index.ts',
},
format: ['cjs', 'esm'],
dts: true,
clean: true,
splitting: false,
sourcemap: true,
external: ['@nestjs/common', '@nestjs/core'],
});

View file

@ -0,0 +1,390 @@
# Mana Notify Service
Central notification microservice for email, push, Matrix, and webhook notifications across all ManaCore apps.
## Overview
- **Port**: 3040
- **Technology**: NestJS + BullMQ + Drizzle ORM + PostgreSQL + Redis
- **Purpose**: Unified notification API with template support and delivery tracking
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Consumer Apps │
│ Auth │ Calendar │ Chat │ Picture │ Zitare │ ... │
└─────────────────────────┬───────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ mana-notify (Port 3040) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Notification│ │ Template │ │ Preferences │ │
│ │ API │ │ Engine │ │ Manager │ │
│ └──────┬──────┘ └──────┬──────┘ └─────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ BullMQ Job Queues │ │
│ │ Email │ Push │ Matrix │ Webhook │ │
│ └──────────────────────────────────────────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Brevo │ │ Expo │ │ Matrix │ │ HTTP │ │
│ │ SMTP │ │ Push │ │ API │ │ Client │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Quick Start
### Development
```bash
# 1. Start PostgreSQL and Redis (from monorepo root)
pnpm docker:up
# 2. Install dependencies
pnpm install
# 3. Push database schema
pnpm db:push
# 4. Start in development mode
pnpm dev
```
### Production
```bash
pnpm build
pnpm start
```
## API Endpoints
### Notifications (Service-Key Auth: X-Service-Key header)
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/v1/notifications/send` | Send notification immediately |
| POST | `/api/v1/notifications/schedule` | Schedule notification for later |
| POST | `/api/v1/notifications/batch` | Send multiple notifications |
| GET | `/api/v1/notifications/:id` | Get notification status |
| DELETE | `/api/v1/notifications/:id` | Cancel pending notification |
### Templates (Service-Key Auth)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/templates` | List all templates |
| GET | `/api/v1/templates/:slug` | Get template by slug |
| POST | `/api/v1/templates` | Create custom template |
| PUT | `/api/v1/templates/:slug` | Update template |
| DELETE | `/api/v1/templates/:slug` | Delete custom template |
| POST | `/api/v1/templates/:slug/preview` | Preview rendered template |
| POST | `/api/v1/templates/preview` | Preview custom template |
### Devices (JWT Auth: Bearer token)
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/v1/devices/register` | Register push device |
| GET | `/api/v1/devices` | List user's devices |
| DELETE | `/api/v1/devices/:id` | Unregister device |
### Preferences (JWT Auth)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/preferences` | Get user preferences |
| PUT | `/api/v1/preferences` | Update preferences |
### System
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/health` | Health check |
| GET | `/metrics` | Prometheus metrics |
## Usage Examples
### Send Email
```bash
curl -X POST http://localhost:3040/api/v1/notifications/send \
-H "Content-Type: application/json" \
-H "X-Service-Key: your-service-key" \
-d '{
"channel": "email",
"appId": "auth",
"template": "auth-password-reset",
"recipient": "user@example.com",
"data": {
"resetUrl": "https://mana.how/reset?token=xxx",
"userName": "Max"
}
}'
```
### Send Push Notification
```bash
curl -X POST http://localhost:3040/api/v1/notifications/send \
-H "Content-Type: application/json" \
-H "X-Service-Key: your-service-key" \
-d '{
"channel": "push",
"appId": "calendar",
"userId": "user-uuid",
"subject": "Erinnerung",
"body": "Meeting in 15 Minuten",
"data": { "eventId": "event-uuid" }
}'
```
### Register Device (User JWT)
```bash
curl -X POST http://localhost:3040/api/v1/devices/register \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_JWT" \
-d '{
"pushToken": "ExponentPushToken[xxx]",
"platform": "ios",
"deviceName": "iPhone 15"
}'
```
### Schedule Notification
```bash
curl -X POST http://localhost:3040/api/v1/notifications/schedule \
-H "Content-Type: application/json" \
-H "X-Service-Key: your-service-key" \
-d '{
"channel": "email",
"appId": "calendar",
"template": "calendar-reminder",
"recipient": "user@example.com",
"data": {
"eventTitle": "Team Meeting",
"eventTime": "14:00 Uhr",
"eventUrl": "https://calendar.mana.how/event/xxx"
},
"scheduledFor": "2024-12-20T13:45:00Z"
}'
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | 3040 | API port |
| `DATABASE_URL` | - | PostgreSQL connection URL |
| `REDIS_HOST` | localhost | Redis host for BullMQ |
| `REDIS_PORT` | 6379 | Redis port |
| `SERVICE_KEY` | dev-service-key | Internal service authentication key |
| `MANA_CORE_AUTH_URL` | http://localhost:3001 | Auth service URL for JWT validation |
| `SMTP_HOST` | smtp-relay.brevo.com | SMTP server host |
| `SMTP_PORT` | 587 | SMTP server port |
| `SMTP_USER` | - | SMTP username |
| `SMTP_PASSWORD` | - | SMTP password |
| `SMTP_FROM` | ManaCore <noreply@mana.how> | Default sender address |
| `EXPO_ACCESS_TOKEN` | - | Expo push notification access token |
| `MATRIX_HOMESERVER_URL` | - | Matrix homeserver URL |
| `MATRIX_ACCESS_TOKEN` | - | Matrix bot access token |
| `RATE_LIMIT_EMAIL_PER_MINUTE` | 10 | Email rate limit |
| `RATE_LIMIT_PUSH_PER_MINUTE` | 100 | Push notification rate limit |
## Development Commands
```bash
# Install dependencies
pnpm install
# Start development server
pnpm dev
# Build for production
pnpm build
# Start production server
pnpm start
# Type checking
pnpm type-check
# Linting
pnpm lint
# Database commands
pnpm db:push # Push schema to database
pnpm db:generate # Generate migrations
pnpm db:migrate # Run migrations
pnpm db:studio # Open Drizzle Studio
```
## Database Schema
The service uses its own schema (`notify`) in the shared ManaCore database:
- `notify.notifications` - Notification records with status tracking
- `notify.templates` - Email/push templates with Handlebars support
- `notify.devices` - Registered push notification devices
- `notify.preferences` - User notification preferences
- `notify.delivery_logs` - Delivery attempt logs
## Default Templates
| Slug | Channel | Purpose |
|------|---------|---------|
| `auth-password-reset` | email | Password reset email |
| `auth-verification` | email | Email verification |
| `auth-welcome` | email | Welcome email |
| `calendar-reminder` | email | Calendar event reminder |
## Notification Channels
### Email (Brevo SMTP)
- Uses Nodemailer with Brevo SMTP relay
- Supports HTML and plain text
- Template rendering with Handlebars
### Push (Expo)
- Uses Expo Server SDK
- Supports iOS, Android, and web
- Batch sending with automatic chunking
### Matrix
- Direct Matrix API integration
- Supports formatted (HTML) messages
- For bot notifications
### Webhook
- HTTP POST/PUT to external URLs
- Configurable headers and timeout
- Retry with exponential backoff
## Integration with Other Services
### Usage from NestJS Backend
```typescript
// Direct HTTP call
const response = await fetch('http://mana-notify:3040/api/v1/notifications/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': process.env.MANA_NOTIFY_SERVICE_KEY,
},
body: JSON.stringify({
channel: 'email',
appId: 'calendar',
template: 'calendar-reminder',
recipient: user.email,
data: { eventTitle, eventTime },
}),
});
```
### Using the Client SDK
```typescript
import { NotifyClient } from '@manacore/notify-client';
const notify = new NotifyClient({
serviceUrl: 'http://localhost:3040',
serviceKey: process.env.MANA_NOTIFY_SERVICE_KEY,
appId: 'calendar',
});
// Send email
await notify.sendEmail({
to: 'user@example.com',
template: 'calendar-reminder',
data: { eventTitle: 'Meeting', eventTime: '14:00' },
});
// Send push to user
await notify.sendPush({
userId: 'user-uuid',
title: 'Reminder',
body: 'Meeting in 15 minutes',
data: { eventId: 'xxx' },
});
```
## Project Structure
```
services/mana-notify/
├── src/
│ ├── main.ts # Application entry point
│ ├── app.module.ts # Root module
│ ├── config/
│ │ └── configuration.ts # App configuration
│ ├── db/
│ │ ├── schema/ # Drizzle schemas
│ │ ├── database.module.ts # Database provider
│ │ └── connection.ts # DB connection
│ ├── common/
│ │ ├── filters/ # Exception filters
│ │ └── guards/ # Auth guards
│ ├── queue/
│ │ ├── queue.module.ts # BullMQ setup
│ │ └── processors/ # Channel processors
│ ├── channels/
│ │ ├── email/ # Nodemailer service
│ │ ├── push/ # Expo push service
│ │ ├── matrix/ # Matrix API service
│ │ └── webhook/ # HTTP webhook service
│ ├── notifications/ # Core notification API
│ ├── templates/ # Template engine
│ │ └── defaults/ # Default HBS templates
│ ├── devices/ # Device registration
│ ├── preferences/ # User preferences
│ ├── health/ # Health check
│ └── metrics/ # Prometheus metrics
├── drizzle.config.ts
├── package.json
├── tsconfig.json
├── Dockerfile
└── CLAUDE.md
```
## Troubleshooting
### Email not sending
1. Check SMTP credentials in environment
2. Verify SMTP host/port settings
3. Check logs for error messages
### Push notifications failing
1. Verify Expo push tokens are valid
2. Check Expo access token is set
3. Ensure devices are registered
### Redis connection issues
```bash
# Check Redis
docker exec mana-notify-redis-dev redis-cli ping
# Check queue status
curl http://localhost:3040/health
```
## Metrics
Available at `/metrics` in Prometheus format:
- `mana_notify_notifications_sent_total` - Total notifications sent
- `mana_notify_notifications_failed_total` - Total failed notifications
- `mana_notify_emails_sent_total` - Emails sent by template
- `mana_notify_push_sent_total` - Push notifications by platform
- `mana_notify_notification_latency_seconds` - Processing latency

View file

@ -0,0 +1,46 @@
FROM node:20-alpine AS base
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Build stage
FROM base AS builder
# Copy workspace files
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY services/mana-notify/package.json ./services/mana-notify/
# Install dependencies
RUN pnpm install --frozen-lockfile --filter @manacore/mana-notify
# Copy source code
COPY services/mana-notify ./services/mana-notify
# Build
WORKDIR /app/services/mana-notify
RUN pnpm build
# Production stage
FROM base AS production
WORKDIR /app
# Copy built files
COPY --from=builder /app/services/mana-notify/dist ./dist
COPY --from=builder /app/services/mana-notify/package.json ./
# Copy template files
COPY --from=builder /app/services/mana-notify/src/templates/defaults ./dist/templates/defaults
# Install production dependencies only
COPY --from=builder /app/node_modules ./node_modules
# Set environment
ENV NODE_ENV=production
ENV PORT=3040
EXPOSE 3040
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,23 @@
version: '3.8'
# Development compose for mana-notify
# Provides Redis for BullMQ queue
# PostgreSQL should be running from root docker-compose
services:
redis:
image: redis:7-alpine
container_name: mana-notify-redis-dev
ports:
- '6379:6379'
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 5s
retries: 5
volumes:
redis-data:

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/index.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/manacore',
},
verbose: true,
strict: true,
});

View file

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": ["templates/defaults/**/*.hbs"],
"watchAssets": true
}
}

View file

@ -0,0 +1,60 @@
{
"name": "@manacore/mana-notify",
"version": "1.0.0",
"description": "Central notification microservice for email, push, matrix, and webhook notifications",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@nestjs/bullmq": "^10.2.3",
"@nestjs/common": "^10.4.17",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.17",
"@nestjs/platform-express": "^10.4.17",
"@nestjs/schedule": "^4.1.2",
"bullmq": "^5.34.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"drizzle-orm": "^0.38.4",
"expo-server-sdk": "^3.10.0",
"handlebars": "^4.7.8",
"ioredis": "^5.4.2",
"jose": "^5.9.6",
"nodemailer": "^7.0.3",
"postgres": "^3.4.5",
"prom-client": "^15.1.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@manacore/shared-drizzle-config": "workspace:*",
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.17",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.5",
"@types/nodemailer": "^6.4.17",
"drizzle-kit": "^0.30.4",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,44 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq';
import { ScheduleModule } from '@nestjs/schedule';
import configuration from './config/configuration';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { MetricsModule } from './metrics/metrics.module';
import { QueueModule } from './queue/queue.module';
import { ChannelsModule } from './channels/channels.module';
import { NotificationsModule } from './notifications/notifications.module';
import { TemplatesModule } from './templates/templates.module';
import { DevicesModule } from './devices/devices.module';
import { PreferencesModule } from './preferences/preferences.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get<string>('redis.host', 'localhost'),
port: configService.get<number>('redis.port', 6379),
},
}),
inject: [ConfigService],
}),
ScheduleModule.forRoot(),
DatabaseModule,
HealthModule,
MetricsModule,
QueueModule,
ChannelsModule,
NotificationsModule,
TemplatesModule,
DevicesModule,
PreferencesModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { EmailService } from './email/email.service';
import { PushService } from './push/push.service';
import { MatrixService } from './matrix/matrix.service';
import { WebhookService } from './webhook/webhook.service';
@Module({
providers: [EmailService, PushService, MatrixService, WebhookService],
exports: [EmailService, PushService, MatrixService, WebhookService],
})
export class ChannelsModule {}

View file

@ -0,0 +1,95 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
export interface EmailOptions {
to: string;
subject: string;
html: string;
text?: string;
from?: string;
replyTo?: string;
}
export interface EmailResult {
success: boolean;
messageId?: string;
error?: string;
}
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private transporter: nodemailer.Transporter | null = null;
private readonly defaultFrom: string;
constructor(private readonly configService: ConfigService) {
this.defaultFrom = this.configService.get<string>('smtp.from', 'ManaCore <noreply@mana.how>');
this.initializeTransporter();
}
private initializeTransporter(): void {
const host = this.configService.get<string>('smtp.host');
const port = this.configService.get<number>('smtp.port', 587);
const user = this.configService.get<string>('smtp.user');
const pass = this.configService.get<string>('smtp.password');
if (!user || !pass) {
this.logger.warn('SMTP credentials not configured, emails will be logged only');
return;
}
this.transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: {
user,
pass,
},
});
this.logger.log(`Email service initialized with SMTP host: ${host}`);
}
async sendEmail(options: EmailOptions): Promise<EmailResult> {
const { to, subject, html, text, from, replyTo } = options;
const sender = from || this.defaultFrom;
this.logger.debug(`Sending email to: ${to}, subject: ${subject}`);
if (!this.transporter) {
this.logger.log('[Email] No SMTP configured, logging email content:');
this.logger.log(` To: ${to}`);
this.logger.log(` Subject: ${subject}`);
this.logger.log(` HTML: ${html.substring(0, 200)}...`);
return { success: false, error: 'SMTP not configured' };
}
try {
const result = await this.transporter.sendMail({
from: sender,
to,
subject,
html,
text: text || this.stripHtml(html),
replyTo,
});
this.logger.log(`Email sent successfully, messageId: ${result.messageId}`);
return { success: true, messageId: result.messageId };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Failed to send email: ${errorMessage}`);
return { success: false, error: errorMessage };
}
}
private stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '');
}
isConfigured(): boolean {
return this.transporter !== null;
}
}

View file

@ -0,0 +1,85 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface MatrixMessage {
roomId: string;
body: string;
formattedBody?: string; // HTML formatted
msgtype?: 'text' | 'notice';
}
export interface MatrixResult {
success: boolean;
eventId?: string;
error?: string;
}
@Injectable()
export class MatrixService {
private readonly logger = new Logger(MatrixService.name);
private readonly homeserverUrl: string | null;
private readonly accessToken: string | null;
constructor(private readonly configService: ConfigService) {
this.homeserverUrl = this.configService.get<string>('matrix.homeserverUrl') || null;
this.accessToken = this.configService.get<string>('matrix.accessToken') || null;
if (this.isConfigured()) {
this.logger.log(`Matrix service initialized with homeserver: ${this.homeserverUrl}`);
} else {
this.logger.warn('Matrix service not configured');
}
}
isConfigured(): boolean {
return !!(this.homeserverUrl && this.accessToken);
}
async sendMessage(message: MatrixMessage): Promise<MatrixResult> {
if (!this.isConfigured()) {
return { success: false, error: 'Matrix not configured' };
}
const { roomId, body, formattedBody, msgtype = 'text' } = message;
const txnId = `mana_${Date.now()}_${Math.random().toString(36).substring(7)}`;
const endpoint = `${this.homeserverUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
const content: Record<string, string> = {
msgtype: `m.${msgtype}`,
body,
};
if (formattedBody) {
content.format = 'org.matrix.custom.html';
content.formatted_body = formattedBody;
}
try {
const response = await fetch(endpoint, {
method: 'PUT',
headers: {
Authorization: `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(content),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = (errorData as { error?: string }).error || response.statusText;
this.logger.error(`Matrix API error: ${response.status} - ${errorMessage}`);
return { success: false, error: errorMessage };
}
const data = (await response.json()) as { event_id?: string };
this.logger.debug(`Matrix message sent to ${roomId}, eventId: ${data.event_id}`);
return { success: true, eventId: data.event_id };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Failed to send Matrix message: ${errorMessage}`);
return { success: false, error: errorMessage };
}
}
}

View file

@ -0,0 +1,178 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Expo, { ExpoPushMessage, ExpoPushTicket, ExpoPushReceipt } from 'expo-server-sdk';
export interface PushNotification {
title: string;
body: string;
data?: Record<string, unknown>;
sound?: 'default' | null;
badge?: number;
channelId?: string;
}
export interface PushResult {
success: boolean;
ticketId?: string;
error?: string;
}
@Injectable()
export class PushService {
private readonly logger = new Logger(PushService.name);
private readonly expo: Expo;
constructor(private readonly configService: ConfigService) {
const accessToken = this.configService.get<string>('push.expoAccessToken');
this.expo = new Expo({
accessToken: accessToken || undefined,
});
if (accessToken) {
this.logger.log('Push service initialized with Expo access token');
} else {
this.logger.warn('Push service initialized without access token (rate limited)');
}
}
/**
* Validate if a token is a valid Expo push token
*/
isValidToken(token: string): boolean {
return Expo.isExpoPushToken(token);
}
/**
* Send push notification to a single token
*/
async sendToToken(token: string, notification: PushNotification): Promise<PushResult> {
if (!this.isValidToken(token)) {
this.logger.warn(`Invalid Expo push token: ${token}`);
return { success: false, error: 'Invalid push token' };
}
const message: ExpoPushMessage = {
to: token,
title: notification.title,
body: notification.body,
data: notification.data,
sound: notification.sound ?? 'default',
badge: notification.badge,
channelId: notification.channelId,
};
try {
const tickets = await this.expo.sendPushNotificationsAsync([message]);
const ticket = tickets[0];
if (ticket.status === 'error') {
this.logger.error(`Push notification error: ${ticket.message}`, ticket.details);
return { success: false, error: ticket.message };
}
this.logger.debug(
`Push notification sent successfully to token: ${token.substring(0, 30)}...`
);
return {
success: true,
ticketId: (ticket as ExpoPushTicket & { id?: string }).id,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Failed to send push notification: ${errorMessage}`);
return { success: false, error: errorMessage };
}
}
/**
* Send push notification to multiple tokens
*/
async sendToTokens(
tokens: string[],
notification: PushNotification
): Promise<Map<string, PushResult>> {
const results = new Map<string, PushResult>();
const validTokens = tokens.filter((token) => {
const isValid = this.isValidToken(token);
if (!isValid) {
this.logger.warn(`Skipping invalid token: ${token}`);
results.set(token, { success: false, error: 'Invalid token' });
}
return isValid;
});
if (validTokens.length === 0) {
return results;
}
const messages: ExpoPushMessage[] = validTokens.map((token) => ({
to: token,
title: notification.title,
body: notification.body,
data: notification.data,
sound: notification.sound ?? 'default',
badge: notification.badge,
channelId: notification.channelId,
}));
// Chunk messages (Expo has a limit of 100 per batch)
const chunks = this.expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
try {
const tickets = await this.expo.sendPushNotificationsAsync(chunk);
tickets.forEach((ticket, index) => {
const token = (chunk[index] as ExpoPushMessage).to as string;
if (ticket.status === 'ok') {
results.set(token, {
success: true,
ticketId: (ticket as ExpoPushTicket & { id?: string }).id,
});
} else {
this.logger.error(`Push error for ${token}: ${ticket.message}`);
results.set(token, { success: false, error: ticket.message });
}
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Failed to send push notification batch: ${errorMessage}`);
chunk.forEach((msg) => {
results.set(msg.to as string, { success: false, error: errorMessage });
});
}
}
const successCount = Array.from(results.values()).filter((v) => v.success).length;
this.logger.log(`Push notifications sent: ${successCount}/${tokens.length} successful`);
return results;
}
/**
* Check receipts for sent notifications
* Call this after some time to verify delivery
*/
async checkReceipts(ticketIds: string[]): Promise<Map<string, ExpoPushReceipt>> {
const results = new Map<string, ExpoPushReceipt>();
const chunks = this.expo.chunkPushNotificationReceiptIds(ticketIds);
for (const chunk of chunks) {
try {
const receipts = await this.expo.getPushNotificationReceiptsAsync(chunk);
for (const [id, receipt] of Object.entries(receipts)) {
results.set(id, receipt);
if (receipt.status === 'error') {
this.logger.error(`Receipt error for ${id}: ${receipt.message}`, receipt.details);
}
}
} catch (error) {
this.logger.error(`Failed to get push notification receipts: ${error}`);
}
}
return results;
}
}

View file

@ -0,0 +1,91 @@
import { Injectable, Logger } from '@nestjs/common';
export interface WebhookPayload {
url: string;
method?: 'POST' | 'PUT';
headers?: Record<string, string>;
body: Record<string, unknown>;
timeout?: number;
}
export interface WebhookResult {
success: boolean;
statusCode?: number;
response?: unknown;
error?: string;
durationMs?: number;
}
@Injectable()
export class WebhookService {
private readonly logger = new Logger(WebhookService.name);
private readonly defaultTimeout = 10000; // 10 seconds
async send(payload: WebhookPayload): Promise<WebhookResult> {
const { url, method = 'POST', headers = {}, body, timeout = this.defaultTimeout } = payload;
const startTime = Date.now();
this.logger.debug(`Sending webhook to ${url}`);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'ManaNotify/1.0',
...headers,
},
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeoutId);
const durationMs = Date.now() - startTime;
let responseData: unknown;
try {
responseData = await response.json();
} catch {
responseData = await response.text();
}
if (!response.ok) {
this.logger.warn(`Webhook returned ${response.status}: ${url}`);
return {
success: false,
statusCode: response.status,
response: responseData,
error: `HTTP ${response.status}`,
durationMs,
};
}
this.logger.debug(`Webhook sent successfully to ${url} in ${durationMs}ms`);
return {
success: true,
statusCode: response.status,
response: responseData,
durationMs,
};
} catch (error) {
const durationMs = Date.now() - startTime;
const errorMessage =
error instanceof Error
? error.name === 'AbortError'
? 'Request timeout'
: error.message
: 'Unknown error';
this.logger.error(`Webhook failed: ${errorMessage}`);
return {
success: false,
error: errorMessage,
durationMs,
};
}
}
}

View file

@ -0,0 +1,45 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || exception.message;
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(`Unhandled error: ${exception.message}`, exception.stack);
}
response.status(status).json({
success: false,
error: {
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
},
});
}
}

View file

@ -0,0 +1,75 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { jwtVerify, createRemoteJWKSet, JWTPayload } from 'jose';
export interface AuthenticatedUser {
userId: string;
email: string;
role?: string;
sessionId?: string;
}
export interface AuthenticatedRequest extends Request {
user: AuthenticatedUser;
}
/**
* Guard for user authentication via JWT (validated against mana-core-auth JWKS)
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
private readonly logger = new Logger(JwtAuthGuard.name);
private readonly authUrl: string;
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
constructor(private readonly configService: ConfigService) {
this.authUrl = this.configService.get<string>('auth.manaCoreAuthUrl', 'http://localhost:3001');
}
private getJwks() {
if (!this.jwks) {
this.jwks = createRemoteJWKSet(new URL('/api/v1/auth/jwks', this.authUrl));
}
return this.jwks;
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid authorization header');
}
const token = authHeader.substring(7);
try {
const { payload } = await jwtVerify(token, this.getJwks(), {
issuer: 'manacore',
audience: 'manacore',
});
request.user = this.extractUser(payload);
return true;
} catch (error) {
this.logger.warn(`JWT verification failed: ${error}`);
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractUser(payload: JWTPayload): AuthenticatedUser {
return {
userId: payload.sub as string,
email: payload.email as string,
role: payload.role as string | undefined,
sessionId: payload.sid as string | undefined,
};
}
}

View file

@ -0,0 +1,39 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
/**
* Guard for internal service-to-service authentication using X-Service-Key header
*/
@Injectable()
export class ServiceAuthGuard implements CanActivate {
private readonly logger = new Logger(ServiceAuthGuard.name);
private readonly serviceKey: string;
constructor(private readonly configService: ConfigService) {
this.serviceKey = this.configService.get<string>('auth.serviceKey', 'dev-service-key');
}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const providedKey = request.headers['x-service-key'] as string;
if (!providedKey) {
this.logger.warn('Missing X-Service-Key header');
throw new UnauthorizedException('Missing service key');
}
if (providedKey !== this.serviceKey) {
this.logger.warn('Invalid service key provided');
throw new UnauthorizedException('Invalid service key');
}
return true;
}
}

View file

@ -0,0 +1,44 @@
export default () => ({
port: parseInt(process.env.PORT || '3040', 10),
nodeEnv: process.env.NODE_ENV || 'development',
database: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/manacore',
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
},
auth: {
serviceKey: process.env.SERVICE_KEY || 'dev-service-key',
manaCoreAuthUrl: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
},
smtp: {
host: process.env.SMTP_HOST || 'smtp-relay.brevo.com',
port: parseInt(process.env.SMTP_PORT || '587', 10),
user: process.env.SMTP_USER,
password: process.env.SMTP_PASSWORD,
from: process.env.SMTP_FROM || 'ManaCore <noreply@mana.how>',
},
push: {
expoAccessToken: process.env.EXPO_ACCESS_TOKEN,
},
matrix: {
homeserverUrl: process.env.MATRIX_HOMESERVER_URL,
accessToken: process.env.MATRIX_ACCESS_TOKEN,
},
rateLimits: {
emailPerMinute: parseInt(process.env.RATE_LIMIT_EMAIL_PER_MINUTE || '10', 10),
pushPerMinute: parseInt(process.env.RATE_LIMIT_PUSH_PER_MINUTE || '100', 10),
},
cors: {
origins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:*'],
},
});

View file

@ -0,0 +1,33 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}

View file

@ -0,0 +1,24 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService) => {
const databaseUrl = configService.get<string>('database.url');
if (!databaseUrl) {
throw new Error('DATABASE_URL is not configured');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

View file

@ -0,0 +1,29 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
async function runMigrations() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('DATABASE_URL environment variable is not set');
process.exit(1);
}
console.log('Running migrations...');
const sql = postgres(databaseUrl, { max: 1 });
const db = drizzle(sql);
try {
await migrate(db, { migrationsFolder: './drizzle' });
console.log('Migrations completed successfully');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
await sql.end();
}
}
runMigrations();

View file

@ -0,0 +1,49 @@
import {
pgSchema,
uuid,
text,
integer,
boolean,
timestamp,
index,
varchar,
} from 'drizzle-orm/pg-core';
import { notifySchema, channelEnum, notifications } from './notifications.schema';
export const deliveryLogs = notifySchema.table(
'delivery_logs',
{
id: uuid('id').defaultRandom().primaryKey(),
// Reference
notificationId: uuid('notification_id')
.notNull()
.references(() => notifications.id, { onDelete: 'cascade' }),
// Attempt info
attemptNumber: integer('attempt_number').notNull(),
channel: channelEnum('channel').notNull(),
// Result
success: boolean('success').notNull(),
statusCode: integer('status_code'),
errorMessage: text('error_message'),
// Provider info
providerId: varchar('provider_id', { length: 255 }), // Expo ticket ID, email message ID, etc.
// Performance
durationMs: integer('duration_ms'),
// Timestamp
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
notificationIdIdx: index('delivery_logs_notification_id_idx').on(table.notificationId),
successIdx: index('delivery_logs_success_idx').on(table.success),
createdAtIdx: index('delivery_logs_created_at_idx').on(table.createdAt),
})
);
export type DeliveryLog = typeof deliveryLogs.$inferSelect;
export type NewDeliveryLog = typeof deliveryLogs.$inferInsert;

View file

@ -0,0 +1,46 @@
import {
pgSchema,
uuid,
text,
varchar,
boolean,
timestamp,
index,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { notifySchema } from './notifications.schema';
export const devices = notifySchema.table(
'devices',
{
id: uuid('id').defaultRandom().primaryKey(),
// Owner
userId: text('user_id').notNull(),
// Token
pushToken: text('push_token').notNull(),
tokenType: varchar('token_type', { length: 20 }).notNull().default('expo'), // expo, fcm, apns
// Device Info
platform: varchar('platform', { length: 20 }).notNull(), // ios, android, web
deviceName: varchar('device_name', { length: 100 }),
appId: varchar('app_id', { length: 50 }), // Which app registered this device
// Status
isActive: boolean('is_active').notNull().default(true),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('devices_user_id_idx').on(table.userId),
pushTokenIdx: uniqueIndex('devices_push_token_idx').on(table.pushToken),
platformIdx: index('devices_platform_idx').on(table.platform),
})
);
export type Device = typeof devices.$inferSelect;
export type NewDevice = typeof devices.$inferInsert;

View file

@ -0,0 +1,5 @@
export * from './notifications.schema';
export * from './templates.schema';
export * from './devices.schema';
export * from './preferences.schema';
export * from './delivery-logs.schema';

View file

@ -0,0 +1,75 @@
import {
pgSchema,
uuid,
text,
varchar,
integer,
timestamp,
index,
jsonb,
} from 'drizzle-orm/pg-core';
export const notifySchema = pgSchema('notify');
// Channel enum
export const channelEnum = notifySchema.enum('channel', ['email', 'push', 'matrix', 'webhook']);
// Status enum
export const statusEnum = notifySchema.enum('notification_status', [
'pending',
'processing',
'delivered',
'failed',
'cancelled',
]);
// Priority enum
export const priorityEnum = notifySchema.enum('priority', ['low', 'normal', 'high', 'critical']);
export const notifications = notifySchema.table(
'notifications',
{
id: uuid('id').defaultRandom().primaryKey(),
// Target
userId: text('user_id'),
appId: varchar('app_id', { length: 50 }).notNull(), // calendar, chat, auth, etc.
// Channel & Template
channel: channelEnum('channel').notNull(),
templateId: varchar('template_id', { length: 100 }),
// Content
subject: varchar('subject', { length: 500 }),
body: text('body'),
data: jsonb('data').$type<Record<string, unknown>>(), // Template variables
// Delivery
status: statusEnum('status').notNull().default('pending'),
priority: priorityEnum('priority').notNull().default('normal'),
scheduledFor: timestamp('scheduled_for', { withTimezone: true }),
recipient: varchar('recipient', { length: 500 }), // Email, Matrix Room, Webhook URL
// Idempotency
externalId: varchar('external_id', { length: 255 }),
// Processing
attempts: integer('attempts').notNull().default(0),
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
errorMessage: text('error_message'),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('notifications_user_id_idx').on(table.userId),
appIdIdx: index('notifications_app_id_idx').on(table.appId),
statusIdx: index('notifications_status_idx').on(table.status),
scheduledForIdx: index('notifications_scheduled_for_idx').on(table.scheduledFor),
externalIdIdx: index('notifications_external_id_idx').on(table.externalId),
})
);
export type Notification = typeof notifications.$inferSelect;
export type NewNotification = typeof notifications.$inferInsert;

View file

@ -0,0 +1,46 @@
import {
pgSchema,
uuid,
text,
varchar,
boolean,
timestamp,
uniqueIndex,
jsonb,
} from 'drizzle-orm/pg-core';
import { notifySchema } from './notifications.schema';
export const preferences = notifySchema.table(
'preferences',
{
id: uuid('id').defaultRandom().primaryKey(),
// Owner
userId: text('user_id').notNull(),
// Global settings
emailEnabled: boolean('email_enabled').notNull().default(true),
pushEnabled: boolean('push_enabled').notNull().default(true),
// Quiet hours
quietHoursEnabled: boolean('quiet_hours_enabled').notNull().default(false),
quietHoursStart: varchar('quiet_hours_start', { length: 5 }), // "22:00"
quietHoursEnd: varchar('quiet_hours_end', { length: 5 }), // "08:00"
timezone: varchar('timezone', { length: 50 }).notNull().default('Europe/Berlin'),
// Per-category preferences
// e.g., { "calendar": { "reminders": true, "shares": false }, "chat": { "messages": true } }
categoryPreferences:
jsonb('category_preferences').$type<Record<string, Record<string, boolean>>>(),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdIdx: uniqueIndex('preferences_user_id_idx').on(table.userId),
})
);
export type Preference = typeof preferences.$inferSelect;
export type NewPreference = typeof preferences.$inferInsert;

View file

@ -0,0 +1,50 @@
import {
pgSchema,
uuid,
text,
varchar,
boolean,
timestamp,
index,
jsonb,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { notifySchema, channelEnum } from './notifications.schema';
export const templates = notifySchema.table(
'templates',
{
id: uuid('id').defaultRandom().primaryKey(),
// Identification
slug: varchar('slug', { length: 100 }).notNull(), // e.g. "auth-password-reset"
appId: varchar('app_id', { length: 50 }), // NULL = system template
// Channel & Content
channel: channelEnum('channel').notNull(),
subject: varchar('subject', { length: 500 }), // Handlebars template
bodyTemplate: text('body_template').notNull(), // Handlebars template
// Localization
locale: varchar('locale', { length: 10 }).notNull().default('de-DE'),
// Settings
isActive: boolean('is_active').notNull().default(true),
isSystem: boolean('is_system').notNull().default(false), // System templates cannot be deleted
// Metadata
variables: jsonb('variables').$type<Record<string, string>>(), // Expected variables with descriptions
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
slugLocaleIdx: uniqueIndex('templates_slug_locale_idx').on(table.slug, table.locale),
appIdIdx: index('templates_app_id_idx').on(table.appId),
channelIdx: index('templates_channel_idx').on(table.channel),
})
);
export type Template = typeof templates.$inferSelect;
export type NewTemplate = typeof templates.$inferInsert;

View file

@ -0,0 +1,63 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
HttpCode,
HttpStatus,
Req,
} from '@nestjs/common';
import { DevicesService, RegisterDeviceDto } from './devices.service';
import { JwtAuthGuard, AuthenticatedRequest } from '../common/guards/jwt-auth.guard';
import { Device } from '../db/schema';
import { IsString, IsOptional } from 'class-validator';
class RegisterDeviceRequestDto {
@IsString()
pushToken!: string;
@IsString()
@IsOptional()
tokenType?: string;
@IsString()
platform!: string;
@IsString()
@IsOptional()
deviceName?: string;
@IsString()
@IsOptional()
appId?: string;
}
@Controller('devices')
@UseGuards(JwtAuthGuard)
export class DevicesController {
constructor(private readonly devicesService: DevicesService) {}
@Get()
async listDevices(@Req() req: AuthenticatedRequest): Promise<{ devices: Device[] }> {
const devicesList = await this.devicesService.getByUserId(req.user.userId);
return { devices: devicesList };
}
@Post('register')
async register(
@Req() req: AuthenticatedRequest,
@Body() dto: RegisterDeviceRequestDto
): Promise<{ device: Device }> {
const device = await this.devicesService.register(req.user.userId, dto);
return { device };
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async unregister(@Req() req: AuthenticatedRequest, @Param('id') id: string): Promise<void> {
await this.devicesService.unregister(req.user.userId, id);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DevicesService } from './devices.service';
import { DevicesController } from './devices.controller';
@Module({
providers: [DevicesService],
controllers: [DevicesController],
exports: [DevicesService],
})
export class DevicesModule {}

View file

@ -0,0 +1,141 @@
import { Injectable, Logger, Inject, NotFoundException, ConflictException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { devices, type Device, type NewDevice } from '../db/schema';
export interface RegisterDeviceDto {
pushToken: string;
tokenType?: string;
platform: string;
deviceName?: string;
appId?: string;
}
@Injectable()
export class DevicesService {
private readonly logger = new Logger(DevicesService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: any) {}
async register(userId: string, dto: RegisterDeviceDto): Promise<Device> {
this.logger.debug(`Registering device for user ${userId}`);
// Check if token already exists
const [existing] = await this.db
.select()
.from(devices)
.where(eq(devices.pushToken, dto.pushToken))
.limit(1);
if (existing) {
// If same user, just update
if (existing.userId === userId) {
const [updated] = await this.db
.update(devices)
.set({
platform: dto.platform,
deviceName: dto.deviceName,
appId: dto.appId,
isActive: true,
lastSeenAt: new Date(),
updatedAt: new Date(),
})
.where(eq(devices.id, existing.id))
.returning();
this.logger.log(`Updated existing device ${existing.id} for user ${userId}`);
return updated;
} else {
// Token belongs to different user - transfer ownership
const [updated] = await this.db
.update(devices)
.set({
userId,
platform: dto.platform,
deviceName: dto.deviceName,
appId: dto.appId,
isActive: true,
lastSeenAt: new Date(),
updatedAt: new Date(),
})
.where(eq(devices.id, existing.id))
.returning();
this.logger.log(`Transferred device ${existing.id} to user ${userId}`);
return updated;
}
}
// Create new device
const [device] = await this.db
.insert(devices)
.values({
userId,
pushToken: dto.pushToken,
tokenType: dto.tokenType || 'expo',
platform: dto.platform,
deviceName: dto.deviceName,
appId: dto.appId,
isActive: true,
lastSeenAt: new Date(),
})
.returning();
this.logger.log(`Registered new device ${device.id} for user ${userId}`);
return device;
}
async unregister(userId: string, deviceId: string): Promise<void> {
const [device] = await this.db
.select()
.from(devices)
.where(and(eq(devices.id, deviceId), eq(devices.userId, userId)))
.limit(1);
if (!device) {
throw new NotFoundException(`Device ${deviceId} not found`);
}
await this.db.delete(devices).where(eq(devices.id, deviceId));
this.logger.log(`Unregistered device ${deviceId} for user ${userId}`);
}
async getByUserId(userId: string): Promise<Device[]> {
return this.db.select().from(devices).where(eq(devices.userId, userId));
}
async getActiveDevicesByUser(userId: string): Promise<Device[]> {
return this.db
.select()
.from(devices)
.where(and(eq(devices.userId, userId), eq(devices.isActive, true)));
}
async getById(id: string): Promise<Device | null> {
const [device] = await this.db.select().from(devices).where(eq(devices.id, id)).limit(1);
return device || null;
}
async deactivate(deviceId: string): Promise<void> {
await this.db
.update(devices)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(devices.id, deviceId));
}
async updateLastSeen(deviceId: string): Promise<void> {
await this.db
.update(devices)
.set({ lastSeenAt: new Date(), updatedAt: new Date() })
.where(eq(devices.id, deviceId));
}
async deactivateByToken(pushToken: string): Promise<void> {
await this.db
.update(devices)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(devices.pushToken, pushToken));
this.logger.debug(`Deactivated device with token ${pushToken.substring(0, 20)}...`);
}
}

View file

@ -0,0 +1,46 @@
import { Controller, Get, Inject } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DATABASE_CONNECTION } from '../db/database.module';
import { sql } from 'drizzle-orm';
interface HealthStatus {
status: 'healthy' | 'unhealthy';
version: string;
timestamp: string;
services: {
database: boolean;
redis: boolean;
};
}
@Controller()
export class HealthController {
constructor(
private readonly configService: ConfigService,
@Inject(DATABASE_CONNECTION) private readonly db: any
) {}
@Get('/health')
async getHealth(): Promise<HealthStatus> {
const dbHealthy = await this.checkDatabase();
return {
status: dbHealthy ? 'healthy' : 'unhealthy',
version: '1.0.0',
timestamp: new Date().toISOString(),
services: {
database: dbHealthy,
redis: true, // BullMQ manages Redis connection
},
};
}
private async checkDatabase(): Promise<boolean> {
try {
await this.db.execute(sql`SELECT 1`);
return true;
} catch {
return false;
}
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,42 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('port', 3040);
// Global prefix
app.setGlobalPrefix('api/v1');
// CORS
app.enableCors({
origin: configService.get<string[]>('cors.origins', ['http://localhost:*']),
credentials: true,
});
// Global pipes
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
// Global filters
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(port);
logger.log(`Mana Notify Service running on port ${port}`);
logger.log(`Health check: http://localhost:${port}/health`);
logger.log(`Metrics: http://localhost:${port}/metrics`);
}
bootstrap();

View file

@ -0,0 +1,13 @@
import { Controller, Get, Header } from '@nestjs/common';
import { MetricsService } from './metrics.service';
@Controller()
export class MetricsController {
constructor(private readonly metricsService: MetricsService) {}
@Get('/metrics')
@Header('Content-Type', 'text/plain')
async getMetrics(): Promise<string> {
return this.metricsService.getMetrics();
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { MetricsService } from './metrics.service';
import { MetricsController } from './metrics.controller';
@Module({
providers: [MetricsService],
controllers: [MetricsController],
exports: [MetricsService],
})
export class MetricsModule {}

View file

@ -0,0 +1,138 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Counter, Histogram, Registry, collectDefaultMetrics } from 'prom-client';
@Injectable()
export class MetricsService implements OnModuleInit {
private readonly registry: Registry;
// Notification counters
private readonly notificationsSent: Counter;
private readonly notificationsFailed: Counter;
// Channel-specific counters
private readonly emailsSent: Counter;
private readonly pushNotificationsSent: Counter;
private readonly matrixMessagesSent: Counter;
private readonly webhooksSent: Counter;
// Latency histograms
private readonly notificationLatency: Histogram;
private readonly emailLatency: Histogram;
private readonly pushLatency: Histogram;
constructor() {
this.registry = new Registry();
// Total notifications
this.notificationsSent = new Counter({
name: 'mana_notify_notifications_sent_total',
help: 'Total number of notifications sent successfully',
labelNames: ['channel', 'app_id'],
registers: [this.registry],
});
this.notificationsFailed = new Counter({
name: 'mana_notify_notifications_failed_total',
help: 'Total number of notifications that failed to send',
labelNames: ['channel', 'app_id', 'error_type'],
registers: [this.registry],
});
// Channel-specific
this.emailsSent = new Counter({
name: 'mana_notify_emails_sent_total',
help: 'Total number of emails sent',
labelNames: ['template', 'status'],
registers: [this.registry],
});
this.pushNotificationsSent = new Counter({
name: 'mana_notify_push_sent_total',
help: 'Total number of push notifications sent',
labelNames: ['platform', 'status'],
registers: [this.registry],
});
this.matrixMessagesSent = new Counter({
name: 'mana_notify_matrix_sent_total',
help: 'Total number of Matrix messages sent',
labelNames: ['status'],
registers: [this.registry],
});
this.webhooksSent = new Counter({
name: 'mana_notify_webhooks_sent_total',
help: 'Total number of webhooks sent',
labelNames: ['status'],
registers: [this.registry],
});
// Latency
this.notificationLatency = new Histogram({
name: 'mana_notify_notification_latency_seconds',
help: 'Notification processing latency in seconds',
labelNames: ['channel'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
registers: [this.registry],
});
this.emailLatency = new Histogram({
name: 'mana_notify_email_latency_seconds',
help: 'Email sending latency in seconds',
buckets: [0.1, 0.5, 1, 2, 5, 10],
registers: [this.registry],
});
this.pushLatency = new Histogram({
name: 'mana_notify_push_latency_seconds',
help: 'Push notification sending latency in seconds',
buckets: [0.01, 0.05, 0.1, 0.5, 1],
registers: [this.registry],
});
}
onModuleInit() {
collectDefaultMetrics({ register: this.registry });
}
// Recording methods
recordNotificationSent(channel: string, appId: string) {
this.notificationsSent.inc({ channel, app_id: appId });
}
recordNotificationFailed(channel: string, appId: string, errorType: string) {
this.notificationsFailed.inc({ channel, app_id: appId, error_type: errorType });
}
recordEmailSent(template: string, success: boolean) {
this.emailsSent.inc({ template, status: success ? 'success' : 'failure' });
}
recordPushSent(platform: string, success: boolean) {
this.pushNotificationsSent.inc({ platform, status: success ? 'success' : 'failure' });
}
recordMatrixSent(success: boolean) {
this.matrixMessagesSent.inc({ status: success ? 'success' : 'failure' });
}
recordWebhookSent(success: boolean) {
this.webhooksSent.inc({ status: success ? 'success' : 'failure' });
}
recordNotificationLatency(channel: string, durationSeconds: number) {
this.notificationLatency.observe({ channel }, durationSeconds);
}
recordEmailLatency(durationSeconds: number) {
this.emailLatency.observe(durationSeconds);
}
recordPushLatency(durationSeconds: number) {
this.pushLatency.observe(durationSeconds);
}
async getMetrics(): Promise<string> {
return this.registry.metrics();
}
}

View file

@ -0,0 +1 @@
export * from './send-notification.dto';

View file

@ -0,0 +1,161 @@
import {
IsString,
IsOptional,
IsEnum,
IsObject,
IsArray,
IsDateString,
ValidateNested,
IsBoolean,
} from 'class-validator';
import { Type } from 'class-transformer';
export enum NotificationChannel {
EMAIL = 'email',
PUSH = 'push',
MATRIX = 'matrix',
WEBHOOK = 'webhook',
}
export enum NotificationPriority {
LOW = 'low',
NORMAL = 'normal',
HIGH = 'high',
CRITICAL = 'critical',
}
export class EmailData {
@IsString()
from?: string;
@IsString()
@IsOptional()
replyTo?: string;
}
export class PushData {
@IsString()
@IsOptional()
sound?: 'default' | null;
@IsOptional()
badge?: number;
@IsString()
@IsOptional()
channelId?: string;
}
export class WebhookData {
@IsString()
@IsOptional()
method?: 'POST' | 'PUT';
@IsObject()
@IsOptional()
headers?: Record<string, string>;
@IsOptional()
timeout?: number;
}
export class MatrixData {
@IsString()
@IsOptional()
msgtype?: 'text' | 'notice';
@IsString()
@IsOptional()
formattedBody?: string;
}
export class SendNotificationDto {
@IsEnum(NotificationChannel)
channel!: NotificationChannel;
@IsString()
appId!: string;
@IsString()
@IsOptional()
userId?: string;
@IsString()
@IsOptional()
recipient?: string; // Email address, push token, room ID, or webhook URL
@IsArray()
@IsString({ each: true })
@IsOptional()
recipients?: string[]; // For batch sending to multiple recipients
@IsString()
@IsOptional()
template?: string; // Template slug
@IsString()
@IsOptional()
subject?: string; // Override template subject
@IsString()
@IsOptional()
body?: string; // Override template body or custom content
@IsObject()
@IsOptional()
data?: Record<string, unknown>; // Template variables or push data payload
@IsEnum(NotificationPriority)
@IsOptional()
priority?: NotificationPriority;
@IsString()
@IsOptional()
externalId?: string; // For idempotency
// Channel-specific options
@ValidateNested()
@Type(() => EmailData)
@IsOptional()
emailOptions?: EmailData;
@ValidateNested()
@Type(() => PushData)
@IsOptional()
pushOptions?: PushData;
@ValidateNested()
@Type(() => WebhookData)
@IsOptional()
webhookOptions?: WebhookData;
@ValidateNested()
@Type(() => MatrixData)
@IsOptional()
matrixOptions?: MatrixData;
}
export class ScheduleNotificationDto extends SendNotificationDto {
@IsDateString()
scheduledFor!: string;
}
export class BatchNotificationDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => SendNotificationDto)
notifications!: SendNotificationDto[];
}
export class NotificationResponse {
id!: string;
status!: string;
channel!: string;
createdAt!: Date;
}
export class BatchNotificationResponse {
results!: NotificationResponse[];
succeeded!: number;
failed!: number;
}

View file

@ -0,0 +1,68 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { ServiceAuthGuard } from '../common/guards/service-auth.guard';
import {
SendNotificationDto,
ScheduleNotificationDto,
BatchNotificationDto,
NotificationResponse,
BatchNotificationResponse,
} from './dto/send-notification.dto';
import { Notification } from '../db/schema';
@Controller('notifications')
@UseGuards(ServiceAuthGuard)
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Post('send')
async send(@Body() dto: SendNotificationDto): Promise<{ notification: NotificationResponse }> {
const notification = await this.notificationsService.send(dto);
return { notification };
}
@Post('schedule')
async schedule(
@Body() dto: ScheduleNotificationDto
): Promise<{ notification: NotificationResponse }> {
const notification = await this.notificationsService.schedule(dto);
return { notification };
}
@Post('batch')
async batch(@Body() dto: BatchNotificationDto): Promise<BatchNotificationResponse> {
const results = await this.notificationsService.sendBatch(dto.notifications);
const succeeded = results.filter((r) => r.status !== 'failed').length;
const failed = results.length - succeeded;
return {
results,
succeeded,
failed,
};
}
@Get(':id')
async getById(@Param('id') id: string): Promise<{ notification: Notification | null }> {
const notification = await this.notificationsService.getById(id);
return { notification };
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
async cancel(@Param('id') id: string): Promise<{ notification: Notification }> {
const notification = await this.notificationsService.cancel(id);
return { notification };
}
}

View file

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { NotificationsController } from './notifications.controller';
import { TemplatesModule } from '../templates/templates.module';
import { QueueModule } from '../queue/queue.module';
import { DevicesModule } from '../devices/devices.module';
import { PreferencesModule } from '../preferences/preferences.module';
@Module({
imports: [TemplatesModule, QueueModule, DevicesModule, PreferencesModule],
providers: [NotificationsService],
controllers: [NotificationsController],
exports: [NotificationsService],
})
export class NotificationsModule {}

View file

@ -0,0 +1,403 @@
import { Injectable, Logger, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { eq, and, desc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { notifications, type Notification, type NewNotification } from '../db/schema';
import { EMAIL_QUEUE, PUSH_QUEUE, MATRIX_QUEUE, WEBHOOK_QUEUE } from '../queue/queue.module';
import { TemplatesService } from '../templates/templates.service';
import { DevicesService } from '../devices/devices.service';
import { PreferencesService } from '../preferences/preferences.service';
import {
SendNotificationDto,
ScheduleNotificationDto,
NotificationResponse,
NotificationChannel,
} from './dto/send-notification.dto';
import { EmailJob } from '../queue/processors/email.processor';
import { PushJob } from '../queue/processors/push.processor';
import { MatrixJob } from '../queue/processors/matrix.processor';
import { WebhookJob } from '../queue/processors/webhook.processor';
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: any,
@InjectQueue(EMAIL_QUEUE) private readonly emailQueue: Queue,
@InjectQueue(PUSH_QUEUE) private readonly pushQueue: Queue,
@InjectQueue(MATRIX_QUEUE) private readonly matrixQueue: Queue,
@InjectQueue(WEBHOOK_QUEUE) private readonly webhookQueue: Queue,
private readonly templatesService: TemplatesService,
private readonly devicesService: DevicesService,
private readonly preferencesService: PreferencesService
) {}
async send(dto: SendNotificationDto): Promise<NotificationResponse> {
// Check for idempotency
if (dto.externalId) {
const existing = await this.findByExternalId(dto.externalId);
if (existing) {
return this.toResponse(existing);
}
}
// Check user preferences if userId is provided
if (dto.userId) {
const allowed = await this.checkPreferences(dto.userId, dto.channel, dto.appId);
if (!allowed) {
this.logger.debug(`Notification blocked by user preferences: ${dto.userId}`);
// Still create the notification but mark as cancelled
const notification = await this.createNotification({
...this.dtoToNotification(dto),
status: 'cancelled',
errorMessage: 'Blocked by user preferences',
});
return this.toResponse(notification);
}
}
// Render template if specified
let subject = dto.subject;
let body = dto.body;
if (dto.template) {
const rendered = await this.templatesService.renderBySlug(dto.template, dto.data || {});
if (rendered) {
subject = subject || rendered.subject;
body = body || rendered.body;
} else {
this.logger.warn(`Template not found: ${dto.template}`);
}
}
if (!body && dto.channel !== NotificationChannel.WEBHOOK) {
throw new BadRequestException('Either template or body must be provided');
}
// Create notification record
const notification = await this.createNotification({
...this.dtoToNotification(dto),
subject,
body,
});
// Queue the notification based on channel
await this.queueNotification(notification, dto);
return this.toResponse(notification);
}
async schedule(dto: ScheduleNotificationDto): Promise<NotificationResponse> {
const scheduledFor = new Date(dto.scheduledFor);
if (scheduledFor <= new Date()) {
throw new BadRequestException('scheduledFor must be in the future');
}
// Render template if specified
let subject = dto.subject;
let body = dto.body;
if (dto.template) {
const rendered = await this.templatesService.renderBySlug(dto.template, dto.data || {});
if (rendered) {
subject = subject || rendered.subject;
body = body || rendered.body;
}
}
// Create notification record with scheduled status
const notification = await this.createNotification({
...this.dtoToNotification(dto),
subject,
body,
scheduledFor,
status: 'pending',
});
// Queue with delay
const delay = scheduledFor.getTime() - Date.now();
await this.queueNotification(notification, dto, delay);
return this.toResponse(notification);
}
async sendBatch(dtos: SendNotificationDto[]): Promise<NotificationResponse[]> {
const results: NotificationResponse[] = [];
for (const dto of dtos) {
try {
const result = await this.send(dto);
results.push(result);
} catch (error) {
this.logger.error(`Batch notification failed: ${error}`);
// Create a failed notification record
const notification = await this.createNotification({
...this.dtoToNotification(dto),
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
});
results.push(this.toResponse(notification));
}
}
return results;
}
async getById(id: string): Promise<Notification | null> {
const [notification] = await this.db
.select()
.from(notifications)
.where(eq(notifications.id, id))
.limit(1);
return notification || null;
}
async cancel(id: string): Promise<Notification> {
const notification = await this.getById(id);
if (!notification) {
throw new NotFoundException(`Notification ${id} not found`);
}
if (notification.status !== 'pending') {
throw new BadRequestException('Can only cancel pending notifications');
}
const [updated] = await this.db
.update(notifications)
.set({ status: 'cancelled', updatedAt: new Date() })
.where(eq(notifications.id, id))
.returning();
return updated;
}
async listByUser(userId: string, limit: number = 50): Promise<Notification[]> {
return this.db
.select()
.from(notifications)
.where(eq(notifications.userId, userId))
.orderBy(desc(notifications.createdAt))
.limit(limit);
}
private async createNotification(data: NewNotification): Promise<Notification> {
const [notification] = await this.db.insert(notifications).values(data).returning();
return notification;
}
private async findByExternalId(externalId: string): Promise<Notification | null> {
const [notification] = await this.db
.select()
.from(notifications)
.where(eq(notifications.externalId, externalId))
.limit(1);
return notification || null;
}
private async checkPreferences(
userId: string,
channel: NotificationChannel,
appId: string
): Promise<boolean> {
const prefs = await this.preferencesService.getByUserId(userId);
if (!prefs) {
return true; // No preferences = allow all
}
// Check global channel settings
if (channel === NotificationChannel.EMAIL && !prefs.emailEnabled) {
return false;
}
if (channel === NotificationChannel.PUSH && !prefs.pushEnabled) {
return false;
}
// Check quiet hours
if (prefs.quietHoursEnabled && this.isInQuietHours(prefs)) {
return false;
}
return true;
}
private isInQuietHours(prefs: {
quietHoursStart?: string | null;
quietHoursEnd?: string | null;
timezone?: string;
}): boolean {
if (!prefs.quietHoursStart || !prefs.quietHoursEnd) {
return false;
}
const now = new Date();
const [startHour, startMin] = prefs.quietHoursStart.split(':').map(Number);
const [endHour, endMin] = prefs.quietHoursEnd.split(':').map(Number);
const currentHour = now.getHours();
const currentMin = now.getMinutes();
const currentTime = currentHour * 60 + currentMin;
const startTime = startHour * 60 + startMin;
const endTime = endHour * 60 + endMin;
if (startTime <= endTime) {
return currentTime >= startTime && currentTime < endTime;
} else {
// Quiet hours span midnight
return currentTime >= startTime || currentTime < endTime;
}
}
private dtoToNotification(dto: SendNotificationDto): NewNotification {
return {
userId: dto.userId,
appId: dto.appId,
channel: dto.channel,
templateId: dto.template,
subject: dto.subject,
body: dto.body,
data: dto.data,
recipient: dto.recipient,
externalId: dto.externalId,
priority: dto.priority || 'normal',
status: 'pending',
};
}
private async queueNotification(
notification: Notification,
dto: SendNotificationDto,
delay?: number
): Promise<void> {
const jobOptions = delay ? { delay } : undefined;
switch (dto.channel) {
case NotificationChannel.EMAIL:
await this.queueEmail(notification, dto, jobOptions);
break;
case NotificationChannel.PUSH:
await this.queuePush(notification, dto, jobOptions);
break;
case NotificationChannel.MATRIX:
await this.queueMatrix(notification, dto, jobOptions);
break;
case NotificationChannel.WEBHOOK:
await this.queueWebhook(notification, dto, jobOptions);
break;
}
}
private async queueEmail(
notification: Notification,
dto: SendNotificationDto,
jobOptions?: { delay: number }
): Promise<void> {
if (!dto.recipient) {
throw new BadRequestException('Email recipient is required');
}
const job: EmailJob = {
notificationId: notification.id,
to: dto.recipient,
subject: notification.subject || '',
html: notification.body || '',
from: dto.emailOptions?.from,
template: dto.template,
appId: dto.appId,
};
await this.emailQueue.add('send', job, jobOptions);
}
private async queuePush(
notification: Notification,
dto: SendNotificationDto,
jobOptions?: { delay: number }
): Promise<void> {
let tokens: string[] = [];
if (dto.recipients?.length) {
tokens = dto.recipients;
} else if (dto.recipient) {
tokens = [dto.recipient];
} else if (dto.userId) {
// Get all device tokens for user
const devices = await this.devicesService.getActiveDevicesByUser(dto.userId);
tokens = devices.map((d) => d.pushToken);
}
if (tokens.length === 0) {
throw new BadRequestException('No push tokens found');
}
const job: PushJob = {
notificationId: notification.id,
tokens,
title: notification.subject || '',
body: notification.body || '',
data: dto.data,
sound: dto.pushOptions?.sound,
badge: dto.pushOptions?.badge,
platform: 'mixed',
appId: dto.appId,
};
await this.pushQueue.add('send', job, jobOptions);
}
private async queueMatrix(
notification: Notification,
dto: SendNotificationDto,
jobOptions?: { delay: number }
): Promise<void> {
if (!dto.recipient) {
throw new BadRequestException('Matrix room ID is required');
}
const job: MatrixJob = {
notificationId: notification.id,
roomId: dto.recipient,
body: notification.body || '',
formattedBody: dto.matrixOptions?.formattedBody,
msgtype: dto.matrixOptions?.msgtype,
appId: dto.appId,
};
await this.matrixQueue.add('send', job, jobOptions);
}
private async queueWebhook(
notification: Notification,
dto: SendNotificationDto,
jobOptions?: { delay: number }
): Promise<void> {
if (!dto.recipient) {
throw new BadRequestException('Webhook URL is required');
}
const job: WebhookJob = {
notificationId: notification.id,
url: dto.recipient,
method: dto.webhookOptions?.method || 'POST',
headers: dto.webhookOptions?.headers,
body: dto.data || {},
timeout: dto.webhookOptions?.timeout,
appId: dto.appId,
};
await this.webhookQueue.add('send', job, jobOptions);
}
private toResponse(notification: Notification): NotificationResponse {
return {
id: notification.id,
status: notification.status,
channel: notification.channel,
createdAt: notification.createdAt,
};
}
}

View file

@ -0,0 +1,62 @@
import { Controller, Get, Put, Body, UseGuards, Req } from '@nestjs/common';
import { PreferencesService, UpdatePreferencesDto } from './preferences.service';
import { JwtAuthGuard, AuthenticatedRequest } from '../common/guards/jwt-auth.guard';
import { Preference } from '../db/schema';
import { IsBoolean, IsOptional, IsString, IsObject, Matches } from 'class-validator';
class UpdatePreferencesRequestDto {
@IsBoolean()
@IsOptional()
emailEnabled?: boolean;
@IsBoolean()
@IsOptional()
pushEnabled?: boolean;
@IsBoolean()
@IsOptional()
quietHoursEnabled?: boolean;
@IsString()
@IsOptional()
@Matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, {
message: 'quietHoursStart must be in HH:mm format',
})
quietHoursStart?: string;
@IsString()
@IsOptional()
@Matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, {
message: 'quietHoursEnd must be in HH:mm format',
})
quietHoursEnd?: string;
@IsString()
@IsOptional()
timezone?: string;
@IsObject()
@IsOptional()
categoryPreferences?: Record<string, Record<string, boolean>>;
}
@Controller('preferences')
@UseGuards(JwtAuthGuard)
export class PreferencesController {
constructor(private readonly preferencesService: PreferencesService) {}
@Get()
async getPreferences(@Req() req: AuthenticatedRequest): Promise<{ preferences: Preference }> {
const prefs = await this.preferencesService.getOrCreate(req.user.userId);
return { preferences: prefs };
}
@Put()
async updatePreferences(
@Req() req: AuthenticatedRequest,
@Body() dto: UpdatePreferencesRequestDto
): Promise<{ preferences: Preference }> {
const prefs = await this.preferencesService.update(req.user.userId, dto);
return { preferences: prefs };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PreferencesService } from './preferences.service';
import { PreferencesController } from './preferences.controller';
@Module({
providers: [PreferencesService],
controllers: [PreferencesController],
exports: [PreferencesService],
})
export class PreferencesModule {}

View file

@ -0,0 +1,123 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { preferences, type Preference, type NewPreference } from '../db/schema';
export interface UpdatePreferencesDto {
emailEnabled?: boolean;
pushEnabled?: boolean;
quietHoursEnabled?: boolean;
quietHoursStart?: string;
quietHoursEnd?: string;
timezone?: string;
categoryPreferences?: Record<string, Record<string, boolean>>;
}
@Injectable()
export class PreferencesService {
private readonly logger = new Logger(PreferencesService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: any) {}
async getByUserId(userId: string): Promise<Preference | null> {
const [pref] = await this.db
.select()
.from(preferences)
.where(eq(preferences.userId, userId))
.limit(1);
return pref || null;
}
async getOrCreate(userId: string): Promise<Preference> {
const existingPref = await this.getByUserId(userId);
if (existingPref) {
return existingPref;
}
const [newPref] = await this.db
.insert(preferences)
.values({
userId,
emailEnabled: true,
pushEnabled: true,
quietHoursEnabled: false,
timezone: 'Europe/Berlin',
})
.returning();
this.logger.log(`Created default preferences for user ${userId}`);
return newPref;
}
async update(userId: string, dto: UpdatePreferencesDto): Promise<Preference> {
// First ensure preferences exist
await this.getOrCreate(userId);
const updateData: Partial<NewPreference> = {};
if (dto.emailEnabled !== undefined) {
updateData.emailEnabled = dto.emailEnabled;
}
if (dto.pushEnabled !== undefined) {
updateData.pushEnabled = dto.pushEnabled;
}
if (dto.quietHoursEnabled !== undefined) {
updateData.quietHoursEnabled = dto.quietHoursEnabled;
}
if (dto.quietHoursStart !== undefined) {
updateData.quietHoursStart = dto.quietHoursStart;
}
if (dto.quietHoursEnd !== undefined) {
updateData.quietHoursEnd = dto.quietHoursEnd;
}
if (dto.timezone !== undefined) {
updateData.timezone = dto.timezone;
}
if (dto.categoryPreferences !== undefined) {
updateData.categoryPreferences = dto.categoryPreferences;
}
updateData.updatedAt = new Date();
const [updated] = await this.db
.update(preferences)
.set(updateData)
.where(eq(preferences.userId, userId))
.returning();
this.logger.log(`Updated preferences for user ${userId}`);
return updated;
}
async updateCategoryPreference(
userId: string,
appId: string,
category: string,
enabled: boolean
): Promise<Preference> {
const pref = await this.getOrCreate(userId);
const categoryPrefs = pref.categoryPreferences || {};
if (!categoryPrefs[appId]) {
categoryPrefs[appId] = {};
}
categoryPrefs[appId][category] = enabled;
return this.update(userId, { categoryPreferences: categoryPrefs });
}
isCategoryEnabled(pref: Preference, appId: string, category: string): boolean {
if (!pref.categoryPreferences) {
return true; // Default to enabled
}
const appPrefs = pref.categoryPreferences[appId];
if (!appPrefs) {
return true;
}
return appPrefs[category] !== false;
}
}

View file

@ -0,0 +1,125 @@
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger, Inject } from '@nestjs/common';
import { Job } from 'bullmq';
import { eq } from 'drizzle-orm';
import { EMAIL_QUEUE } from '../queue.module';
import { EmailService } from '../../channels/email/email.service';
import { MetricsService } from '../../metrics/metrics.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
import { notifications, deliveryLogs, type NewDeliveryLog } from '../../db/schema';
export interface EmailJob {
notificationId: string;
to: string;
subject: string;
html: string;
text?: string;
from?: string;
template?: string;
appId: string;
}
@Processor(EMAIL_QUEUE, {
concurrency: 5,
})
export class EmailProcessor extends WorkerHost {
private readonly logger = new Logger(EmailProcessor.name);
constructor(
private readonly emailService: EmailService,
private readonly metricsService: MetricsService,
@Inject(DATABASE_CONNECTION) private readonly db: any
) {
super();
}
async process(job: Job<EmailJob>): Promise<void> {
const { notificationId, to, subject, html, text, from, template, appId } = job.data;
const startTime = Date.now();
this.logger.debug(`Processing email job ${job.id} to ${to}`);
// Update notification status to processing
await this.updateNotificationStatus(notificationId, 'processing');
const result = await this.emailService.sendEmail({
to,
subject,
html,
text,
from,
});
const durationMs = Date.now() - startTime;
// Log the delivery attempt
await this.logDelivery({
notificationId,
attemptNumber: job.attemptsMade + 1,
channel: 'email',
success: result.success,
errorMessage: result.error,
providerId: result.messageId,
durationMs,
});
// Record metrics
this.metricsService.recordEmailSent(template || 'custom', result.success);
this.metricsService.recordEmailLatency(durationMs / 1000);
if (result.success) {
this.metricsService.recordNotificationSent('email', appId);
await this.updateNotificationStatus(notificationId, 'delivered', result.messageId);
this.logger.log(`Email sent successfully to ${to} in ${durationMs}ms`);
} else {
this.metricsService.recordNotificationFailed('email', appId, 'send_error');
// Only mark as failed if no more retries
if (job.attemptsMade >= (job.opts.attempts || 3) - 1) {
await this.updateNotificationStatus(notificationId, 'failed', undefined, result.error);
}
throw new Error(result.error || 'Failed to send email');
}
}
@OnWorkerEvent('failed')
onFailed(job: Job<EmailJob>, error: Error) {
this.logger.error(`Email job ${job.id} failed: ${error.message}`);
}
private async updateNotificationStatus(
notificationId: string,
status: string,
providerId?: string,
errorMessage?: string
): Promise<void> {
try {
const updateData: Record<string, unknown> = {
status,
updatedAt: new Date(),
};
if (status === 'delivered') {
updateData.deliveredAt = new Date();
}
if (errorMessage) {
updateData.errorMessage = errorMessage;
}
await this.db
.update(notifications)
.set(updateData)
.where(eq(notifications.id, notificationId));
} catch (error) {
this.logger.error(`Failed to update notification status: ${error}`);
}
}
private async logDelivery(log: Omit<NewDeliveryLog, 'id' | 'createdAt'>): Promise<void> {
try {
await this.db.insert(deliveryLogs).values(log);
} catch (error) {
this.logger.error(`Failed to log delivery: ${error}`);
}
}
}

View file

@ -0,0 +1,121 @@
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger, Inject } from '@nestjs/common';
import { Job } from 'bullmq';
import { eq } from 'drizzle-orm';
import { MATRIX_QUEUE } from '../queue.module';
import { MatrixService } from '../../channels/matrix/matrix.service';
import { MetricsService } from '../../metrics/metrics.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
import { notifications, deliveryLogs, type NewDeliveryLog } from '../../db/schema';
export interface MatrixJob {
notificationId: string;
roomId: string;
body: string;
formattedBody?: string;
msgtype?: 'text' | 'notice';
appId: string;
}
@Processor(MATRIX_QUEUE, {
concurrency: 5,
})
export class MatrixProcessor extends WorkerHost {
private readonly logger = new Logger(MatrixProcessor.name);
constructor(
private readonly matrixService: MatrixService,
private readonly metricsService: MetricsService,
@Inject(DATABASE_CONNECTION) private readonly db: any
) {
super();
}
async process(job: Job<MatrixJob>): Promise<void> {
const { notificationId, roomId, body, formattedBody, msgtype, appId } = job.data;
const startTime = Date.now();
this.logger.debug(`Processing Matrix job ${job.id} to room ${roomId}`);
// Update notification status to processing
await this.updateNotificationStatus(notificationId, 'processing');
const result = await this.matrixService.sendMessage({
roomId,
body,
formattedBody,
msgtype,
});
const durationMs = Date.now() - startTime;
// Log the delivery attempt
await this.logDelivery({
notificationId,
attemptNumber: job.attemptsMade + 1,
channel: 'matrix',
success: result.success,
errorMessage: result.error,
providerId: result.eventId,
durationMs,
});
this.metricsService.recordMatrixSent(result.success);
this.metricsService.recordNotificationLatency('matrix', durationMs / 1000);
if (result.success) {
this.metricsService.recordNotificationSent('matrix', appId);
await this.updateNotificationStatus(notificationId, 'delivered', result.eventId);
this.logger.log(`Matrix message sent to ${roomId} in ${durationMs}ms`);
} else {
this.metricsService.recordNotificationFailed('matrix', appId, 'send_error');
// Only mark as failed if no more retries
if (job.attemptsMade >= (job.opts.attempts || 3) - 1) {
await this.updateNotificationStatus(notificationId, 'failed', undefined, result.error);
}
throw new Error(result.error || 'Failed to send Matrix message');
}
}
@OnWorkerEvent('failed')
onFailed(job: Job<MatrixJob>, error: Error) {
this.logger.error(`Matrix job ${job.id} failed: ${error.message}`);
}
private async updateNotificationStatus(
notificationId: string,
status: string,
providerId?: string,
errorMessage?: string
): Promise<void> {
try {
const updateData: Record<string, unknown> = {
status,
updatedAt: new Date(),
};
if (status === 'delivered') {
updateData.deliveredAt = new Date();
}
if (errorMessage) {
updateData.errorMessage = errorMessage;
}
await this.db
.update(notifications)
.set(updateData)
.where(eq(notifications.id, notificationId));
} catch (error) {
this.logger.error(`Failed to update notification status: ${error}`);
}
}
private async logDelivery(log: Omit<NewDeliveryLog, 'id' | 'createdAt'>): Promise<void> {
try {
await this.db.insert(deliveryLogs).values(log);
} catch (error) {
this.logger.error(`Failed to log delivery: ${error}`);
}
}
}

View file

@ -0,0 +1,154 @@
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger, Inject } from '@nestjs/common';
import { Job } from 'bullmq';
import { eq } from 'drizzle-orm';
import { PUSH_QUEUE } from '../queue.module';
import { PushService } from '../../channels/push/push.service';
import { MetricsService } from '../../metrics/metrics.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
import { notifications, deliveryLogs, type NewDeliveryLog } from '../../db/schema';
export interface PushJob {
notificationId: string;
tokens: string[];
title: string;
body: string;
data?: Record<string, unknown>;
sound?: 'default' | null;
badge?: number;
platform: string;
appId: string;
}
@Processor(PUSH_QUEUE, {
concurrency: 10,
})
export class PushProcessor extends WorkerHost {
private readonly logger = new Logger(PushProcessor.name);
constructor(
private readonly pushService: PushService,
private readonly metricsService: MetricsService,
@Inject(DATABASE_CONNECTION) private readonly db: any
) {
super();
}
async process(job: Job<PushJob>): Promise<void> {
const { notificationId, tokens, title, body, data, sound, badge, platform, appId } = job.data;
const startTime = Date.now();
this.logger.debug(`Processing push job ${job.id} to ${tokens.length} tokens`);
// Update notification status to processing
await this.updateNotificationStatus(notificationId, 'processing');
const results = await this.pushService.sendToTokens(tokens, {
title,
body,
data,
sound,
badge,
});
const durationMs = Date.now() - startTime;
// Count successes and failures
let successCount = 0;
let failCount = 0;
const ticketIds: string[] = [];
for (const [token, result] of results) {
if (result.success) {
successCount++;
if (result.ticketId) {
ticketIds.push(result.ticketId);
}
} else {
failCount++;
}
// Record per-token metrics
this.metricsService.recordPushSent(platform, result.success);
}
// Log the delivery attempt
await this.logDelivery({
notificationId,
attemptNumber: job.attemptsMade + 1,
channel: 'push',
success: successCount > 0,
errorMessage: failCount > 0 ? `${failCount}/${tokens.length} tokens failed` : undefined,
providerId: ticketIds.join(','),
durationMs,
});
this.metricsService.recordPushLatency(durationMs / 1000);
if (successCount > 0) {
this.metricsService.recordNotificationSent('push', appId);
await this.updateNotificationStatus(
notificationId,
failCount === 0 ? 'delivered' : 'delivered', // Partial success still counts as delivered
ticketIds.join(',')
);
this.logger.log(
`Push notification sent: ${successCount}/${tokens.length} successful in ${durationMs}ms`
);
} else {
this.metricsService.recordNotificationFailed('push', appId, 'send_error');
// Only mark as failed if no more retries
if (job.attemptsMade >= (job.opts.attempts || 3) - 1) {
await this.updateNotificationStatus(
notificationId,
'failed',
undefined,
'All tokens failed'
);
}
throw new Error('All push tokens failed');
}
}
@OnWorkerEvent('failed')
onFailed(job: Job<PushJob>, error: Error) {
this.logger.error(`Push job ${job.id} failed: ${error.message}`);
}
private async updateNotificationStatus(
notificationId: string,
status: string,
providerId?: string,
errorMessage?: string
): Promise<void> {
try {
const updateData: Record<string, unknown> = {
status,
updatedAt: new Date(),
};
if (status === 'delivered') {
updateData.deliveredAt = new Date();
}
if (errorMessage) {
updateData.errorMessage = errorMessage;
}
await this.db
.update(notifications)
.set(updateData)
.where(eq(notifications.id, notificationId));
} catch (error) {
this.logger.error(`Failed to update notification status: ${error}`);
}
}
private async logDelivery(log: Omit<NewDeliveryLog, 'id' | 'createdAt'>): Promise<void> {
try {
await this.db.insert(deliveryLogs).values(log);
} catch (error) {
this.logger.error(`Failed to log delivery: ${error}`);
}
}
}

View file

@ -0,0 +1,123 @@
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger, Inject } from '@nestjs/common';
import { Job } from 'bullmq';
import { eq } from 'drizzle-orm';
import { WEBHOOK_QUEUE } from '../queue.module';
import { WebhookService } from '../../channels/webhook/webhook.service';
import { MetricsService } from '../../metrics/metrics.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
import { notifications, deliveryLogs, type NewDeliveryLog } from '../../db/schema';
export interface WebhookJob {
notificationId: string;
url: string;
method?: 'POST' | 'PUT';
headers?: Record<string, string>;
body: Record<string, unknown>;
timeout?: number;
appId: string;
}
@Processor(WEBHOOK_QUEUE, {
concurrency: 10,
})
export class WebhookProcessor extends WorkerHost {
private readonly logger = new Logger(WebhookProcessor.name);
constructor(
private readonly webhookService: WebhookService,
private readonly metricsService: MetricsService,
@Inject(DATABASE_CONNECTION) private readonly db: any
) {
super();
}
async process(job: Job<WebhookJob>): Promise<void> {
const { notificationId, url, method, headers, body, timeout, appId } = job.data;
const startTime = Date.now();
this.logger.debug(`Processing webhook job ${job.id} to ${url}`);
// Update notification status to processing
await this.updateNotificationStatus(notificationId, 'processing');
const result = await this.webhookService.send({
url,
method,
headers,
body,
timeout,
});
const durationMs = Date.now() - startTime;
// Log the delivery attempt
await this.logDelivery({
notificationId,
attemptNumber: job.attemptsMade + 1,
channel: 'webhook',
success: result.success,
statusCode: result.statusCode,
errorMessage: result.error,
durationMs: result.durationMs,
});
this.metricsService.recordWebhookSent(result.success);
this.metricsService.recordNotificationLatency('webhook', durationMs / 1000);
if (result.success) {
this.metricsService.recordNotificationSent('webhook', appId);
await this.updateNotificationStatus(notificationId, 'delivered');
this.logger.log(`Webhook sent to ${url} in ${durationMs}ms`);
} else {
this.metricsService.recordNotificationFailed('webhook', appId, 'send_error');
// Only mark as failed if no more retries
if (job.attemptsMade >= (job.opts.attempts || 5) - 1) {
await this.updateNotificationStatus(notificationId, 'failed', undefined, result.error);
}
throw new Error(result.error || 'Failed to send webhook');
}
}
@OnWorkerEvent('failed')
onFailed(job: Job<WebhookJob>, error: Error) {
this.logger.error(`Webhook job ${job.id} failed: ${error.message}`);
}
private async updateNotificationStatus(
notificationId: string,
status: string,
providerId?: string,
errorMessage?: string
): Promise<void> {
try {
const updateData: Record<string, unknown> = {
status,
updatedAt: new Date(),
};
if (status === 'delivered') {
updateData.deliveredAt = new Date();
}
if (errorMessage) {
updateData.errorMessage = errorMessage;
}
await this.db
.update(notifications)
.set(updateData)
.where(eq(notifications.id, notificationId));
} catch (error) {
this.logger.error(`Failed to update notification status: ${error}`);
}
}
private async logDelivery(log: Omit<NewDeliveryLog, 'id' | 'createdAt'>): Promise<void> {
try {
await this.db.insert(deliveryLogs).values(log);
} catch (error) {
this.logger.error(`Failed to log delivery: ${error}`);
}
}
}

View file

@ -0,0 +1,73 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { EmailProcessor } from './processors/email.processor';
import { PushProcessor } from './processors/push.processor';
import { MatrixProcessor } from './processors/matrix.processor';
import { WebhookProcessor } from './processors/webhook.processor';
import { ChannelsModule } from '../channels/channels.module';
import { MetricsModule } from '../metrics/metrics.module';
export const EMAIL_QUEUE = 'email';
export const PUSH_QUEUE = 'push';
export const MATRIX_QUEUE = 'matrix';
export const WEBHOOK_QUEUE = 'webhook';
@Module({
imports: [
BullModule.registerQueue(
{
name: EMAIL_QUEUE,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: 100,
removeOnFail: 1000,
},
},
{
name: PUSH_QUEUE,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
removeOnComplete: 100,
removeOnFail: 1000,
},
},
{
name: MATRIX_QUEUE,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: 100,
removeOnFail: 500,
},
},
{
name: WEBHOOK_QUEUE,
defaultJobOptions: {
attempts: 5,
backoff: {
type: 'exponential',
delay: 3000,
},
removeOnComplete: 100,
removeOnFail: 1000,
},
}
),
ChannelsModule,
MetricsModule,
],
providers: [EmailProcessor, PushProcessor, MatrixProcessor, WebhookProcessor],
exports: [BullModule],
})
export class QueueModule {}

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #2563eb; margin: 0;">ManaCore</h1>
</div>
<p>Hallo {{userName}},</p>
<p>Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt. Klicke auf den Button unten, um ein neues Passwort zu erstellen:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{resetUrl}}" style="background-color: #2563eb; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 500; display: inline-block;">Passwort zurücksetzen</a>
</div>
<p style="color: #666; font-size: 14px;">Dieser Link ist 1 Stunde gültig. Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px; text-align: center;">
Diese E-Mail wurde automatisch von ManaCore gesendet.<br>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
<a href="{{resetUrl}}" style="color: #2563eb; word-break: break-all;">{{resetUrl}}</a>
</p>
</body>
</html>

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #2563eb; margin: 0;">ManaCore</h1>
</div>
<div style="background-color: #f3f4f6; border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<h2 style="color: #1f2937; margin: 0 0 10px 0;">{{eventTitle}}</h2>
<p style="margin: 5px 0; color: #4b5563;">
<strong>Wann:</strong> {{eventTime}}
</p>
{{#if eventLocation}}
<p style="margin: 5px 0; color: #4b5563;">
<strong>Wo:</strong> {{eventLocation}}
</p>
{{/if}}
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{eventUrl}}" style="background-color: #2563eb; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 500; display: inline-block;">Termin anzeigen</a>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px; text-align: center;">
Diese Erinnerung wurde automatisch von ManaCore gesendet.<br>
Du kannst Erinnerungen in den Kalender-Einstellungen verwalten.
</p>
</body>
</html>

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #2563eb; margin: 0;">ManaCore</h1>
</div>
<p>Hallo {{userName}},</p>
<p>Willkommen bei ManaCore! Bitte bestätige deine E-Mail-Adresse, um deinen Account zu aktivieren:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{verificationUrl}}" style="background-color: #2563eb; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 500; display: inline-block;">E-Mail bestätigen</a>
</div>
<p style="color: #666; font-size: 14px;">Dieser Link ist 24 Stunden gültig. Falls du dich nicht bei ManaCore registriert hast, kannst du diese E-Mail ignorieren.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px; text-align: center;">
Diese E-Mail wurde automatisch von ManaCore gesendet.<br>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
<a href="{{verificationUrl}}" style="color: #2563eb; word-break: break-all;">{{verificationUrl}}</a>
</p>
</body>
</html>

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #2563eb; margin: 0;">ManaCore</h1>
</div>
<p>Hallo {{userName}},</p>
<p>Willkommen bei ManaCore! Dein Account wurde erfolgreich erstellt.</p>
<p>Du kannst dich jetzt mit deiner E-Mail-Adresse und deinem Passwort anmelden und alle Features nutzen:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{loginUrl}}" style="background-color: #2563eb; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 500; display: inline-block;">Jetzt anmelden</a>
</div>
<p style="color: #666; font-size: 14px;">Bei Fragen oder Problemen kannst du uns jederzeit kontaktieren.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px; text-align: center;">
Diese E-Mail wurde automatisch von ManaCore gesendet.
</p>
</body>
</html>

View file

@ -0,0 +1,117 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { TemplatesService, RenderedTemplate } from './templates.service';
import { ServiceAuthGuard } from '../common/guards/service-auth.guard';
import { Template } from '../db/schema';
class CreateTemplateDto {
slug!: string;
channel!: 'email' | 'push' | 'matrix' | 'webhook';
subject?: string;
bodyTemplate!: string;
locale?: string;
appId?: string;
variables?: Record<string, string>;
}
class UpdateTemplateDto {
subject?: string;
bodyTemplate?: string;
isActive?: boolean;
variables?: Record<string, string>;
}
class PreviewTemplateDto {
subject?: string;
bodyTemplate!: string;
data!: Record<string, unknown>;
}
@Controller('templates')
@UseGuards(ServiceAuthGuard)
export class TemplatesController {
constructor(private readonly templatesService: TemplatesService) {}
@Get()
async listTemplates(@Query('appId') appId?: string): Promise<{ templates: Template[] }> {
const templatesList = await this.templatesService.listTemplates(appId);
return { templates: templatesList };
}
@Get(':slug')
async getTemplate(
@Param('slug') slug: string,
@Query('locale') locale: string = 'de-DE'
): Promise<{ template: Template | null }> {
const template = await this.templatesService.getTemplate(slug, locale);
return { template };
}
@Post()
async createTemplate(@Body() dto: CreateTemplateDto): Promise<{ template: Template }> {
const template = await this.templatesService.createTemplate({
slug: dto.slug,
channel: dto.channel,
subject: dto.subject,
bodyTemplate: dto.bodyTemplate,
locale: dto.locale || 'de-DE',
appId: dto.appId,
variables: dto.variables,
isActive: true,
isSystem: false,
});
return { template };
}
@Put(':slug')
async updateTemplate(
@Param('slug') slug: string,
@Query('locale') locale: string = 'de-DE',
@Body() dto: UpdateTemplateDto
): Promise<{ template: Template }> {
const template = await this.templatesService.updateTemplate(slug, locale, dto);
return { template };
}
@Delete(':slug')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteTemplate(
@Param('slug') slug: string,
@Query('locale') locale: string = 'de-DE'
): Promise<void> {
await this.templatesService.deleteTemplate(slug, locale);
}
@Post(':slug/preview')
async previewTemplate(
@Param('slug') slug: string,
@Query('locale') locale: string = 'de-DE',
@Body() dto: { data: Record<string, unknown> }
): Promise<{ preview: RenderedTemplate | null }> {
const preview = await this.templatesService.renderBySlug(slug, dto.data, locale);
return { preview };
}
@Post('preview')
async previewCustomTemplate(
@Body() dto: PreviewTemplateDto
): Promise<{ preview: RenderedTemplate }> {
const preview = this.templatesService.previewTemplate(
dto.subject || null,
dto.bodyTemplate,
dto.data
);
return { preview };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TemplatesService } from './templates.service';
import { TemplatesController } from './templates.controller';
@Module({
providers: [TemplatesService],
controllers: [TemplatesController],
exports: [TemplatesService],
})
export class TemplatesModule {}

View file

@ -0,0 +1,234 @@
import { Injectable, Logger, Inject, OnModuleInit, NotFoundException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import * as Handlebars from 'handlebars';
import * as fs from 'fs';
import * as path from 'path';
import { DATABASE_CONNECTION } from '../db/database.module';
import { templates, type Template, type NewTemplate } from '../db/schema';
export interface RenderedTemplate {
subject: string;
body: string;
}
interface DefaultTemplate {
slug: string;
channel: 'email' | 'push' | 'matrix' | 'webhook';
subject?: string;
bodyFile: string;
variables: Record<string, string>;
}
@Injectable()
export class TemplatesService implements OnModuleInit {
private readonly logger = new Logger(TemplatesService.name);
private readonly defaultTemplates: DefaultTemplate[] = [
{
slug: 'auth-password-reset',
channel: 'email',
subject: 'Passwort zurücksetzen - ManaCore',
bodyFile: 'password-reset.hbs',
variables: { resetUrl: 'URL to reset password', userName: 'User display name' },
},
{
slug: 'auth-verification',
channel: 'email',
subject: 'E-Mail bestätigen - ManaCore',
bodyFile: 'verification.hbs',
variables: { verificationUrl: 'URL to verify email', userName: 'User display name' },
},
{
slug: 'auth-welcome',
channel: 'email',
subject: 'Willkommen bei ManaCore!',
bodyFile: 'welcome.hbs',
variables: { userName: 'User display name', loginUrl: 'URL to login' },
},
{
slug: 'calendar-reminder',
channel: 'email',
subject: 'Erinnerung: {{eventTitle}}',
bodyFile: 'reminder.hbs',
variables: {
eventTitle: 'Event title',
eventTime: 'Event start time',
eventLocation: 'Event location',
eventUrl: 'URL to view event',
},
},
];
constructor(@Inject(DATABASE_CONNECTION) private readonly db: any) {}
async onModuleInit() {
await this.seedDefaultTemplates();
}
private async seedDefaultTemplates(): Promise<void> {
this.logger.log('Checking default templates...');
for (const defaultTemplate of this.defaultTemplates) {
const existing = await this.db
.select()
.from(templates)
.where(and(eq(templates.slug, defaultTemplate.slug), eq(templates.locale, 'de-DE')))
.limit(1);
if (existing.length === 0) {
this.logger.log(`Creating default template: ${defaultTemplate.slug}`);
const bodyTemplate = this.loadDefaultTemplate(defaultTemplate.bodyFile);
await this.db.insert(templates).values({
slug: defaultTemplate.slug,
channel: defaultTemplate.channel,
subject: defaultTemplate.subject,
bodyTemplate,
locale: 'de-DE',
isActive: true,
isSystem: true,
variables: defaultTemplate.variables,
});
}
}
this.logger.log('Default templates initialized');
}
private loadDefaultTemplate(filename: string): string {
// Try multiple paths for flexibility
const paths = [
path.join(__dirname, 'defaults', filename),
path.join(process.cwd(), 'src', 'templates', 'defaults', filename),
path.join(process.cwd(), 'dist', 'templates', 'defaults', filename),
];
for (const templatePath of paths) {
try {
if (fs.existsSync(templatePath)) {
return fs.readFileSync(templatePath, 'utf-8');
}
} catch {
// Continue to next path
}
}
this.logger.warn(`Default template not found: ${filename}, using placeholder`);
return `<p>Template content for ${filename}</p>`;
}
async getTemplate(slug: string, locale: string = 'de-DE'): Promise<Template | null> {
const [template] = await this.db
.select()
.from(templates)
.where(
and(eq(templates.slug, slug), eq(templates.locale, locale), eq(templates.isActive, true))
)
.limit(1);
// Fallback to de-DE if locale not found
if (!template && locale !== 'de-DE') {
return this.getTemplate(slug, 'de-DE');
}
return template || null;
}
async getTemplateById(id: string): Promise<Template | null> {
const [template] = await this.db.select().from(templates).where(eq(templates.id, id)).limit(1);
return template || null;
}
async listTemplates(appId?: string): Promise<Template[]> {
if (appId) {
return this.db
.select()
.from(templates)
.where(eq(templates.appId, appId))
.orderBy(templates.slug);
}
return this.db.select().from(templates).orderBy(templates.slug);
}
async createTemplate(data: NewTemplate): Promise<Template> {
const [template] = await this.db.insert(templates).values(data).returning();
return template;
}
async updateTemplate(
slug: string,
locale: string,
data: Partial<NewTemplate>
): Promise<Template> {
const existing = await this.getTemplate(slug, locale);
if (!existing) {
throw new NotFoundException(`Template ${slug} not found for locale ${locale}`);
}
if (existing.isSystem && data.isActive === false) {
throw new Error('Cannot deactivate system templates');
}
const [updated] = await this.db
.update(templates)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(templates.slug, slug), eq(templates.locale, locale)))
.returning();
return updated;
}
async deleteTemplate(slug: string, locale: string): Promise<void> {
const existing = await this.getTemplate(slug, locale);
if (!existing) {
throw new NotFoundException(`Template ${slug} not found for locale ${locale}`);
}
if (existing.isSystem) {
throw new Error('Cannot delete system templates');
}
await this.db
.delete(templates)
.where(and(eq(templates.slug, slug), eq(templates.locale, locale)));
}
renderTemplate(template: Template, data: Record<string, unknown>): RenderedTemplate {
const subjectTemplate = template.subject ? Handlebars.compile(template.subject) : null;
const bodyTemplate = Handlebars.compile(template.bodyTemplate);
return {
subject: subjectTemplate ? subjectTemplate(data) : '',
body: bodyTemplate(data),
};
}
async renderBySlug(
slug: string,
data: Record<string, unknown>,
locale: string = 'de-DE'
): Promise<RenderedTemplate | null> {
const template = await this.getTemplate(slug, locale);
if (!template) {
return null;
}
return this.renderTemplate(template, data);
}
previewTemplate(
subject: string | null,
bodyTemplate: string,
data: Record<string, unknown>
): RenderedTemplate {
const subjectCompiled = subject ? Handlebars.compile(subject) : null;
const bodyCompiled = Handlebars.compile(bodyTemplate);
return {
subject: subjectCompiled ? subjectCompiled(data) : '',
body: bodyCompiled(data),
};
}
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}