847 lines
28 KiB
Markdown
847 lines
28 KiB
Markdown
# 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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
// 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):**
|
|
```typescript
|
|
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):**
|
|
```typescript
|
|
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):**
|
|
```javascript
|
|
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):**
|
|
```typescript
|
|
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):**
|
|
```typescript
|
|
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:
|
|
|
|
```javascript
|
|
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
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- [Services Module](./services.md) - Business logic layer
|
|
- [Routes Module](./routes.md) - All route files
|
|
- [Middleware Module](./middleware.md) - Permission checking
|
|
- [Database Schema](../database-schema.md) - Model definitions
|