37 KiB
Backend DB Models Module
Overview
The DB Models module defines the Sequelize ORM models that map to PostgreSQL database tables. It provides the data layer for the Tour Builder Platform, handling entity definitions, relationships, validations, and lifecycle hooks.
Location: backend/src/db/models/
Files: 22 model-loader entries (20 models + loader + index bridge). During
the backend TS/ESM migration, model entries have a typed .ts source plus a
typed ESM source file. There is no model-level CommonJS compatibility facade.
| File | Model | Purpose | LOC |
|---|---|---|---|
index.ts |
- | ESM entrypoint re-exporting loader.ts |
1 |
loader.ts |
- | Typed model registry and Sequelize initialization | 128 |
users.ts + .js bridge |
users |
User accounts with authentication | 246 |
projects.ts + .js bridge |
projects |
Virtual tour projects | 211 |
production_presentation_access.ts + .js bridge |
production_presentation_access |
Customer grants for private production presentations | 67 |
tour_pages.ts + .js bridge |
tour_pages |
Individual tour pages with UI schema | 131 |
assets.ts + .js bridge |
assets |
Uploaded media files | 169 |
asset_variants.ts + .js bridge |
asset_variants |
Asset size/format variants | 103 |
roles.ts + roles.js bridge |
roles |
RBAC roles | 85 |
permissions.ts + permissions.js bridge |
permissions |
RBAC permissions | 52 |
project_memberships.ts + .js bridge |
project_memberships |
User-project access | 89 |
publish_events.ts + .js bridge |
publish_events |
Publishing history | 148 |
pwa_caches.ts + .js bridge |
pwa_caches |
PWA offline cache manifests | 84 |
access_logs.ts + .js bridge |
access_logs |
Activity audit trail | 105 |
element_type_defaults.ts + .js bridge |
element_type_defaults |
Global UI element defaults | 91 |
project_element_defaults.ts + .js bridge |
project_element_defaults |
Project-specific element defaults | 101 |
project_audio_tracks.ts + .js bridge |
project_audio_tracks |
Background audio tracks | 103 |
project_transition_settings.ts + .js bridge |
project_transition_settings |
Environment-aware CSS transition settings | 95 |
global_transition_defaults.ts + .js bridge |
global_transition_defaults |
Platform defaults for CSS page transitions | 65 |
global_ui_control_defaults.ts + .js bridge |
global_ui_control_defaults |
Platform defaults for fullscreen, sound, and offline controls | 33 |
project_ui_control_settings.ts + .js bridge |
project_ui_control_settings |
Project/environment overrides for global UI controls | 61 |
presigned_url_requests.ts + .js bridge |
presigned_url_requests |
S3 presigned URL audit | 118 |
file.ts + .js bridge |
file |
Generic file attachments | 53 |
Architecture
TS/ESM Migration Boundary
The model loader uses explicit typed ESM imports from backend/src/db/models/.
Migrated model definitions use this structure:
model-name.tscontains the typed ESM model factory.loader.tsexplicitly imports every model factory and builds the Sequelize registry without dynamic CommonJS discovery.index.tsis the ESM entrypoint toloader.tsused by DB consumers.backend/src/types/db-models.tskeeps the service-specific overload facade as reusable TypeScript contracts.- Shared model factory contracts live in
backend/src/types/sequelize-models.tsand use official Sequelize types.
As of 2026-07-01, all model definitions and the model loader use typed ESM
source. users.ts keeps the hook payload typed through reusable
UserModelInstance and UsersSequelizeModel contracts without type
assertions.
npm run check:esm-boundaries guards this boundary: new .js source or
CommonJS syntax in .ts fails the check unless it is an explicitly documented
bridge or immutable migration.
┌─────────────────────────────────────────────────────────────────────────┐
│ Model Loading Flow │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ index.ts │
│ (entrypoint) │
└────────┬────────┘
│
│ 1. require('./loader.ts')
▼
┌─────────────────┐
│ loader.ts │
└────────┬────────┘
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Model Files │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐ │
│ │ users │ │projects │ │ assets │ │ roles │ │ tour_pages │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └──────┬──────┘ │
│ │ │ │ │ │ │
│ └───────────┴───────────┴───────────┴─────────────┘ │
│ │ │
└───────────────────────────────┼──────────────────────────────────────┘
│
3. Call model.associate(db) for each model
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Associations Created │
│ │
│ users ─────────────┬─── belongsTo ───→ roles (app_role) │
│ ├─── hasMany ─────→ project_memberships │
│ ├─── hasMany ─────→ production_presentation_access│
│ ├─── hasMany ─────→ publish_events │
│ └─── belongsToMany → permissions (custom) │
│ │
│ projects ──────────┬─── hasMany ─────→ tour_pages │
│ ├─── hasMany ─────→ assets │
│ ├─── hasMany ─────→ project_memberships │
│ ├─── hasMany ─────→ production_presentation_access│
│ └─── hasMany ─────→ publish_events │
│ │
│ roles ─────────────┬─── belongsToMany → permissions │
│ └─── hasMany ─────→ users │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Exported db Object │
│ { │
│ users, projects, tour_pages, assets, roles, permissions, │
│ project_memberships, publish_events, pwa_caches, access_logs, │
│ element_type_defaults, project_element_defaults, ... │
│ sequelize, // Connection instance │
│ Sequelize // Sequelize library │
│ } │
└─────────────────────────────────────────────────────────────────────┘
Model Loader
Locations:
backend/src/db/models/loader.tsbackend/src/db/models/index.tsbackend/src/types/db-models.ts
loader.ts initializes Sequelize, explicitly imports every model factory, and
calls associate() for models that define associations. index.ts re-exports
the typed registry, and src/types/db-models.ts provides the overload surface
for service-specific model calls.
Database Configuration
Location: backend/src/db/db-config.ts
| Environment | Database | Logging | Notes |
|---|---|---|---|
production |
From env vars | Disabled | Live production |
development |
db_tour_builder_platform |
Console | Local dev |
dev_stage |
From env vars | Console | Staging server |
Model Definitions
Common Model Options
All models share these Sequelize options:
{
timestamps: true, // Adds createdAt, updatedAt
paranoid: true, // Soft delete with deletedAt
freezeTableName: true, // Use exact model name as table name
}
Common Fields
| Field | Type | Description |
|---|---|---|
id |
UUID |
Primary key (auto-generated UUIDv4) |
importHash |
STRING(255) |
Unique hash for bulk import deduplication |
createdAt |
DATE |
Auto-managed creation timestamp |
updatedAt |
DATE |
Auto-managed update timestamp |
deletedAt |
DATE |
Soft delete timestamp (paranoid mode) |
Common Associations
Most models have these standard associations:
// Audit trail associations
db.MODEL.belongsTo(db.users, { as: 'createdBy' });
db.MODEL.belongsTo(db.users, { as: 'updatedBy' });
Core Models
users
Purpose: User accounts for authentication and authorization.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
firstName |
TEXT | Yes | - | Trimmed |
lastName |
TEXT | Yes | - | Trimmed |
phoneNumber |
TEXT | Yes | - | - |
email |
TEXT | No | - | isEmail, notEmpty, unique |
password |
TEXT | No | - | Hashed with bcrypt |
disabled |
BOOLEAN | No | false | - |
emailVerified |
BOOLEAN | No | false | - |
emailVerificationToken |
TEXT | Yes | - | - |
emailVerificationTokenExpiresAt |
DATE | Yes | - | - |
passwordResetToken |
TEXT | Yes | - | - |
passwordResetTokenExpiresAt |
DATE | Yes | - | - |
provider |
TEXT | No | 'local' | OAuth provider |
app_roleId |
UUID | Yes | - | FK to roles |
Indexes:
email(unique)app_roleIddeletedAt
Associations:
users.belongsTo(roles, { as: 'app_role' });
users.belongsToMany(permissions, { as: 'custom_permissions', through: 'usersCustom_permissionsPermissions' });
users.hasMany(project_memberships, { as: 'project_memberships_user' });
users.hasMany(presigned_url_requests, { as: 'presigned_url_requests_user' });
users.hasMany(publish_events, { as: 'publish_events_user' });
users.hasMany(access_logs, { as: 'access_logs_user' });
users.hasMany(file, { as: 'avatar', scope: { belongsTo: 'users', belongsToColumn: 'avatar' } });
Hooks:
users.beforeCreate((user) => {
// Trim string fields
// Auto-verify email for OAuth providers
// Generate random password for OAuth if not provided
});
users.beforeUpdate((user) => {
// Trim string fields
});
projects
Purpose: Virtual tour projects container.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
name |
TEXT | No | - | notEmpty, len[1,255] |
slug |
TEXT | No | - | notEmpty, unique, alphanumeric + dashes/underscores |
description |
TEXT | Yes | - | - |
logo_url |
TEXT | Yes | - | - |
favicon_url |
TEXT | Yes | - | - |
og_image_url |
TEXT | Yes | - | - |
production_presentation_visibility |
ENUM | No | public | public, private |
Indexes:
slug(unique)deletedAt
Associations:
projects.hasMany(project_memberships, { as: 'project_memberships_project', onDelete: 'CASCADE' });
projects.hasMany(assets, { as: 'assets_project', onDelete: 'CASCADE' });
projects.hasMany(presigned_url_requests, { as: 'presigned_url_requests_project', onDelete: 'CASCADE' });
projects.hasMany(tour_pages, { as: 'tour_pages_project', onDelete: 'CASCADE' });
projects.hasMany(project_audio_tracks, { as: 'project_audio_tracks_project', onDelete: 'CASCADE' });
projects.hasMany(project_transition_settings, { as: 'project_transition_settings_project', onDelete: 'CASCADE' });
projects.hasMany(publish_events, { as: 'publish_events_project', onDelete: 'CASCADE' });
projects.hasMany(pwa_caches, { as: 'pwa_caches_project', onDelete: 'CASCADE' });
projects.hasMany(access_logs, { as: 'access_logs_project', onDelete: 'CASCADE' });
projects.hasMany(project_element_defaults, { as: 'project_element_defaults_project', onDelete: 'CASCADE' });
projects.hasMany(production_presentation_access, { as: 'production_presentation_access_project', onDelete: 'CASCADE' });
production_presentation_access
Purpose: Grants Public-role customer users access to selected private production presentations.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
projectId |
UUID | No | - | FK to projects |
userId |
UUID | No | - | FK to users |
createdById |
UUID | Yes | - | FK to users |
updatedById |
UUID | Yes | - | FK to users |
importHash |
STRING(255) | Yes | - | unique |
Indexes:
projectIduserIdprojectId, userIdunique for active rows
Associations:
production_presentation_access.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
production_presentation_access.belongsTo(users, { as: 'user', onDelete: 'CASCADE' });
production_presentation_access.belongsTo(users, { as: 'createdBy', onDelete: 'SET NULL' });
production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SET NULL' });
tour_pages
Purpose: Individual pages within a tour with UI elements schema.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
environment |
ENUM | No | 'dev' | dev, stage, production |
source_key |
TEXT | Yes | - | Original page ID for cloning |
name |
TEXT | No | - | notEmpty, len[1,255] |
slug |
TEXT | No | - | notEmpty, alphanumeric + dashes |
sort_order |
INTEGER | No | 0 | - |
background_image_url |
TEXT | Yes | - | - |
background_video_url |
TEXT | Yes | - | - |
background_audio_url |
TEXT | Yes | - | - |
background_loop |
BOOLEAN | No | false | - |
requires_auth |
BOOLEAN | No | false | - |
ui_schema_json |
JSON | Yes | - | Page elements, links, transitions |
projectId |
UUID | Yes | - | FK to projects |
Indexes:
projectId[projectId, environment, slug](unique) - Composite unique per project+environment[projectId, environment, sort_order]- For ordering queriesdeletedAt
Note: The ui_schema_json field stores all page elements (buttons, hotspots, galleries, tooltips, media players), navigation links, and transition configurations.
roles
Purpose: RBAC role definitions.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
name |
TEXT | No | - | notEmpty, len[1,100] |
role_customization |
TEXT | Yes | - | Custom role metadata |
Associations:
roles.belongsToMany(permissions, { as: 'permissions', through: 'rolesPermissionsPermissions' });
roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
permissions
Purpose: Individual permission definitions.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
name |
TEXT | No | - | notEmpty, unique, len[1,100] |
Permission Naming Convention: {ACTION}_{ENTITY} (e.g., READ_USERS, CREATE_ASSETS)
Asset Models
assets
Purpose: Uploaded media files (images, videos, audio, documents).
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
name |
TEXT | Yes | - | len[0,255] |
asset_type |
ENUM | No | - | image, video, audio, file |
type |
ENUM | No | 'general' | icon, background_image, audio, video, transition, logo, favicon, document, general |
cdn_url |
TEXT | Yes | - | - |
storage_key |
TEXT | Yes | - | S3/storage path |
mime_type |
TEXT | Yes | - | MIME type format |
size_mb |
DECIMAL | Yes | - | - |
width_px |
INTEGER | Yes | - | Image/video width |
height_px |
INTEGER | Yes | - | Image/video height |
duration_sec |
DECIMAL | Yes | - | Audio/video duration |
checksum |
TEXT | Yes | - | File hash |
is_public |
BOOLEAN | No | false | - |
projectId |
UUID | Yes | - | FK to projects |
Indexes:
projectIdasset_typetypeis_publicdeletedAt
Associations:
assets.hasMany(asset_variants, { as: 'asset_variants_asset', onDelete: 'CASCADE' });
assets.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
asset_variants
Purpose: Optimized versions of assets (thumbnails, different formats).
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
variant_type |
ENUM | Yes | - | thumbnail, preview, webp, mp4_low, mp4_high, original |
cdn_url |
TEXT | Yes | - | len[0,2048], URL format |
width_px |
INTEGER | Yes | - | min: 0 |
height_px |
INTEGER | Yes | - | min: 0 |
size_mb |
DECIMAL | Yes | - | min: 0 |
assetId |
UUID | Yes | - | FK to assets |
Associations:
asset_variants.belongsTo(assets, { as: 'asset', onDelete: 'CASCADE' });
Element Defaults Models
element_type_defaults
Purpose: Global platform-wide default settings for UI element types.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
element_type |
TEXT | No | - | notEmpty, unique, len[1,100] |
name |
TEXT | No | - | notEmpty, len[1,255] |
sort_order |
INTEGER | No | 0 | - |
is_active |
VIRTUAL | - | true | Always returns true |
default_settings_json |
TEXT | Yes | - | Mapped from settings_json column |
Indexes:
element_typesort_orderdeletedAt
Associations:
element_type_defaults.hasMany(project_element_defaults, {
as: 'project_defaults',
foreignKey: 'source_element_id',
onDelete: 'SET NULL'
});
Element Types: button, hotspot, gallery, tooltip, video_player, audio_player, image, text, link, form, iframe
project_element_defaults
Purpose: Project-specific overrides for element defaults.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
element_type |
TEXT | No | - | notEmpty, len[1,100] |
name |
TEXT | Yes | - | len[0,255] |
sort_order |
INTEGER | No | 0 | - |
settings_json |
TEXT | Yes | - | Element configuration |
source_element_id |
UUID | Yes | - | FK to element_type_defaults |
snapshot_version |
INTEGER | No | 1 | Version tracking |
projectId |
UUID | No | - | FK to projects |
Indexes:
projectId[projectId, element_type](unique)element_typesource_element_iddeletedAt
Associations:
project_element_defaults.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
project_element_defaults.belongsTo(element_type_defaults, {
as: 'source_element',
onDelete: 'SET NULL'
});
Publishing & Audit Models
publish_events
Purpose: Track publishing actions between environments.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
title |
STRING | Yes | - | len[0,255] |
description |
TEXT | Yes | - | len[0,5000] |
from_environment |
ENUM | No | - | dev, stage, production |
to_environment |
ENUM | No | - | dev, stage, production |
started_at |
DATE | Yes | - | - |
finished_at |
DATE | Yes | - | - |
status |
ENUM | No | 'queued' | queued, running, success, failed |
error_message |
TEXT | Yes | - | - |
pages_copied |
INTEGER | Yes | - | min: 0 |
transitions_copied |
INTEGER | Yes | - | min: 0 |
audios_copied |
INTEGER | Yes | - | min: 0 |
projectId |
UUID | Yes | - | FK to projects |
userId |
UUID | Yes | - | FK to users |
Indexes:
projectIduserIdstatusstarted_at
access_logs
Purpose: Audit trail for user activity.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
environment |
ENUM | No | - | admin, stage, production |
path |
TEXT | Yes | - | len[0,2048] |
ip_address |
TEXT | Yes | - | len[0,45] (IPv6 max) |
user_agent |
TEXT | Yes | - | len[0,1024] |
accessed_at |
DATE | No | NOW | - |
projectId |
UUID | Yes | - | FK to projects |
userId |
UUID | Yes | - | FK to users |
Indexes:
projectIdenvironmentuserIdaccessed_at
Supporting Models
project_memberships
Purpose: User access to projects with role-based permissions.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
access_level |
ENUM | No | 'viewer' | owner, editor, reviewer, viewer |
is_active |
BOOLEAN | No | false | - |
invited_at |
DATE | Yes | - | - |
accepted_at |
DATE | Yes | - | - |
projectId |
UUID | Yes | - | FK to projects |
userId |
UUID | Yes | - | FK to users |
Indexes:
projectIduserId[projectId, userId](unique) - One membership per user per projectis_activedeletedAt
project_audio_tracks
Purpose: Background audio tracks for projects.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
environment |
ENUM | Yes | - | dev, stage, production |
source_key |
TEXT | Yes | - | Original track ID for cloning |
name |
TEXT | Yes | - | len[0,255] |
slug |
TEXT | Yes | - | - |
url |
TEXT | Yes | - | - |
loop |
BOOLEAN | No | false | - |
volume |
DECIMAL | Yes | - | min: 0, max: 1 |
sort_order |
INTEGER | Yes | - | - |
is_enabled |
BOOLEAN | No | false | - |
projectId |
UUID | Yes | - | FK to projects |
project_transition_settings
Purpose: Environment-aware CSS transition settings for page navigation.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
environment |
ENUM | No | - | dev, stage, production |
source_key |
TEXT | Yes | - | Original settings ID for cloning |
transition_type |
TEXT | No | 'fade' | CSS transition type |
duration_ms |
INTEGER | No | 700 | Transition duration in ms |
easing |
TEXT | No | 'ease-in-out' | CSS easing function |
overlay_color |
TEXT | No | '#000000' | Transition overlay color |
projectId |
UUID | No | - | FK to projects |
createdById |
UUID | Yes | - | FK to users |
updatedById |
UUID | Yes | - | FK to users |
Indexes:
[projectId, environment](unique where deletedAt IS NULL)projectIddeletedAt
Associations:
project_transition_settings.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
project_transition_settings.belongsTo(users, { as: 'createdBy' });
project_transition_settings.belongsTo(users, { as: 'updatedBy' });
Publishing Integration: Copied between environments during Save to Stage (dev → stage) and Publish (stage → production). Uses source_key to track lineage.
pwa_caches
Purpose: PWA offline cache manifest tracking.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
environment |
ENUM | Yes | - | dev, stage, production |
cache_version |
TEXT | Yes | - | len[0,255] |
manifest_json |
JSON | Yes | - | PWA manifest |
asset_list_json |
JSON | Yes | - | Cached asset URLs |
generated_at |
DATE | Yes | - | - |
is_active |
BOOLEAN | No | false | - |
projectId |
UUID | Yes | - | FK to projects |
presigned_url_requests
Purpose: Audit log for S3 presigned URL requests.
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
purpose |
ENUM | Yes | - | upload, download |
asset_type |
ENUM | Yes | - | image, video, audio, file |
requested_key |
TEXT | Yes | - | len[0,1024] |
mime_type |
TEXT | Yes | - | MIME format, len[0,255] |
requested_size_mb |
DECIMAL | Yes | - | min: 0 |
expires_at |
DATE | Yes | - | - |
status |
TEXT | Yes | - | - |
projectId |
UUID | Yes | - | FK to projects |
userId |
UUID | Yes | - | FK to users |
file
Purpose: Generic file attachments (user avatars, etc.).
| Field | Type | Nullable | Default | Validation |
|---|---|---|---|---|
id |
UUID | No | UUIDv4 | - |
belongsTo |
STRING(255) | Yes | - | Parent table name |
belongsToId |
UUID | Yes | - | Parent record ID |
belongsToColumn |
STRING(255) | Yes | - | Parent column name |
name |
STRING(2083) | No | - | notEmpty |
sizeInBytes |
INTEGER | Yes | - | - |
privateUrl |
STRING(2083) | Yes | - | - |
publicUrl |
STRING(2083) | No | - | notEmpty |
Usage Pattern: Polymorphic association via belongsTo, belongsToId, belongsToColumn fields and scoped hasMany on parent models:
// In users model:
db.users.hasMany(db.file, {
as: 'avatar',
foreignKey: 'belongsToId',
scope: {
belongsTo: 'users',
belongsToColumn: 'avatar',
},
});
Entity Relationship Diagram
┌─────────────────────────────────────────────────────────────────────────────┐
│ Entity Relationships │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────┐
│ roles │
│ │
│ name │
│ permissions │◄─── M:N ───┐
└──────┬──────┘ │
│ │
│ 1:N │
│ │
┌─────────────────────────┐ ┌──────▼──────┐ ┌───────┴───────┐
│ element_type_defaults │ │ users │ │ permissions │
│ │ │ │ │ │
│ element_type │ │ email │ │ name │
│ name │ │ password │ │ │
│ settings_json │ │ app_role ───┼───┘ │
└───────────┬─────────────┘ └──────┬──────┘ │
│ │ │
│ 1:N │ 1:N │
│ │ │
▼ ▼ │
┌───────────────────────┐ ┌────────────────────┐ │
│project_element_defaults│ │project_memberships │ │
│ │ │ │ │
│ element_type │ │ access_level │ │
│ settings_json │ │ is_active │ │
│ source_element_id ────┼───┘ │ │
│ projectId ────────────┼───┐ │ │
└───────────────────────┘ │ │ │
│ │ │
│ N:1 │ N:1 │
│ │ │
▼ ▼ │
┌─────────────────────────────────────┐ │
│ projects │ │
│ │ │
│ name, slug, description │ │
│ logo_url, favicon_url, og_image_url │ │
└───────────────┬─────────────────────┘ │
│ │
┌─────────────┬───────────────┼───────────────┬─────────┘
│ │ │ │
│ 1:N │ 1:N │ 1:N │ 1:N
▼ ▼ ▼ ▼
┌───────────────┐ ┌───────────┐ ┌───────────────┐ ┌───────────────┐
│ tour_pages │ │ assets │ │publish_events │ │ pwa_caches │
│ │ │ │ │ │ │ │
│ environment │ │ asset_type│ │ status │ │ environment │
│ name, slug │ │ cdn_url │ │ from/to_env │ │ manifest_json │
│ ui_schema_json│ │ storage_key│ │ pages_copied │ │ asset_list │
└───────────────┘ └─────┬─────┘ └───────────────┘ └───────────────┘
│
│ 1:N
▼
┌─────────────────┐
│ asset_variants │
│ │
│ variant_type │
│ cdn_url │
│ width_px │
└─────────────────┘
Additional Models (project-scoped):
• project_audio_tracks → projects (N:1)
• project_transition_settings → projects (N:1)
• production_presentation_access → projects (N:1), users (N:1)
• access_logs → projects (N:1), users (N:1)
• presigned_url_requests → projects (N:1), users (N:1)
Model Patterns
1. Soft Delete (Paranoid Mode)
All models use paranoid: true, which adds a deletedAt column and modifies queries:
// This:
await Model.destroy({ where: { id } });
// Does NOT delete, but sets deletedAt = NOW()
// All find queries automatically filter deletedAt IS NULL
2. Environment Enum
Several models use the environment enum for multi-environment content:
environment: {
type: DataTypes.ENUM,
values: ['dev', 'stage', 'production'],
}
Used by: tour_pages, project_audio_tracks, project_transition_settings, pwa_caches, publish_events
3. Import Hash Deduplication
The importHash field prevents duplicate imports:
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
}
4. Cascade Delete Patterns
| Relationship | onDelete | Use Case |
|---|---|---|
CASCADE |
Delete children when parent deleted | Projects → tour_pages |
SET NULL |
Keep children, null the FK | Roles → users |
5. Composite Unique Constraints
For scoped uniqueness:
indexes: [
{ fields: ['projectId', 'environment', 'slug'], unique: true },
]
6. JSON Fields
Complex configurations stored as JSON:
ui_schema_json: { type: DataTypes.JSON } // Parsed JSON column
settings_json: { type: DataTypes.TEXT } // Stringified JSON text
7. Virtual Fields
Computed fields that don't exist in the database:
is_active: {
type: DataTypes.VIRTUAL,
get() {
return true;
},
}
Validation Patterns
String Length
name: {
type: DataTypes.TEXT,
validate: {
len: {
args: [1, 255],
msg: 'Name must be between 1 and 255 characters',
},
},
}
Required Fields
email: {
type: DataTypes.TEXT,
allowNull: false,
validate: {
notEmpty: { msg: 'Email is required' },
},
}
Email Format
email: {
validate: {
isEmail: { msg: 'Must be a valid email address' },
},
}
Custom Validators
cdn_url: {
validate: {
isUrlOrEmpty(value) {
if (value && value.length > 0 && !/^https?:\/\/.+/.test(value)) {
throw new Error('CDN URL must be a valid URL');
}
},
},
}
Numeric Range
volume: {
type: DataTypes.DECIMAL,
validate: {
min: { args: [0], msg: 'Volume must be at least 0' },
max: { args: [1], msg: 'Volume must be at most 1' },
},
}
Usage Examples
Importing Models
const db = require('./db/models');
// Access models
const user = await db.users.findByPk(id);
const project = await db.projects.findOne({ where: { slug } });
// Access Sequelize instance
const transaction = await db.sequelize.transaction();
// Access Sequelize operators
const { Op } = db.Sequelize;
const users = await db.users.findAll({
where: { email: { [Op.like]: '%@example.com' } }
});
Creating Records with Associations
const project = await db.projects.create({
name: 'My Tour',
slug: 'my-tour',
createdById: userId,
});
const page = await db.tour_pages.create({
name: 'Home',
slug: 'home',
projectId: project.id,
environment: 'dev',
createdById: userId,
});
Querying with Includes
const project = await db.projects.findOne({
where: { id: projectId },
include: [
{ association: 'tour_pages_project', where: { environment: 'production' } },
{ association: 'project_memberships_project', include: ['user'] },
],
});
Related Documentation
- Database Schema - Complete schema reference
- Factories Module - Service and router factories that use models
- Services Module - Business logic using models
- API Endpoints - REST API exposing models