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

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 message
  • code - 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 message
  • code - 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');
// 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",