1110 lines
36 KiB
Markdown
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
|