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

28 KiB

Backend Factories Module

Overview

The Factories module provides code generation patterns that eliminate boilerplate for standard CRUD operations across the backend. Two factory functions generate consistent, standardized router and service classes for all entity types.

Location: backend/src/factories/

Files:

File Purpose LOC
router.factory.ts Generates Express routers with CRUD endpoints 429
service.factory.ts Generates service classes with transaction handling 350

Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                         Factory Pattern Flow                            │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│  Route File     │    │  Service File   │    │  DB API File    │
│  (3-5 lines)    │    │  (3-5 lines)    │    │  (extends base) │
└────────┬────────┘    └────────┬────────┘    └────────┬────────┘
         │                      │                      │
         ▼                      ▼                      ▼
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│ createEntity    │    │ createEntity    │    │ GenericDBApi    │
│ Router()        │    │ Service()       │    │ (base.api.ts)   │
└────────┬────────┘    └────────┬────────┘    └────────┬────────┘
         │                      │                      │
         └──────────────────────┼──────────────────────┘
                                │
                                ▼
                    ┌───────────────────────┐
                    │   Sequelize Model     │
                    │   (db/models/*.js)    │
                    └───────────────────────┘

Boilerplate Reduction:
- Without factories: ~300-500 lines per entity
- With factories: ~10-20 lines per entity (97% reduction)

Factory Files

router.factory.ts

Purpose: Generates Express routers with standardized CRUD endpoints, permission checking, CSV export, and error handling.

Location: backend/src/factories/router.factory.ts

Function Signature

function createEntityRouter(entityName, Service, DBApi, options = {})

Parameters

Parameter Type Description
entityName string Entity name for routes and permissions
Service class Service class with CRUD methods
DBApi class Database API class extending GenericDBApi
options object Configuration options

Options

Option Type Default Description
permissionEntity string entityName Override permission entity name
csvFields string[] DBApi.CSV_FIELDS Fields to include in CSV export
customRoutes function null Callback to add custom routes

Generated Endpoints

Method Endpoint Description
POST / Create new record
POST /bulk-import Bulk import from CSV
PUT /:id Update record by ID
DELETE /:id Delete record by ID
POST /deleteByIds Delete multiple records
GET / List all records (with filters, pagination)
GET /count Get record count
GET /autocomplete Get autocomplete suggestions
GET /:id Get single record by ID

Implementation

const express = require('express');
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
const { checkCrudPermissions } = require('../middlewares/check-permissions');
const { parse } = require('json2csv');

function createEntityRouter(entityName, Service, DBApi, options = {}) {
  const router = express.Router();

  // Apply CRUD permission middleware for all routes
  const permissionEntity = options.permissionEntity || entityName;
  router.use(checkCrudPermissions(permissionEntity));

  // POST / - Create
  router.post('/', wrapAsync(async (req, res) => {
    const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
    const link = new URL(referer);
    const payload = await Service.create({
      data: req.body.data,
      currentUser: req.currentUser,
      runtimeContext: req.runtimeContext,
      sendInvitationEmails: true,
      host: link.host,
    });
    res.status(200).send(payload);
  }));

  // POST /bulk-import - Bulk CSV import
  router.post('/bulk-import', wrapAsync(async (req, res) => {
    const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
    const link = new URL(referer);
    await Service.bulkImport(req, res, true, link.host);
    res.status(200).send(true);
  }));

  // PUT /:id - Update
  router.put('/:id', wrapAsync(async (req, res) => {
    assertRouteIdMatchesBody(req);
    await Service.update({
      id: req.params.id,
      data: req.body.data,
      currentUser: req.currentUser,
      runtimeContext: req.runtimeContext,
    });
    res.status(200).send(true);
  }));

  // DELETE /:id - Delete single
  router.delete('/:id', wrapAsync(async (req, res) => {
    await Service.remove({
      id: req.params.id,
      currentUser: req.currentUser,
      runtimeContext: req.runtimeContext,
    });
    res.status(200).send(true);
  }));

  // POST /deleteByIds - Delete multiple
  router.post('/deleteByIds', wrapAsync(async (req, res) => {
    await Service.deleteByIds({
      ids: req.body.data,
      currentUser: req.currentUser,
      runtimeContext: req.runtimeContext,
    });
    res.status(200).send(true);
  }));

  // GET / - List all with optional CSV export
  router.get('/', wrapAsync(async (req, res) => {
    const filetype = req.query.filetype;
    const currentUser = req.currentUser;
    const runtimeContext = req.runtimeContext;

    const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi, {
      csv: filetype === 'csv',
    }), { currentUser, runtimeContext });

    if (filetype === 'csv') {
      const fields = options.csvFields || DBApi.CSV_FIELDS || ['id', 'createdAt'];
      const opts = { fields };
      try {
        const csv = parse(payload.rows, opts);
        res.status(200).attachment('export.csv').send(csv);
      } catch (err) {
        logger.error({ err, entityName }, 'CSV export error');
        res.status(500).send('CSV export error');
      }
    } else {
      res.status(200).send(payload);
    }
  }));

  // GET /count - Count only
  router.get('/count', wrapAsync(async (req, res) => {
    const currentUser = req.currentUser;
    const runtimeContext = req.runtimeContext;
    const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi), { countOnly: true, currentUser, runtimeContext });
    res.status(200).send(payload);
  }));

  // GET /autocomplete - Autocomplete search
  router.get('/autocomplete', wrapAsync(async (req, res) => {
    const payload = await DBApi.findAllAutocomplete({
      query: req.query.query,
      limit,
      offset: req.query.offset,
    });
    res.status(200).send(payload);
  }));

  // GET /:id - Find by ID
  router.get('/:id', wrapAsync(async (req, res) => {
    if (!isUuidV4(req.params.id)) {
      return res.status(400).send(`Invalid ${entityName} id`);
    }
    const runtimeContext = req.runtimeContext;
    const payload = await DBApi.findBy({ id: req.params.id }, { runtimeContext });
    res.status(200).send(payload);
  }));

  // Custom routes hook
  if (options.customRoutes) {
    options.customRoutes(router, Service, DBApi);
  }

  // Error handler
  router.use('/', commonErrorHandler);

  return router;
}

module.exports = { createEntityRouter, isUuidV4 };

Generic CRUD query safety:

  • PUT /:id uses req.params.id as the canonical id and rejects mismatched body ids.
  • List and count queries default to limit=50, max limit=1000, and sanitize sort direction to ASC or DESC.
  • Sort fields are accepted only when present in the model's rawAttributes or DBApi.SORTABLE_FIELDS.
  • CSV export uses the same auth path as list and is capped at limit=1000.
  • Autocomplete defaults to limit=20 and is capped at limit=50.

Exports

Export Type Description
createEntityRouter function Factory function
isUuidV4 function UUID validation helper

service.factory.ts

Purpose: Generates service classes with standardized CRUD operations wrapped in database transactions.

Location: backend/src/factories/service.factory.ts

Function Signature

function createEntityService(DBApi, options = {})

Parameters

Parameter Type Description
DBApi class Database API class extending GenericDBApi
options object Configuration options

Options

Option Type Default Description
entityName string 'Entity' Name used in error messages

Generated Methods

Method Description
create({ data, currentUser, transaction, runtimeContext }) Create record with transaction
bulkImport(req, res) Bulk import from CSV with transaction
update({ id, data, currentUser, transaction, runtimeContext }) Update record with transaction
deleteByIds({ ids, currentUser, transaction, runtimeContext }) Delete multiple with transaction
remove({ id, currentUser, transaction, runtimeContext }) Delete single with transaction

Implementation

const db = require('../db/models');
const processFile = require('../middlewares/upload');
const ValidationError = require('../services/notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');

function createEntityService(DBApi, options = {}) {
  const entityName = options.entityName || 'Entity';

  return class GenericService {
    static async create({ data, currentUser, transaction: externalTransaction, runtimeContext }) {
      const transaction = externalTransaction || await db.sequelize.transaction();
      const ownsTransaction = !externalTransaction;
      try {
        const record = await DBApi.create({ data, currentUser, transaction, runtimeContext });
        if (ownsTransaction) await transaction.commit();
        return record;
      } catch (error) {
        if (ownsTransaction) await transaction.rollback();
        throw error;
      }
    }

    static async bulkImport(req, res) {
      const transaction = await db.sequelize.transaction();
      try {
        await processFile(req, res);
        const bufferStream = new stream.PassThrough();
        const results = [];

        await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));

        await new Promise((resolve, reject) => {
          bufferStream
            .pipe(csv())
            .on('data', (data) => results.push(data))
            .on('end', () => resolve())
            .on('error', (error) => reject(error));
        });

        await DBApi.bulkImport(results, {
          transaction,
          ignoreDuplicates: true,
          validate: true,
          currentUser: req.currentUser,
        });

        await transaction.commit();
      } catch (error) {
        await transaction.rollback();
        throw error;
      }
    }

    static async update({ id, data, currentUser, transaction: externalTransaction, runtimeContext }) {
      const transaction = externalTransaction || await db.sequelize.transaction();
      const ownsTransaction = !externalTransaction;
      try {
        const record = await DBApi.findBy({ id }, { transaction, runtimeContext });

        if (!record) {
          throw new ValidationError(`${entityName}NotFound`);
        }

        const updated = await DBApi.update({ id, data, currentUser, transaction, runtimeContext });
        if (ownsTransaction) await transaction.commit();
        return updated;
      } catch (error) {
        if (ownsTransaction) await transaction.rollback();
        throw error;
      }
    }

    static async deleteByIds({ ids, currentUser, transaction: externalTransaction, runtimeContext }) {
      const transaction = externalTransaction || await db.sequelize.transaction();
      const ownsTransaction = !externalTransaction;
      try {
        await DBApi.deleteByIds({ ids, currentUser, transaction, runtimeContext });
        if (ownsTransaction) await transaction.commit();
      } catch (error) {
        if (ownsTransaction) await transaction.rollback();
        throw error;
      }
    }

    static async remove({ id, currentUser, transaction: externalTransaction, runtimeContext }) {
      const transaction = externalTransaction || await db.sequelize.transaction();
      const ownsTransaction = !externalTransaction;
      try {
        await DBApi.remove({ id, currentUser, transaction, runtimeContext });
        if (ownsTransaction) await transaction.commit();
      } catch (error) {
        if (ownsTransaction) await transaction.rollback();
        throw error;
      }
    }
  };
}

module.exports = { createEntityService };

Transaction Pattern

All service methods follow the same transaction pattern:

static async methodName(params) {
  const transaction = await db.sequelize.transaction();
  try {
    // ... database operations with { transaction }
    await transaction.commit();
    return result;
  } catch (error) {
    await transaction.rollback();
    throw error;
  }
}

Supporting Components

helpers.js

Location: backend/src/helpers.js

Helper utilities used by the router factory:

module.exports = class Helpers {
  // Wrap async route handlers to propagate errors
  static wrapAsync(fn) {
    return function (req, res, next) {
      fn(req, res, next).catch(next);
    };
  }

  // Centralized error response handler
  static commonErrorHandler(error, req, res, _next) {
    const statusCode = error.code || error.status;

    if ([400, 401, 403, 404, 409, 422].includes(statusCode)) {
      return res.status(statusCode).send(error.message);
    }

    console.error(error);
    return res.status(500).send('Internal server error');
  }

  // JWT token signing
  static jwtSign(data) {
    return jwt.sign(data, config.secret_key, { expiresIn: '6h' });
  }

  // UUID v4 validation
  static isUuidV4(value) {
    return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
  }
};

check-permissions.ts

Location: backend/src/middlewares/check-permissions.ts

Permission checking middleware used by router factory:

// HTTP method to permission action mapping
const METHOD_MAP = {
  POST: 'CREATE',
  GET: 'READ',
  PUT: 'UPDATE',
  PATCH: 'UPDATE',
  DELETE: 'DELETE',
};

// Entities accessible publicly in runtime mode
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
  'PROJECTS',
  'TOUR_PAGES',
  'PAGE_ELEMENTS',
  'PAGE_LINKS',
  'TRANSITIONS',
  'PROJECT_AUDIO_TRACKS',
]);

// Generate permission name from HTTP method and entity
function checkCrudPermissions(name) {
  return (req, res, next) => {
    // Skip auth for public runtime read requests
    const isRuntimePublicRead =
      req.isRuntimePublicRequest === true &&
      req.method === 'GET' &&
      RUNTIME_PUBLIC_READ_ENTITIES.has(name.toUpperCase());

    if (isRuntimePublicRead) {
      return next();
    }

    // Build permission name: e.g., 'READ_ASSETS', 'CREATE_USERS'
    const permissionName = `${METHOD_MAP[req.method]}_${name.toUpperCase()}`;
    return checkPermissions(permissionName)(req, res, next);
  };
}

GenericDBApi (Base Class)

Location: backend/src/db/api/base.api.ts

The DB API base class that all entity APIs extend. Provides declarative configuration for CRUD operations.

Static Getters (Override in Subclasses)

Getter Type Description
MODEL Model Sequelize model reference (required)
TABLE_NAME string Database table name
SEARCHABLE_FIELDS string[] Fields for text search (ILIKE)
RANGE_FIELDS string[] Fields for range filtering
ENUM_FIELDS string[] Fields for exact match filtering
RELATION_FILTERS object[] Related entity filters
CSV_FIELDS string[] Fields for CSV export
AUTOCOMPLETE_FIELD string Field for autocomplete
ASSOCIATIONS object[] Related entity setters
FIND_BY_INCLUDES object[] Includes for findBy
FIND_ALL_INCLUDES object[] Includes for findAll
JSON_FIELDS string[] Fields to auto-stringify
FIELD_TRANSFORMERS object Custom field transformers
FIELD_DEFAULTS object Default values for fields

Methods

Method Description
getFieldMapping(data) Transform input data for database
create(data, options) Create record
bulkImport(data, options) Bulk create records
update({ id, data, currentUser, transaction, runtimeContext }) Update record
deleteByIds({ ids, currentUser, transaction, runtimeContext }) Soft delete multiple
remove({ id, currentUser, transaction, runtimeContext }) Soft delete single
findBy(where, options) Find single by criteria
findAll(filter, options) Find all with pagination/filters
findAllAutocomplete({ query, limit, offset }, options) Autocomplete search
toCSV(rows) Convert to CSV string

Usage Examples

Basic Entity (Minimal Configuration)

Route (assets.ts):

import AssetsDBApi from '../db/api/assets.ts';
import { createEntityRouter } from '../factories/router.factory.ts';
import AssetsService from '../services/assets.ts';

// 1 line: generates 9 CRUD endpoints
export default createEntityRouter('assets', AssetsService, AssetsDBApi);

Service (assets.ts):

import AssetsDBApi from '../db/api/assets.ts';
import { createEntityService } from '../factories/service.factory.ts';

// 1 line: generates service class with 5 transaction-wrapped methods
export default createEntityService(AssetsDBApi, {
  entityName: 'assets',
});

DB API (assets.js):

const GenericDBApi = require('./base.api');
const db = require('../models');

class AssetsDBApi extends GenericDBApi {
  static get MODEL() {
    return db.assets;
  }

  static get SEARCHABLE_FIELDS() {
    return ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum'];
  }

  static get RANGE_FIELDS() {
    return ['size_mb', 'width_px', 'height_px', 'duration_sec'];
  }

  static get ENUM_FIELDS() {
    return ['asset_type', 'type', 'is_public'];
  }

  static get ASSOCIATIONS() {
    return [{ field: 'project', setter: 'setProject', isArray: false }];
  }

  static getFieldMapping(data) {
    return {
      name: data.name || null,
      asset_type: data.asset_type || null,
      type: data.type || 'general',
      cdn_url: data.cdn_url || null,
      // ... other fields
    };
  }
}

module.exports = AssetsDBApi;

Entity with Custom Routes

Route (project_element_defaults.ts):

import Service from '../services/project_element_defaults.ts';
import DBApi from '../db/api/project_element_defaults.ts';
import { createEntityRouter } from '../factories/router.factory.ts';
import { wrapAsync } from '../helpers.ts';

// Create base router
const baseRouter = createEntityRouter(
  'project_element_defaults',
  Service,
  DBApi,
  { permissionEntity: 'page_elements' }  // Override permission entity
);

// Add custom endpoint
baseRouter.post('/:id/reset', wrapAsync(async (req, res) => {
  const payload = await Service.resetToGlobal(req.params.id, {
    currentUser: req.currentUser,
  });
  res.status(200).json(payload);
}));

// Add another custom endpoint
baseRouter.get('/:id/diff', wrapAsync(async (req, res) => {
  const payload = await Service.getDiffFromGlobal(req.params.id);
  res.status(200).json(payload);
}));

export default baseRouter;

Service with Extended Methods

Service (project_element_defaults.ts):

import DBApi from '../db/api/project_element_defaults.ts';
import { createEntityService } from '../factories/service.factory.ts';

// Create base service class
const BaseService = createEntityService(DBApi, {
  entityName: 'project_element_defaults',
});

// Extend with custom methods
export default class Project_element_defaultsService extends BaseService {
  static resetToGlobal(id, options = {}) {
    return DBApi.resetToGlobal(id, options);
  }

  static getDiffFromGlobal(id) {
    return DBApi.getDiffFromGlobal(id);
  }

  static snapshotGlobalDefaults(projectId, options = {}) {
    return DBApi.snapshotGlobalDefaults(projectId, options);
  }
}

Using customRoutes Callback

Alternative approach using the options callback:

module.exports = createEntityRouter('entities', Service, DBApi, {
  customRoutes: (router, Service, DBApi) => {
    router.post('/:id/custom-action', wrapAsync(async (req, res) => {
      const result = await Service.customAction(req.params.id);
      res.status(200).json(result);
    }));

    router.get('/stats', wrapAsync(async (req, res) => {
      const stats = await DBApi.getStatistics();
      res.status(200).json(stats);
    }));
  }
});

Entity Usage Summary

Entities Using Router Factory (13)

Entity Permission Override Custom Routes
access_logs - No
asset_variants - No
assets - No
element_type_defaults - No
permissions - No
presigned_url_requests - No
project_audio_tracks - No
project_element_defaults page_elements Yes (reset, diff)
project_memberships - No
publish_events - No
pwa_caches - No
roles - No
tour_pages - No

Entities Using Service Factory (11)

Entity Custom Methods
access_logs No
asset_variants No
assets No
element_type_defaults No
permissions No
presigned_url_requests No
pwa_caches No
publish_events No
tour_pages No
project_element_defaults Yes (resetToGlobal, getDiffFromGlobal, snapshotGlobalDefaults)
project_memberships No

Entities NOT Using Factories

Some entities have custom implementations due to specialized requirements:

Entity Reason
users Complex auth, password hashing, token management
projects Publishing workflow, complex business logic
auth Authentication flows (login, OAuth, password reset)
file File upload/download, S3/GCloud/Local storage
search Full-text search across multiple entities
publish Multi-step publishing workflow

Generated Endpoints Flow

HTTP Request
    │
    ▼
┌─────────────────────────────────┐
│ JWT Authentication              │  (from index.js)
│ passport.authenticate('jwt')    │
└─────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────┐
│ checkCrudPermissions()          │  (from router.factory.ts)
│ - Maps HTTP method to action    │
│ - Builds permission name        │
│ - Checks user role permissions  │
└─────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────┐
│ Route Handler                   │  (from router.factory.ts)
│ - wrapAsync() for error catch   │
│ - Calls Service method          │
└─────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────┐
│ Service Method                  │  (from service.factory.ts)
│ - Starts transaction            │
│ - Calls DBApi method            │
│ - Commits or rollbacks          │
└─────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────┐
│ DBApi Method                    │  (from base.api.ts + entity)
│ - getFieldMapping() transform   │
│ - Sequelize model operation     │
│ - Returns result                │
└─────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────┐
│ commonErrorHandler()            │  (from helpers.js)
│ - Formats error response        │
│ - Appropriate HTTP status       │
└─────────────────────────────────┘
    │
    ▼
HTTP Response

Design Patterns

Factory Pattern

Both createEntityRouter and createEntityService implement the Factory pattern, creating objects (router, service class) without specifying their exact classes.

Template Method Pattern

GenericDBApi.getFieldMapping() uses the Template Method pattern - the base class defines the algorithm skeleton, while subclasses can override specific steps via static getters (JSON_FIELDS, FIELD_TRANSFORMERS, FIELD_DEFAULTS).

Strategy Pattern

The permission checking system uses Strategy pattern - different entities can have different permission strategies by overriding permissionEntity option.

Decorator Pattern

The router factory decorates Express routers with permission middleware and error handling.


Best Practices

When to Use Factories

Use factories when:

  • Entity requires standard CRUD operations
  • No complex business logic beyond data transformation
  • Permissions follow standard READ/CREATE/UPDATE/DELETE pattern
  • No special authentication requirements

When NOT to Use Factories

Don't use factories when:

  • Complex multi-step workflows (use custom service)
  • Special authentication (OAuth flows, password reset)
  • External API integration (file storage, AI)
  • Cross-entity transactions
  • Custom endpoint patterns

Extending Factory-Generated Code

  1. Add custom routes: Extend the base router after factory call
  2. Add custom service methods: Extend the generated class
  3. Override permissions: Use permissionEntity option
  4. Customize data transformation: Override getFieldMapping() in DBApi