30 KiB
30 KiB
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.
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.
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:
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.
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 messagecode- HTTP status code (400)
Usage:
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.
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 messagecode- HTTP status code (403)
Usage:
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
// 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
// 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
// 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
// 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
// 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
// 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
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
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
// 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
// Good - organized by domain
auth: {
passwordReset: {
invalidToken: '...',
error: '...',
}
}
// Avoid - flat structure
authPasswordResetInvalidToken: '...',
authPasswordResetError: '...',
3. Security-Conscious Messages
// 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
// 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 - Service layer error handling
- Auth Module - Authentication errors
- Email Module - Email message templates
- Middleware Module - Permission error handling