39948-vm/backend/docs/modules/notifications.md
2026-07-03 16:11:24 +02:00

802 lines
30 KiB
Markdown

# Backend Notifications Module
## Overview
The Notifications module provides a centralized **error handling system** and **internationalization (i18n) catalog** for the backend. It includes custom error classes with HTTP status codes and a message resolution system that supports parameter substitution.
**Location:** `backend/src/services/notifications/`
**Total Files:** 4
---
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ Services Layer │
│ (auth.js, users.js, projects.js, roles.js, search.js, etc.) │
│ │
│ throw new ValidationError('auth.userNotFound'); │
│ throw new ForbiddenError('auth.forbidden'); │
└───────────────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Notifications Module │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Error Classes │ │
│ │ (errors/) │ │
│ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │
│ │ │ ValidationError │ │ ForbiddenError │ │ │
│ │ │ (HTTP 400) │ │ (HTTP 403) │ │ │
│ │ └──────────┬──────────┘ └──────────┬──────────┘ │ │
│ │ │ │ │ │
│ │ └────────────┬───────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ helpers.js │ │ │
│ │ │ • getNotification() │ │ │
│ │ │ • isNotification() │ │ │
│ │ └────────────┬────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ list.js │ │ │
│ │ │ (Message Catalog) │ │ │
│ │ │ • auth.* │ │ │
│ │ │ • iam.* │ │ │
│ │ │ • importer.* │ │ │
│ │ │ • errors.* │ │ │
│ │ │ • emails.* │ │ │
│ │ └─────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Error Handler Middleware │
│ (Generic error handling in index.js) │
│ │
│ app.use((err, req, res, _next) => { │
│ res.status(err.code || 500).json({ message: err.message }); │
│ }); │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Directory Structure
```
services/notifications/
├── helpers.js # Message resolution functions (31 LOC)
├── list.js # Message catalog / i18n strings (101 LOC)
└── errors/
├── validation.js # ValidationError class - 400 (17 LOC)
└── forbidden.js # ForbiddenError class - 403 (17 LOC)
```
---
## Core Components
### Message Catalog (`list.js`)
Centralized storage for all user-facing messages and email content.
```javascript
const errors = {
app: {
title: 'Tour Builder Platform',
},
auth: {
userDisabled: 'Your account is disabled',
forbidden: 'Forbidden',
unauthorized: 'Unauthorized',
userNotFound: "Sorry, we don't recognize your credentials",
wrongPassword: "Sorry, we don't recognize your credentials",
weakPassword: 'This password is too weak',
emailAlreadyInUse: 'Email is already in use',
invalidEmail: 'Please provide a valid email',
passwordReset: {
invalidToken: 'Password reset link is invalid or has expired',
error: 'Email not recognized',
},
passwordUpdate: {
samePassword: "You can't use the same password. Please create new password",
},
userNotVerified: 'Sorry, your email has not been verified yet',
emailAddressVerificationEmail: {
invalidToken: 'Email verification link is invalid or has expired',
error: 'Email not recognized',
},
},
iam: {
errors: {
userAlreadyExists: 'User with this email already exists',
userNotFound: 'User not found',
disablingHimself: "You can't disable yourself",
revokingOwnPermission: "You can't revoke your own owner permission",
deletingHimself: "You can't delete yourself",
emailRequired: 'Email is required',
slugAlreadyExists: 'This slug is already in use by another project',
searchQueryRequired: 'Search query is required',
},
},
importer: {
errors: {
invalidFileEmpty: 'The file is empty',
invalidFileExcel: 'Only excel (.xlsx) files are allowed',
invalidFileUpload: 'Invalid file. Make sure you are using the last version of the template.',
importHashRequired: 'Import hash is required',
importHashExistent: 'Data has already been imported',
userEmailMissing: 'Some items in the CSV do not have an email',
},
},
errors: {
forbidden: {
message: 'Forbidden',
},
validation: {
message: 'An error occurred',
},
searchQueryRequired: {
message: 'Search query is required',
},
},
emails: {
invitation: {
subject: "You've been invited to {0}",
body: '...',
},
emailAddressVerification: {
subject: 'Verify your email for {0}',
body: '...',
},
passwordReset: {
subject: 'Reset your password for {0}',
body: '...',
},
},
};
module.exports = errors;
```
### Message Categories
| Category | Purpose | Example Key |
|----------|---------|-------------|
| `app` | Application metadata | `app.title` |
| `auth` | Authentication errors | `auth.userNotFound` |
| `iam` | Identity/access management | `iam.errors.userAlreadyExists` |
| `importer` | CSV/file import errors | `importer.errors.invalidFileEmpty` |
| `errors` | Generic error messages | `errors.validation.message` |
| `emails` | Email subjects/bodies | `emails.invitation.subject` |
---
### Helper Functions (`helpers.js`)
Functions for resolving and formatting notification messages.
```javascript
const _get = require('lodash/get');
const errors = require('./list');
/**
* Format message with positional arguments
* @param {string} message - Message with {0}, {1}, etc. placeholders
* @param {Array} args - Arguments to substitute
* @returns {string} Formatted message
*/
function format(message, args) {
if (!message) {
return null;
}
return message.replace(/{(\d+)}/g, function (match, number) {
return typeof args[number] != 'undefined' ? args[number] : match;
});
}
/**
* Check if a key exists in the notification catalog
* @param {string} key - Dot-notation path (e.g., 'auth.userNotFound')
* @returns {boolean}
*/
const isNotification = (key) => {
const message = _get(errors, key);
return !!message;
};
/**
* Get notification message by key with optional parameter substitution
* @param {string} key - Dot-notation path
* @param {...any} args - Values to substitute for {0}, {1}, etc.
* @returns {string} Resolved message or original key if not found
*/
const getNotification = (key, ...args) => {
const message = _get(errors, key);
if (!message) {
return key; // Return raw key as fallback
}
return format(message, args);
};
exports.getNotification = getNotification;
exports.isNotification = isNotification;
```
**Usage Examples:**
```javascript
const { getNotification, isNotification } = require('./notifications/helpers');
// Simple lookup
getNotification('auth.userNotFound');
// → "Sorry, we don't recognize your credentials"
// With parameter substitution
getNotification('emails.invitation.subject', 'Tour Builder Platform');
// → "You've been invited to Tour Builder Platform"
// Check if key exists
isNotification('auth.userNotFound'); // → true
isNotification('custom.message'); // → false
// Unknown key returns the key itself
getNotification('unknown.key');
// → "unknown.key"
```
---
### Error Classes
#### ValidationError (`errors/validation.js`)
HTTP 400 Bad Request error for validation failures.
```javascript
const { getNotification, isNotification } = require('../helpers');
module.exports = class ValidationError extends Error {
constructor(messageCode) {
let message;
// Try to resolve from notification catalog
if (messageCode && isNotification(messageCode)) {
message = getNotification(messageCode);
}
// Fallback to generic validation message
message = message || getNotification('errors.validation.message');
super(message);
this.code = 400;
}
};
```
**Properties:**
- `message` - Human-readable error message
- `code` - HTTP status code (400)
**Usage:**
```javascript
const ValidationError = require('./notifications/errors/validation');
// With catalog key
throw new ValidationError('auth.userNotFound');
// → Error: "Sorry, we don't recognize your credentials" (code: 400)
// With unknown key (uses fallback)
throw new ValidationError('unknown.error');
// → Error: "An error occurred" (code: 400)
// Without argument
throw new ValidationError();
// → Error: "An error occurred" (code: 400)
```
#### ForbiddenError (`errors/forbidden.js`)
HTTP 403 Forbidden error for authorization failures.
```javascript
const { getNotification, isNotification } = require('../helpers');
module.exports = class ForbiddenError extends Error {
constructor(messageCode) {
let message;
// Try to resolve from notification catalog
if (messageCode && isNotification(messageCode)) {
message = getNotification(messageCode);
}
// Fallback to generic forbidden message
message = message || getNotification('errors.forbidden.message');
super(message);
this.code = 403;
}
};
```
**Properties:**
- `message` - Human-readable error message
- `code` - HTTP status code (403)
**Usage:**
```javascript
const ForbiddenError = require('./notifications/errors/forbidden');
// With catalog key
throw new ForbiddenError('auth.forbidden');
// → Error: "Forbidden" (code: 403)
// Without argument
throw new ForbiddenError();
// → Error: "Forbidden" (code: 403)
```
---
## Complete Message Reference
### Authentication Messages (`auth.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `auth.userDisabled` | "Your account is disabled" | signin |
| `auth.forbidden` | "Forbidden" | authorization failures |
| `auth.unauthorized` | "Unauthorized" | missing authentication |
| `auth.userNotFound` | "Sorry, we don't recognize your credentials" | signin |
| `auth.wrongPassword` | "Sorry, we don't recognize your credentials" | signin, password update |
| `auth.weakPassword` | "This password is too weak" | signup, password reset |
| `auth.emailAlreadyInUse` | "Email is already in use" | signup |
| `auth.invalidEmail` | "Please provide a valid email" | signup |
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | signin |
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | password reset |
| `auth.passwordReset.error` | "Email not recognized" | password reset request |
| `auth.passwordUpdate.samePassword` | "You can't use the same password..." | password update |
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | email verification |
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | email verification |
### IAM Messages (`iam.errors.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `iam.errors.userAlreadyExists` | "User with this email already exists" | user creation |
| `iam.errors.userNotFound` | "User not found" | user update/delete |
| `iam.errors.disablingHimself` | "You can't disable yourself" | user disable |
| `iam.errors.revokingOwnPermission` | "You can't revoke your own owner permission" | permission revoke |
| `iam.errors.deletingHimself` | "You can't delete yourself" | user delete |
| `iam.errors.emailRequired` | "Email is required" | user creation |
| `iam.errors.slugAlreadyExists` | "This slug is already in use by another project" | project create/update |
| `iam.errors.searchQueryRequired` | "Search query is required" | search |
### Importer Messages (`importer.errors.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `importer.errors.invalidFileEmpty` | "The file is empty" | CSV import |
| `importer.errors.invalidFileExcel` | "Only excel (.xlsx) files are allowed" | file import |
| `importer.errors.invalidFileUpload` | "Invalid file..." | file import |
| `importer.errors.importHashRequired` | "Import hash is required" | bulk import |
| `importer.errors.importHashExistent` | "Data has already been imported" | duplicate import |
| `importer.errors.userEmailMissing` | "Some items in the CSV do not have an email" | user CSV import |
### Generic Messages (`errors.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `errors.forbidden.message` | "Forbidden" | ForbiddenError default |
| `errors.validation.message` | "An error occurred" | ValidationError default |
| `errors.searchQueryRequired.message` | "Search query is required" | search validation |
### Email Messages (`emails.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `emails.invitation.subject` | "You've been invited to {0}" | user invitation |
| `emails.emailAddressVerification.subject` | "Verify your email for {0}" | email verification |
| `emails.passwordReset.subject` | "Reset your password for {0}" | password reset |
---
## Integration Points
### Services Using Notifications
| Service | Errors Used | Common Keys |
|---------|-------------|-------------|
| `auth.js` | ValidationError, ForbiddenError | auth.*, iam.* |
| `users.ts` | ValidationError | iam.errors.* |
| `projects.ts` | ValidationError | projectsNotFound |
| `roles.ts` | ValidationError | rolesNotFound, Public role permission validation |
| `search.js` | ValidationError | auth.unauthorized, auth.forbidden |
| `project_audio_tracks.ts` | ValidationError | project_audio_tracksNotFound |
### Email Templates Using Notifications
| Template | Helper Usage |
|----------|--------------|
| `passwordReset.js` | `getNotification('emails.passwordReset.subject')` |
| `addressVerification.js` | `getNotification('emails.emailAddressVerification.subject')` |
| `invitation.js` | `getNotification('emails.invitation.subject')` |
### Middleware Using Notifications
| Middleware | Error Used | Purpose |
|------------|------------|---------|
| `check-permissions.ts` | ValidationError | Permission denied responses |
### Service Factory Using Notifications
```javascript
// factories/service.factory.js
const ValidationError = require('../services/notifications/errors/validation');
// Used in update() when entity not found
if (!record) {
throw new ValidationError(`${entityName}NotFound`);
}
```
---
## Error Flow
### Error Creation and Propagation
```
┌─────────────────────────────────────────────────────────────────────┐
│ Service Layer │
│ │
│ const ValidationError = require('./notifications/errors/validation');│
│ │
│ if (!user) { │
│ throw new ValidationError('auth.userNotFound'); │
│ } │
│ │ │
└─────────────────┼───────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Notifications Module │
│ │
│ 1. isNotification('auth.userNotFound') → true │
│ 2. getNotification('auth.userNotFound') │
│ → lodash.get(errors, 'auth.userNotFound') │
│ → "Sorry, we don't recognize your credentials" │
│ 3. new Error(message) with code = 400 │
│ │ │
└─────────────────┼───────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Route Handler │
│ │
│ try { │
│ await service.signin(email, password); │
│ } catch (error) { │
│ // Error propagates to error handler │
│ } │
│ │ │
└─────────────────┼───────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Error Handler Middleware │
│ (index.js) │
│ │
│ app.use((err, req, res, _next) => { │
│ logger.error({ err, url: req.url }, 'Unhandled error'); │
│ res.status(err.code || 500).json({ │
│ message: err.message || 'Internal server error' │
│ }); │
│ }); │
│ │
│ → Response: { "message": "Sorry, we don't recognize your credentials" }│
│ → Status: 400 │
└─────────────────────────────────────────────────────────────────────┘
```
### Message Resolution Flow
```
throw new ValidationError('auth.passwordReset.invalidToken')
┌─────────────────────────────────────┐
│ isNotification(messageCode) │
│ lodash.get(errors, key) │
│ → "Password reset link..." │
│ → true │
└─────────────────┬───────────────────┘
│ (exists)
┌─────────────────────────────────────┐
│ getNotification(messageCode) │
│ → "Password reset link is │
│ invalid or has expired" │
└─────────────────┬───────────────────┘
┌─────────────────────────────────────┐
│ new Error(message) │
│ this.message = "Password reset..."│
│ this.code = 400 │
└─────────────────────────────────────┘
```
---
## Comparison: Notifications vs Utils Errors
The backend has two error systems that serve different purposes:
### `services/notifications/errors/` (Primary)
- **Used by**: Services, middleware, service factory
- **Integration**: Uses notification catalog for i18n
- **Message resolution**: Dynamic via `getNotification()`
- **Properties**: `message`, `code`
```javascript
// notifications/errors/validation.js
class ValidationError extends Error {
constructor(messageCode) {
const message = isNotification(messageCode)
? getNotification(messageCode)
: getNotification('errors.validation.message');
super(message);
this.code = 400;
}
}
```
### `utils/errors.js` (Utility)
- **Used by**: Lower-level utilities
- **Integration**: Direct message strings
- **Message resolution**: Static, passed at construction
- **Properties**: `message`, `statusCode`, `details`, `isOperational`
```javascript
// utils/errors.js
class AppError extends Error {
constructor(message, statusCode = 500, details = null) {
super(message);
this.statusCode = statusCode;
this.details = details;
this.isOperational = true;
}
}
class ValidationError extends AppError {
constructor(message, details = null) {
super(message, 400, details);
}
}
```
### When to Use Which
| Scenario | Use |
|----------|-----|
| Service business logic errors | `notifications/errors/ValidationError` |
| Authorization failures | `notifications/errors/ForbiddenError` |
| Email subject/body text | `getNotification()` |
| Low-level utility errors | `utils/errors.js` |
| Errors needing details object | `utils/errors.js` |
---
## Adding New Messages
### 1. Add to Catalog
```typescript
// services/notifications/list.ts
const notifications = {
// ... existing ...
projects: {
errors: {
notFound: 'Project not found',
slugTaken: 'A project with this slug already exists',
invalidSlug: 'Project slug must contain only letters, numbers, and hyphens',
},
},
};
```
### 2. Use in Service
```javascript
// services/projects.ts
const ValidationError = require('./notifications/errors/validation');
async function createProject(data) {
const existing = await findBySlug(data.slug);
if (existing) {
throw new ValidationError('projects.errors.slugTaken');
}
// ...
}
```
### 3. With Parameters
```javascript
// Add to catalog
const errors = {
projects: {
errors: {
tooManyPages: 'Project cannot have more than {0} pages',
},
},
};
// Use with parameter
const MAX_PAGES = 100;
if (pageCount > MAX_PAGES) {
const message = getNotification('projects.errors.tooManyPages', MAX_PAGES);
throw new ValidationError(message);
}
// → "Project cannot have more than 100 pages"
```
---
## Testing
### Unit Testing Error Classes
```javascript
describe('ValidationError', () => {
it('should resolve message from catalog', () => {
const error = new ValidationError('auth.userNotFound');
expect(error.message).toBe("Sorry, we don't recognize your credentials");
expect(error.code).toBe(400);
});
it('should use fallback for unknown keys', () => {
const error = new ValidationError('unknown.key');
expect(error.message).toBe('An error occurred');
expect(error.code).toBe(400);
});
it('should use fallback without argument', () => {
const error = new ValidationError();
expect(error.message).toBe('An error occurred');
});
});
describe('ForbiddenError', () => {
it('should resolve message from catalog', () => {
const error = new ForbiddenError('auth.forbidden');
expect(error.message).toBe('Forbidden');
expect(error.code).toBe(403);
});
});
```
### Unit Testing Helpers
```javascript
const { getNotification, isNotification } = require('./helpers');
describe('getNotification', () => {
it('should return message for valid key', () => {
expect(getNotification('auth.userNotFound')).toBe(
"Sorry, we don't recognize your credentials"
);
});
it('should substitute parameters', () => {
expect(getNotification('emails.invitation.subject', 'My App')).toBe(
"You've been invited to My App"
);
});
it('should return key for unknown path', () => {
expect(getNotification('unknown.path')).toBe('unknown.path');
});
});
describe('isNotification', () => {
it('should return true for existing keys', () => {
expect(isNotification('auth.userNotFound')).toBe(true);
});
it('should return false for unknown keys', () => {
expect(isNotification('unknown.key')).toBe(false);
});
});
```
---
## Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| `lodash/get` | ^4.x | Deep object property access |
---
## Best Practices
### 1. Use Catalog Keys, Not Raw Strings
```javascript
// Good - uses catalog for consistency
throw new ValidationError('auth.userNotFound');
// Avoid - raw strings bypass i18n
throw new ValidationError('User was not found');
```
### 2. Group Related Messages
```javascript
// Good - organized by domain
auth: {
passwordReset: {
invalidToken: '...',
error: '...',
}
}
// Avoid - flat structure
authPasswordResetInvalidToken: '...',
authPasswordResetError: '...',
```
### 3. Security-Conscious Messages
```javascript
// Good - doesn't reveal if email exists
userNotFound: "Sorry, we don't recognize your credentials",
wrongPassword: "Sorry, we don't recognize your credentials",
// Avoid - reveals email existence
userNotFound: "No account with this email exists",
wrongPassword: "Password is incorrect",
```
### 4. Parameter Substitution for Dynamic Content
```javascript
// Good - parameterized
subject: "You've been invited to {0}",
getNotification('emails.invitation.subject', appName);
// Avoid - hardcoded
subject: "You've been invited to Tour Builder",
```
---
## Related Documentation
- [Services Module](./services.md) - Service layer error handling
- [Auth Module](./auth.md) - Authentication errors
- [Email Module](./email.md) - Email message templates
- [Middleware Module](./middleware.md) - Permission error handling