# 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