2026-07-03 16:11:24 +02:00

1110 lines
36 KiB
Markdown

# Backend DB API Module
## Overview
The DB API module provides the data access layer that sits between services and Sequelize models. It encapsulates query logic, filtering, pagination, and data transformations using a declarative configuration pattern via the `GenericDBApi` base class.
**Location:** `backend/src/db/api/`
**Files:** 20 files (1 base class + 18 entity APIs + 1 utility)
| File | Class/Purpose | LOC | Extends GenericDBApi |
|------|---------------|-----|---------------------|
| `base.api.ts` | `GenericDBApi` - Base class | 726 | - |
| `users.ts` | `UsersDBApi` - User accounts | 979 | No (custom) |
| `projects.ts` | `ProjectsDBApi` - Projects | ~320 | Yes |
| `tour_pages.ts` | `Tour_pagesDBApi` - Tour pages | ~350 | Yes |
| `assets.ts` | `AssetsDBApi` - Media assets | ~92 | Yes |
| `asset_variants.ts` | `Asset_variantsDBApi` - Asset variants | 82 | Yes |
| `roles.ts` | `RolesDBApi` - RBAC roles | 71 | Yes |
| `permissions.ts` | `PermissionsDBApi` - RBAC permissions | 53 | Yes |
| `project_memberships.ts` | `Project_membershipsDBApi` - Team access | 86 | Yes |
| `element_type_defaults.ts` | `Element_type_defaultsDBApi` - Global defaults | ~409 | Yes |
| `project_element_defaults.ts` | `Project_element_defaultsDBApi` - Project defaults | ~410 | Yes |
| `project_audio_tracks.ts` | `Project_audio_tracksDBApi` - Audio tracks | ~199 | Yes |
| `project_transition_settings.ts` | `Project_transition_settingsDBApi` - Project transition settings | ~277 | Yes |
| `global_transition_defaults.ts` | `Global_transition_defaultsDBApi` - Global transition defaults | ~155 | Yes |
| `global_ui_control_defaults.ts` | `Global_ui_control_defaultsDBApi` - Global UI control defaults | ~160 | Yes |
| `project_ui_control_settings.ts` | `Project_ui_control_settingsDBApi` - Project UI control settings | ~150 | Yes |
| `publish_events.ts` | `Publish_eventsDBApi` - Publishing history | 101 | Yes |
| `pwa_caches.ts` | `Pwa_cachesDBApi` - PWA manifests | 76 | Yes |
| `access_logs.ts` | `Access_logsDBApi` - Audit trail | 88 | Yes |
| `presigned_url_requests.ts` | `Presigned_url_requestsDBApi` - S3 URL audit | 90 | Yes |
| `file.ts` | `FileDBApi` - Polymorphic file attachments | ~95 | No (custom) |
| `runtime-context.ts` | Runtime context helpers | 57 | - |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ DB API Layer Architecture │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────┐
│ Service Layer │
│ (services/*.js) │
└──────────┬──────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ DB API Layer │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ GenericDBApi (base.api.ts) │ │
│ │ │ │
│ │ Static Getters (Configuration): │ │
│ │ • MODEL, TABLE_NAME │ │
│ │ • SEARCHABLE_FIELDS, RANGE_FIELDS, ENUM_FIELDS, UUID_FIELDS │ │
│ │ • ASSOCIATIONS, RELATION_FILTERS │ │
│ │ • FIND_BY_INCLUDES, FIND_ALL_INCLUDES │ │
│ │ • JSON_FIELDS, FIELD_DEFAULTS, FIELD_TRANSFORMERS │ │
│ │ • CSV_FIELDS, AUTOCOMPLETE_FIELD │ │
│ │ │ │
│ │ Methods (CRUD + Query): │ │
│ │ • create(), bulkImport(), update() │ │
│ │ • deleteByIds(), remove() │ │
│ │ • findBy(), findAll(), findAllAutocomplete() │ │
│ │ • getFieldMapping(), toCSV() │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Simple Entity │ │ Runtime-Aware │ │ Fully Custom │ │
│ │ API │ │ API │ │ API │ │
│ │ │ │ │ │ │ │
│ │ • PermissionsDB │ │ • Tour_pagesDB │ │ • UsersDBApi │ │
│ │ • RolesDBApi │ │ • ProjectsDBApi │ │ • FileDBApi │ │
│ │ • AssetsDBApi │ │ • AudioTracksDB │ │ │ │
│ │ │ │ │ │ │ │
│ │ Override only: │ │ Override: │ │ Fully custom │ │
│ │ - Static getters │ │ - Static getters │ │ implementation │ │
│ │ - getFieldMapping│ │ - findBy() │ │ │ │
│ │ │ │ - findAll() │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Supporting Utilities │ │
│ │ runtime-context.ts - Environment/project filtering helpers │ │
│ │ ../utils.js - UUID validation, ILIKE query building │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────┐
│ Sequelize Models │
│ (models/*.js) │
└─────────────────────┘
```
---
## GenericDBApi Base Class
**Location:** `backend/src/db/api/base.api.ts`
The base class provides a Template Method pattern where subclasses configure behavior through static getters and optionally override methods for custom logic.
### Static Getters (Configuration)
| Getter | Type | Default | Description |
|--------|------|---------|-------------|
| `MODEL` | Model | (required) | Sequelize model reference |
| `TABLE_NAME` | string | From MODEL | Database table name |
| `SEARCHABLE_FIELDS` | string[] | `[]` | Fields for ILIKE text search |
| `RANGE_FIELDS` | string[] | `[]` | Fields for range queries (min/max) |
| `ENUM_FIELDS` | string[] | `[]` | Fields for exact match filtering |
| `UUID_FIELDS` | string[] | `[]` | UUID foreign key fields (validated before query) |
| `RELATION_FILTERS` | object[] | `[]` | Related entity filter configs |
| `ASSOCIATIONS` | object[] | `[]` | M:N or belongsTo setters |
| `FIND_BY_INCLUDES` | object[] | `[]` | Includes for findBy() |
| `FIND_ALL_INCLUDES` | object[] | `[]` | Includes for findAll() |
| `CSV_FIELDS` | string[] | `['id', 'createdAt']` | Fields for CSV export |
| `AUTOCOMPLETE_FIELD` | string | `'name'` | Field for autocomplete |
| `JSON_FIELDS` | string[] | `[]` | Fields to auto-stringify |
| `FIELD_DEFAULTS` | object | `{}` | Default values for fields |
| `FIELD_TRANSFORMERS` | object | `{}` | Custom field transformations |
### Methods
#### getFieldMapping(data)
Transforms input data for database operations using declarative configuration:
```javascript
static getFieldMapping(data) {
if (!data) return data;
const mapped = { ...data };
// 1. Apply field defaults
for (const [field, config] of Object.entries(this.FIELD_DEFAULTS)) {
if (mapped[field] === undefined) {
mapped[field] = config.default;
} else if (mapped[field] === null && config.nullDefault !== undefined) {
mapped[field] = config.nullDefault;
}
}
// 2. Auto-stringify JSON fields
for (const field of this.JSON_FIELDS) {
if (mapped[field] !== undefined && mapped[field] !== null) {
if (typeof mapped[field] !== 'string') {
mapped[field] = JSON.stringify(mapped[field]);
}
}
}
// 3. Apply custom transformers
for (const [field, transformer] of Object.entries(this.FIELD_TRANSFORMERS)) {
if (mapped[field] !== undefined) {
mapped[field] = transformer(mapped[field]);
}
}
return mapped;
}
```
#### create(options)
Creates a new record with associations:
```javascript
static async create({ data, currentUser = { id: null }, transaction, runtimeContext }) {
const mappedData = this.getFieldMapping(data);
const record = await this.MODEL.create(
{
...mappedData,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
// Handle M:N and belongsTo associations
for (const assoc of this.ASSOCIATIONS) {
if (data[assoc.field] !== undefined) {
const setter = record[assoc.setter];
await setter.call(
record,
data[assoc.field] || (assoc.isArray ? [] : null),
{ transaction },
);
}
}
return record;
}
```
#### update({ id, data, currentUser, transaction, runtimeContext })
Updates a record with partial data:
```javascript
static async update({ id, data, currentUser = { id: null }, transaction }) {
const record = await this.MODEL.findByPk(id, { transaction });
if (!record) {
throw { status: 404, message: `${this.TABLE_NAME} not found` };
}
const updatePayload = { updatedById: currentUser.id };
const mappedData = this.getFieldMapping(data);
// Only update defined fields
for (const [key, value] of Object.entries(mappedData)) {
if (value !== undefined) {
updatePayload[key] = value;
}
}
await record.update(updatePayload, { transaction });
// Update associations
for (const assoc of this.ASSOCIATIONS) {
if (data[assoc.field] !== undefined) {
const setter = record[assoc.setter];
await setter.call(record, data[assoc.field], { transaction });
}
}
return record;
}
```
#### findAll(filter, options)
Full-featured query with filtering, pagination, and sorting:
```javascript
static async findAll(filter = {}, options = {}) {
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
let include = [...this.FIND_ALL_INCLUDES];
// ID exact match (with UUID validation)
if (filter.id) {
if (!Utils.isValidUuid(filter.id)) {
return { rows: [], count: 0 };
}
where.id = filter.id;
}
// Text search (ILIKE)
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
// Range queries (fieldRange: [min, max])
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start) where[field] = { ...where[field], [Op.gte]: start };
if (end) where[field] = { ...where[field], [Op.lte]: end };
}
}
// Enum exact match
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
// Relation filters (search by related entity ID or name)
for (const rel of this.RELATION_FILTERS) {
if (filter[rel.filterKey]) {
// ... adds required include with where clause
}
}
// createdAt date range
if (filter.createdAtRange) {
// ... applies date range
}
const queryOptions = {
where,
include,
distinct: true,
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return { rows: options.countOnly ? [] : rows, count };
}
```
#### Other Methods
| Method | Description |
|--------|-------------|
| `bulkImport(data, options)` | Bulk create with timestamps offset |
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple records |
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single record |
| `findBy(where, options)` | Find single record by criteria |
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
| `toCSV(rows)` | Convert rows to CSV string |
---
## Query Filter Patterns
### SEARCHABLE_FIELDS - Text Search
Case-insensitive partial match using ILIKE:
```javascript
// Configuration
static get SEARCHABLE_FIELDS() {
return ['name', 'description', 'email'];
}
// Usage
GET /api/users?name=john
// Generates: WHERE LOWER(users.name) LIKE '%john%'
```
### RANGE_FIELDS - Range Queries
Min/max range filtering:
```javascript
// Configuration
static get RANGE_FIELDS() {
return ['sort_order', 'price', 'created_at'];
}
// Usage
GET /api/assets?sort_orderRange=[0,10]
// Generates: WHERE sort_order >= 0 AND sort_order <= 10
GET /api/users?createdAtRange=[2024-01-01,2024-12-31]
// Generates: WHERE createdAt >= '2024-01-01' AND createdAt <= '2024-12-31'
```
### ENUM_FIELDS - Exact Match
Direct equality filtering:
```javascript
// Configuration
static get ENUM_FIELDS() {
return ['environment', 'status', 'asset_type'];
}
// Usage
GET /api/tour_pages?environment=production
// Generates: WHERE environment = 'production'
```
### RELATION_FILTERS - Related Entity Search
Filter by related entity ID or searchable field:
```javascript
// Configuration
static get RELATION_FILTERS() {
return [
{
filterKey: 'project', // Query param name
model: db.projects, // Related model
as: 'project', // Association alias
searchField: 'name', // Field for text search
},
];
}
// Usage
GET /api/assets?project=abc123
// Filters by project ID: abc123
GET /api/assets?project=my-tour
// Filters by project name containing "my-tour"
GET /api/assets?project=abc123|my-tour
// Pipe-separated: matches either
```
---
## API Categories
### 1. Simple Entity APIs
Extend `GenericDBApi` with minimal configuration. Only override static getters and `getFieldMapping()`.
**Example: PermissionsDBApi**
```typescript
class PermissionsDBApi extends GenericDBApi {
static override get MODEL(): unknown { return db.permissions; }
static override get TABLE_NAME(): string { return 'permissions'; }
static override get SEARCHABLE_FIELDS(): string[] { return ['name']; }
static override get CSV_FIELDS(): string[] { return ['id', 'name', 'createdAt']; }
static override get AUTOCOMPLETE_FIELD(): string { return 'name'; }
static override getFieldMapping(data: PermissionData): PermissionFieldMapping {
return {
id: data.id || undefined,
name: data.name || null,
};
}
}
```
**Entities using this pattern:**
- `PermissionsDBApi`
- `AssetsDBApi`
- `Asset_variantsDBApi`
- `Publish_eventsDBApi`
- `Pwa_cachesDBApi`
- `Access_logsDBApi`
- `Presigned_url_requestsDBApi`
### 2. Runtime-Aware APIs
Override `findBy()` and `findAll()` to apply runtime environment/project filters for public presentation access.
**Example: Tour_pagesDBApi**
```javascript
class Tour_pagesDBApi extends GenericDBApi {
// ... configuration getters ...
static async findBy(where, options = {}) {
const queryWhere = applyRuntimeEnvironment({ ...where }, options);
const projectInclude = applyRuntimeProjectFilter(
{ model: db.projects, as: 'project' },
options,
);
const record = await this.MODEL.findOne({
where: queryWhere,
include: [projectInclude],
});
return record ? record.get({ plain: true }) : null;
}
static async findAll(filter = {}, options = {}) {
// ... standard filtering logic ...
// Apply runtime environment filter
where = applyRuntimeEnvironment(where, options);
// Apply project slug filter to include
include[0] = applyRuntimeProjectFilter(include[0], options);
// ... execute query ...
}
}
```
**Example: ProjectsDBApi**
`ProjectsDBApi` uses runtime slug filtering but with two unique behaviors:
1. **Auto-snapshot on create**: Copies global element defaults to new projects
2. **Slug filter skipped for ID lookups**: Prevents stale `X-Runtime-Project-Slug` headers from breaking ID-based queries
```javascript
class ProjectsDBApi extends GenericDBApi {
// ... configuration getters ...
static async findBy(where, options = {}) {
const runtimeProjectSlug = getRuntimeProjectSlug(options);
const queryWhere = { ...where };
// Runtime access: filter by project slug
// Skip if finding by ID (unambiguous lookup)
if (runtimeProjectSlug && !where.id) {
queryWhere.slug = runtimeProjectSlug;
}
const record = await this.MODEL.findOne({
where: queryWhere,
include: options.include ?? this.DEFAULT_INCLUDES,
});
return record ? record.get({ plain: true }) : null;
}
static async create(options) {
const { transaction } = options;
// Create the project using parent's create
const project = await super.create(options);
// Auto-snapshot global element defaults to the new project
await Project_element_defaultsDBApi.snapshotGlobalDefaults(project.id, {
...options,
transaction,
});
return project;
}
static async findAll(filter = {}, options = {}) {
// ... standard filtering logic ...
// Runtime access: filter by project slug (no ID bypass needed for list)
const runtimeProjectSlug = getRuntimeProjectSlug(options);
if (runtimeProjectSlug) {
where.slug = runtimeProjectSlug;
}
// ... execute query ...
}
}
```
**Entities using this pattern:**
- `Tour_pagesDBApi` - Environment filtering via `applyRuntimeEnvironment()`
- `ProjectsDBApi` - Slug filtering with ID bypass and auto-snapshot on create
- `Project_audio_tracksDBApi` - Environment filtering
### 3. APIs with Custom Methods
Extend base functionality with domain-specific methods.
**Example: Element_type_defaultsDBApi**
```javascript
class Element_type_defaultsDBApi extends GenericDBApi {
// ... configuration ...
// Self-initialization pattern
static async ensureInitialized() {
if (!this.initializationPromise) {
this.initializationPromise = (async () => {
const count = await this.MODEL.count();
if (count > 0) return;
// Seed default rows
await this.MODEL.bulkCreate(
this.DEFAULT_ROWS.map((item) => this.getFieldMapping(item)),
);
})();
}
await this.initializationPromise;
}
// Override all methods to ensure initialization
static async create(options) {
await this.ensureInitialized();
return super.create(options);
}
// ... similar overrides for other methods ...
// Default data configuration
static get DEFAULT_ROWS() {
return [
{ element_type: 'navigation_next', name: 'Navigation Forward Button', ... },
{ element_type: 'tooltip', name: 'Tooltip', ... },
// ... 11 default element types
];
}
}
```
**Example: Project_element_defaultsDBApi**
```javascript
class Project_element_defaultsDBApi extends GenericDBApi {
// ... configuration ...
// Snapshot global defaults to a project
static async snapshotGlobalDefaults(projectId, options = {}) {
const globalDefaults = await Element_type_defaultsDBApi.findAll({});
return this.MODEL.bulkCreate(
globalDefaults.rows.map((global) => ({
projectId,
element_type: global.element_type,
settings_json: global.default_settings_json,
source_element_id: global.id,
snapshot_version: 1,
})),
{ transaction: options.transaction },
);
}
// Reset project default to current global
static async resetToGlobal(id, options = {}) {
const projectDefault = await this.MODEL.findByPk(id);
const globalDefault = await Element_type_defaultsDBApi.MODEL.findOne({
where: { element_type: projectDefault.element_type },
});
await projectDefault.update({
settings_json: globalDefault.default_settings_json,
snapshot_version: projectDefault.snapshot_version + 1,
});
return projectDefault.reload();
}
// Compare project default with global
static async getDiffFromGlobal(id) {
// ... returns diff object
}
}
```
### 4. Fully Custom APIs
Don't extend `GenericDBApi` due to significantly different requirements.
**Example: UsersDBApi**
Complex user management with:
- Password hashing (bcrypt)
- File avatar handling
- Token generation for email verification and password reset
- OAuth authentication support
```typescript
class UsersDBApi {
static async create(options: UserCreateOptions): Promise<UserRecord> {
const { data, currentUser = { id: null }, transaction } = options;
const users = await db.users.create({
firstName: data.firstName || null,
lastName: data.lastName || null,
email: data.email || null,
password: data.password || null, // Already hashed by service
// ...
}, { transaction });
// Auto-assign default role
if (!data.app_role) {
const role = await db.roles.findOne({ where: { name: 'User' } });
await users.setApp_role(role, { transaction });
}
// Handle avatar file
await FileDBApi.replaceRelationFiles(
{ belongsTo: 'users', belongsToColumn: 'avatar', belongsToId: users.id },
data.data.avatar,
options,
);
return users;
}
static async update({ id, data, currentUser, transaction, runtimeContext }) {
const users = await db.users.findByPk(id, { transaction });
// Hash password if changed
if (data.password) {
data.password = bcrypt.hashSync(data.password, config.bcrypt.saltRounds);
} else {
data.password = users.password;
}
// ... update fields ...
}
// Token generation
static async generateEmailVerificationToken(email, options) {
return this._generateToken(
['emailVerificationToken', 'emailVerificationTokenExpiresAt'],
email,
options,
);
}
static async _generateToken(keyNames, email, options) {
const users = await db.users.findOne({ where: { email: email.toLowerCase() } });
const token = crypto.randomBytes(20).toString('hex');
const tokenExpiresAt = Date.now() + (24 * 60 * 60 * 1000); // 24 hours
await users.update({
[keyNames[0]]: token,
[keyNames[1]]: tokenExpiresAt,
});
return token;
}
// Token lookup
static async findByPasswordResetToken(token, options) {
return db.users.findOne({
where: {
passwordResetToken: token,
passwordResetTokenExpiresAt: { [Op.gt]: Date.now() },
},
});
}
};
```
**Example: FileDBApi**
Polymorphic file attachment handling:
```typescript
export default class FileDBApi {
static async replaceRelationFiles(relation, rawFiles, options = {}) {
assert(relation.belongsTo);
assert(relation.belongsToColumn);
assert(relation.belongsToId);
const files = Array.isArray(rawFiles) ? rawFiles : rawFiles ? [rawFiles] : [];
await this._removeLegacyFiles(relation, files, options);
await this._addFiles(relation, files, options);
}
static async _addFiles(relation, files, options) {
const inexistentFiles = files.filter((file) => !!file.new);
for (const file of inexistentFiles) {
await db.file.create({
belongsTo: relation.belongsTo,
belongsToColumn: relation.belongsToColumn,
belongsToId: relation.belongsToId,
name: file.name,
publicUrl: file.publicUrl,
privateUrl: file.privateUrl,
}, { transaction: options.transaction });
}
}
static async _removeLegacyFiles(relation, files, options) {
const filesToDelete = await db.file.findAll({
where: {
belongsTo: relation.belongsTo,
belongsToId: relation.belongsToId,
belongsToColumn: relation.belongsToColumn,
id: { [Op.notIn]: files.filter((f) => !f.new).map((f) => f.id) },
},
});
for (const file of filesToDelete) {
await services.deleteFile(file.privateUrl);
await file.destroy({ transaction: options.transaction });
}
}
};
```
---
## Runtime Context Helpers
**Location:** `backend/src/db/api/runtime-context.ts`
Utilities for filtering queries based on runtime presentation context (public tour access).
```javascript
// Get runtime environment from options.runtimeContext
function getRuntimeEnvironment(options = {}) {
const runtimeContext = options.runtimeContext || null;
if (!runtimeContext) return null;
// SECURITY: Only allow 'production' and 'stage' from header
if (runtimeContext.headerEnvironment === 'production') return 'production';
if (runtimeContext.headerEnvironment === 'stage') return 'stage';
return null;
}
// Get project slug from runtime context
function getRuntimeProjectSlug(options = {}) {
const runtimeContext = options.runtimeContext || null;
return runtimeContext?.headerProjectSlug || null;
}
// Apply environment filter to where clause
function applyRuntimeEnvironment(where = {}, options = {}) {
const environment = getRuntimeEnvironment(options);
if (!environment) return where;
return { ...where, environment };
}
// Apply project slug filter to include
function applyRuntimeProjectFilter(projectInclude = {}, options = {}) {
const projectSlug = getRuntimeProjectSlug(options);
if (!projectSlug) return projectInclude;
return {
...projectInclude,
required: true,
where: { ...(projectInclude.where || {}), slug: projectSlug },
};
}
```
**Usage:**
```javascript
// In runtime-aware API
static async findAll(filter = {}, options = {}) {
let where = { /* ... */ };
let include = [{ model: db.projects, as: 'project' }];
// Apply runtime filters
where = applyRuntimeEnvironment(where, options);
include[0] = applyRuntimeProjectFilter(include[0], options);
// ... execute query
}
```
---
## DB Utils
**Location:** `backend/src/db/utils.js`
Utility functions for UUID validation and query building:
```javascript
const validator = require('validator');
const { v4: uuidv4 } = require('uuid');
module.exports = class Utils {
// Check if value is a valid UUID
static isValidUuid(value) {
return Boolean(value && validator.isUUID(String(value)));
}
// Generate a new UUID v4
static generateUuid() {
return uuidv4();
}
// Filter array to only valid UUIDs
static filterValidUuids(values) {
return values.filter((v) => this.isValidUuid(v));
}
// Case-insensitive LIKE query
static ilike(model, column, value) {
return Sequelize.where(
Sequelize.fn('lower', Sequelize.col(`${model}.${column}`)),
{ [Sequelize.Op.like]: `%${value}%`.toLowerCase() },
);
}
};
```
**UUID Utility Functions:**
| Function | Purpose | Returns |
|----------|---------|---------|
| `isValidUuid(value)` | Check if valid UUID | `boolean` |
| `generateUuid()` | Create new UUID v4 | `string` |
| `filterValidUuids(values)` | Filter array to valid UUIDs only | `string[]` |
| `ilike(model, column, value)` | Case-insensitive search | Sequelize where clause |
**UUID Validation Behavior:**
- Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately
- Invalid UUID in relation filter (`?project=uuid|name`) → filters out invalid UUIDs for ID search, keeps all terms for text search
- Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately
---
## Declarative Configuration Examples
### Simple Entity
```javascript
class AssetsDBApi extends GenericDBApi {
static get MODEL() { return db.assets; }
static get TABLE_NAME() { return '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'];
}
// UUID foreign key fields - validated before querying
static get UUID_FIELDS() {
return ['projectId'];
}
static get ASSOCIATIONS() {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static get FIND_BY_INCLUDES() {
return [
{ association: 'asset_variants_asset' },
{ association: 'project' },
];
}
static get RELATION_FILTERS() {
return [
{ filterKey: 'project', model: db.projects, as: 'project', searchField: 'name' },
];
}
static getFieldMapping(data) {
return {
name: data.name || null,
asset_type: data.asset_type || null,
type: data.type || 'general',
cdn_url: data.cdn_url || null,
storage_key: data.storage_key || null,
mime_type: data.mime_type || null,
size_mb: data.size_mb || null,
width_px: data.width_px || null,
height_px: data.height_px || null,
duration_sec: data.duration_sec || null,
checksum: data.checksum || null,
is_public: data.is_public || false,
};
}
}
```
### Foreign Key Fields in getFieldMapping
**Important:** Foreign key fields (like `assetId`, `projectId`) must be included in `getFieldMapping()` if they are passed directly to `create()` or `update()`. Without this, the foreign key value is silently dropped and the record is created without the relationship.
**Two ways to handle foreign keys:**
1. **Direct field mapping** (preferred for programmatic use):
```javascript
// In getFieldMapping()
static getFieldMapping(data) {
return {
assetId: data.assetId || null, // Include FK field directly
variant_type: data.variant_type || null,
// ...
};
}
// In service
await Asset_variantsDBApi.create({ assetId: asset.id, ... });
```
2. **Via ASSOCIATIONS setter** (used by frontend forms):
```javascript
// ASSOCIATIONS config uses 'asset' (relation name)
static get ASSOCIATIONS() {
return [{ field: 'asset', setter: 'setAsset', isArray: false }];
}
// Service passes 'asset' instead of 'assetId'
await Asset_variantsDBApi.create({ asset: asset.id, ... });
// GenericDBApi.create() calls record.setAsset(asset.id) with record as `this`
```
**Common mistake:** Passing `assetId` when only `asset` association is configured (or vice versa) results in orphaned records with NULL foreign keys.
**Implementation detail:** Sequelize association mixins rely on the model
instance as `this`. `GenericDBApi` must call association setters as bound
record methods (`setter.call(record, ...)`) rather than detached functions;
otherwise belongsTo setters such as `setProject`/`setAsset` receive an
undefined source instance and fail before the foreign key can be saved.
### Entity with M:N Relations
```typescript
class RolesDBApi extends GenericDBApi {
static override get MODEL(): unknown { return db.roles; }
static override get ASSOCIATIONS(): RoleAssociationConfig[] {
return [{ field: 'permissions', setter: 'setPermissions', isArray: true }];
}
static override get FIND_BY_INCLUDES(): unknown[] {
return [
{ association: 'users_app_role' },
{ association: 'permissions' },
];
}
static override get FIND_ALL_INCLUDES(): unknown[] {
return [
{ model: db.permissions, as: 'permissions', required: false },
];
}
static get RELATION_FILTERS() {
return [
{ filterKey: 'permissions', model: db.permissions, as: 'permissions_filter', searchField: 'name' },
];
}
}
```
### Entity with JSON Fields and Defaults
```javascript
class Element_type_defaultsDBApi extends GenericDBApi {
// Auto-stringify JSON fields
static get JSON_FIELDS() {
return ['default_settings_json'];
}
// Field defaults
static get FIELD_DEFAULTS() {
return {
element_type: { default: null },
name: { default: null },
sort_order: { default: 0 },
};
}
static getFieldMapping(data) {
// Apply base class transformations first
const mapped = super.getFieldMapping(data);
return {
id: mapped.id || undefined,
element_type: mapped.element_type,
name: mapped.name,
sort_order: mapped.sort_order,
default_settings_json: mapped.default_settings_json,
};
}
}
```
---
## API Summary Table
| API | Pattern | Getters | Custom Methods | Notes |
|-----|---------|---------|----------------|-------|
| `PermissionsDBApi` | Simple | 6 | 0 | Minimal config |
| `AssetsDBApi` | Simple | 9 | 0 | With associations |
| `RolesDBApi` | Simple | 10 | 0 | M:N permissions |
| `ProjectsDBApi` | Runtime-aware | 10 | 3 | Auto-snapshot on create, slug filter skipped for ID lookups |
| `Tour_pagesDBApi` | Runtime-aware | 9 | 0 | Environment filtering |
| `Project_audio_tracksDBApi` | Runtime-aware | 8 | 0 | Environment filtering |
| `Element_type_defaultsDBApi` | Self-init | 9 | 1 | Default seeding |
| `Project_element_defaultsDBApi` | Extended | 10 | 4 | Snapshot, reset, diff |
| `UsersDBApi` | Fully custom | - | 12 | Auth, tokens, files |
| `FileDBApi` | Fully custom | - | 3 | Polymorphic files |
---
## Best Practices
### When to Extend GenericDBApi
- Standard CRUD operations needed
- Filtering by searchable/range/enum fields
- Pagination and sorting
- CSV export
- Autocomplete
### When to Override Methods
- Custom runtime environment filtering
- Special business logic during create/update
- Complex association handling
- Self-initialization patterns
### When to Use Fully Custom API
- Complex authentication logic (password hashing, tokens)
- Polymorphic associations (FileDBApi)
- Significantly different query patterns
- Multiple specialized methods
---
## Related Documentation
- [DB Models Module](./db-models.md) - Sequelize model definitions
- [Factories Module](./factories.md) - Service/router factories that use APIs
- [Services Module](./services.md) - Business logic layer
- [Middleware Module](./middleware.md) - Runtime context middleware