mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
✨ 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:
parent
1495dbe476
commit
b5fa0f42b6
66 changed files with 4824 additions and 0 deletions
2
packages/notify-client/.eslintignore
Normal file
2
packages/notify-client/.eslintignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
tsup.config.ts
|
||||
dist/
|
||||
149
packages/notify-client/README.md
Normal file
149
packages/notify-client/README.md
Normal 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 |
|
||||
44
packages/notify-client/package.json
Normal file
44
packages/notify-client/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
354
packages/notify-client/src/client.ts
Normal file
354
packages/notify-client/src/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/notify-client/src/index.ts
Normal file
2
packages/notify-client/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { NotifyClient, NotifyClientOptions } from './client';
|
||||
export * from './types';
|
||||
2
packages/notify-client/src/nestjs/constants.ts
Normal file
2
packages/notify-client/src/nestjs/constants.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const NOTIFY_CLIENT = 'NOTIFY_CLIENT';
|
||||
export const NOTIFY_MODULE_OPTIONS = 'NOTIFY_MODULE_OPTIONS';
|
||||
2
packages/notify-client/src/nestjs/index.ts
Normal file
2
packages/notify-client/src/nestjs/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { NotifyModule, NotifyModuleOptions, NotifyModuleAsyncOptions } from './notify.module';
|
||||
export { NOTIFY_CLIENT } from './constants';
|
||||
106
packages/notify-client/src/nestjs/notify.module.ts
Normal file
106
packages/notify-client/src/nestjs/notify.module.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
104
packages/notify-client/src/types.ts
Normal file
104
packages/notify-client/src/types.ts
Normal 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>>;
|
||||
}
|
||||
17
packages/notify-client/tsconfig.json
Normal file
17
packages/notify-client/tsconfig.json
Normal 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"]
|
||||
}
|
||||
15
packages/notify-client/tsup.config.ts
Normal file
15
packages/notify-client/tsup.config.ts
Normal 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'],
|
||||
});
|
||||
390
services/mana-notify/CLAUDE.md
Normal file
390
services/mana-notify/CLAUDE.md
Normal 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
|
||||
46
services/mana-notify/Dockerfile
Normal file
46
services/mana-notify/Dockerfile
Normal 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"]
|
||||
23
services/mana-notify/docker-compose.dev.yml
Normal file
23
services/mana-notify/docker-compose.dev.yml
Normal 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:
|
||||
12
services/mana-notify/drizzle.config.ts
Normal file
12
services/mana-notify/drizzle.config.ts
Normal 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,
|
||||
});
|
||||
10
services/mana-notify/nest-cli.json
Normal file
10
services/mana-notify/nest-cli.json
Normal 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
|
||||
}
|
||||
}
|
||||
60
services/mana-notify/package.json
Normal file
60
services/mana-notify/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
44
services/mana-notify/src/app.module.ts
Normal file
44
services/mana-notify/src/app.module.ts
Normal 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 {}
|
||||
11
services/mana-notify/src/channels/channels.module.ts
Normal file
11
services/mana-notify/src/channels/channels.module.ts
Normal 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 {}
|
||||
95
services/mana-notify/src/channels/email/email.service.ts
Normal file
95
services/mana-notify/src/channels/email/email.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
85
services/mana-notify/src/channels/matrix/matrix.service.ts
Normal file
85
services/mana-notify/src/channels/matrix/matrix.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
178
services/mana-notify/src/channels/push/push.service.ts
Normal file
178
services/mana-notify/src/channels/push/push.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
91
services/mana-notify/src/channels/webhook/webhook.service.ts
Normal file
91
services/mana-notify/src/channels/webhook/webhook.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
75
services/mana-notify/src/common/guards/jwt-auth.guard.ts
Normal file
75
services/mana-notify/src/common/guards/jwt-auth.guard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
39
services/mana-notify/src/common/guards/service-auth.guard.ts
Normal file
39
services/mana-notify/src/common/guards/service-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
services/mana-notify/src/config/configuration.ts
Normal file
44
services/mana-notify/src/config/configuration.ts
Normal 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:*'],
|
||||
},
|
||||
});
|
||||
33
services/mana-notify/src/db/connection.ts
Normal file
33
services/mana-notify/src/db/connection.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
24
services/mana-notify/src/db/database.module.ts
Normal file
24
services/mana-notify/src/db/database.module.ts
Normal 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 {}
|
||||
29
services/mana-notify/src/db/migrate.ts
Normal file
29
services/mana-notify/src/db/migrate.ts
Normal 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();
|
||||
49
services/mana-notify/src/db/schema/delivery-logs.schema.ts
Normal file
49
services/mana-notify/src/db/schema/delivery-logs.schema.ts
Normal 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;
|
||||
46
services/mana-notify/src/db/schema/devices.schema.ts
Normal file
46
services/mana-notify/src/db/schema/devices.schema.ts
Normal 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;
|
||||
5
services/mana-notify/src/db/schema/index.ts
Normal file
5
services/mana-notify/src/db/schema/index.ts
Normal 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';
|
||||
75
services/mana-notify/src/db/schema/notifications.schema.ts
Normal file
75
services/mana-notify/src/db/schema/notifications.schema.ts
Normal 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;
|
||||
46
services/mana-notify/src/db/schema/preferences.schema.ts
Normal file
46
services/mana-notify/src/db/schema/preferences.schema.ts
Normal 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;
|
||||
50
services/mana-notify/src/db/schema/templates.schema.ts
Normal file
50
services/mana-notify/src/db/schema/templates.schema.ts
Normal 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;
|
||||
63
services/mana-notify/src/devices/devices.controller.ts
Normal file
63
services/mana-notify/src/devices/devices.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
services/mana-notify/src/devices/devices.module.ts
Normal file
10
services/mana-notify/src/devices/devices.module.ts
Normal 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 {}
|
||||
141
services/mana-notify/src/devices/devices.service.ts
Normal file
141
services/mana-notify/src/devices/devices.service.ts
Normal 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)}...`);
|
||||
}
|
||||
}
|
||||
46
services/mana-notify/src/health/health.controller.ts
Normal file
46
services/mana-notify/src/health/health.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
services/mana-notify/src/health/health.module.ts
Normal file
7
services/mana-notify/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
42
services/mana-notify/src/main.ts
Normal file
42
services/mana-notify/src/main.ts
Normal 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();
|
||||
13
services/mana-notify/src/metrics/metrics.controller.ts
Normal file
13
services/mana-notify/src/metrics/metrics.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
10
services/mana-notify/src/metrics/metrics.module.ts
Normal file
10
services/mana-notify/src/metrics/metrics.module.ts
Normal 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 {}
|
||||
138
services/mana-notify/src/metrics/metrics.service.ts
Normal file
138
services/mana-notify/src/metrics/metrics.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
1
services/mana-notify/src/notifications/dto/index.ts
Normal file
1
services/mana-notify/src/notifications/dto/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './send-notification.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
403
services/mana-notify/src/notifications/notifications.service.ts
Normal file
403
services/mana-notify/src/notifications/notifications.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
10
services/mana-notify/src/preferences/preferences.module.ts
Normal file
10
services/mana-notify/src/preferences/preferences.module.ts
Normal 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 {}
|
||||
123
services/mana-notify/src/preferences/preferences.service.ts
Normal file
123
services/mana-notify/src/preferences/preferences.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
125
services/mana-notify/src/queue/processors/email.processor.ts
Normal file
125
services/mana-notify/src/queue/processors/email.processor.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
services/mana-notify/src/queue/processors/matrix.processor.ts
Normal file
121
services/mana-notify/src/queue/processors/matrix.processor.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
154
services/mana-notify/src/queue/processors/push.processor.ts
Normal file
154
services/mana-notify/src/queue/processors/push.processor.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
123
services/mana-notify/src/queue/processors/webhook.processor.ts
Normal file
123
services/mana-notify/src/queue/processors/webhook.processor.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
services/mana-notify/src/queue/queue.module.ts
Normal file
73
services/mana-notify/src/queue/queue.module.ts
Normal 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 {}
|
||||
|
|
@ -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>
|
||||
37
services/mana-notify/src/templates/defaults/reminder.hbs
Normal file
37
services/mana-notify/src/templates/defaults/reminder.hbs
Normal 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>
|
||||
30
services/mana-notify/src/templates/defaults/verification.hbs
Normal file
30
services/mana-notify/src/templates/defaults/verification.hbs
Normal 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>
|
||||
30
services/mana-notify/src/templates/defaults/welcome.hbs
Normal file
30
services/mana-notify/src/templates/defaults/welcome.hbs
Normal 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>
|
||||
117
services/mana-notify/src/templates/templates.controller.ts
Normal file
117
services/mana-notify/src/templates/templates.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
services/mana-notify/src/templates/templates.module.ts
Normal file
10
services/mana-notify/src/templates/templates.module.ts
Normal 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 {}
|
||||
234
services/mana-notify/src/templates/templates.service.ts
Normal file
234
services/mana-notify/src/templates/templates.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
25
services/mana-notify/tsconfig.json
Normal file
25
services/mana-notify/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue