# 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