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

978 lines
37 KiB
Markdown

# 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.ts` contains the typed ESM model factory.
- `loader.ts` explicitly imports every model factory and builds the Sequelize
registry without dynamic CommonJS discovery.
- `index.ts` is the ESM entrypoint to `loader.ts` used by DB consumers.
- `backend/src/types/db-models.ts` keeps the service-specific overload facade
as reusable TypeScript contracts.
- Shared model factory contracts live in `backend/src/types/sequelize-models.ts`
and 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.ts`
- `backend/src/db/models/index.ts`
- `backend/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:
```javascript
{
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:
```javascript
// 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_roleId`
- `deletedAt`
**Associations:**
```javascript
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:**
```javascript
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:**
```javascript
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:**
- `projectId`
- `userId`
- `projectId, userId` unique for active rows
**Associations:**
```javascript
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 queries
- `deletedAt`
**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:**
```javascript
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:**
- `projectId`
- `asset_type`
- `type`
- `is_public`
- `deletedAt`
**Associations:**
```javascript
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:**
```javascript
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_type`
- `sort_order`
- `deletedAt`
**Associations:**
```javascript
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_type`
- `source_element_id`
- `deletedAt`
**Associations:**
```javascript
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:**
- `projectId`
- `userId`
- `status`
- `started_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:**
- `projectId`
- `environment`
- `userId`
- `accessed_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:**
- `projectId`
- `userId`
- `[projectId, userId]` (unique) - One membership per user per project
- `is_active`
- `deletedAt`
---
### 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)
- `projectId`
- `deletedAt`
**Associations:**
```javascript
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:
```javascript
// 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:
```javascript
// 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:
```javascript
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:
```javascript
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:
```javascript
indexes: [
{ fields: ['projectId', 'environment', 'slug'], unique: true },
]
```
### 6. JSON Fields
Complex configurations stored as JSON:
```javascript
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:
```javascript
is_active: {
type: DataTypes.VIRTUAL,
get() {
return true;
},
}
```
---
## Validation Patterns
### String Length
```javascript
name: {
type: DataTypes.TEXT,
validate: {
len: {
args: [1, 255],
msg: 'Name must be between 1 and 255 characters',
},
},
}
```
### Required Fields
```javascript
email: {
type: DataTypes.TEXT,
allowNull: false,
validate: {
notEmpty: { msg: 'Email is required' },
},
}
```
### Email Format
```javascript
email: {
validate: {
isEmail: { msg: 'Must be a valid email address' },
},
}
```
### Custom Validators
```javascript
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
```javascript
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
```javascript
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
```javascript
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
```javascript
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](../database-schema.md) - Complete schema reference
- [Factories Module](./factories.md) - Service and router factories that use models
- [Services Module](./services.md) - Business logic using models
- [API Endpoints](../api-endpoints.md) - REST API exposing models