diff --git a/backend/docs/api-endpoints.md b/backend/docs/api-endpoints.md index f3357b9..a45e97e 100644 --- a/backend/docs/api-endpoints.md +++ b/backend/docs/api-endpoints.md @@ -36,10 +36,10 @@ routes returning `401 Unauthorized` without JWT are healthy. See 15. [Publishing](#15-publishing) 16. [File Management](#16-file-management) 17. [Search](#17-search) -20. [Access Logs](#20-access-logs) -21. [Publish Events](#21-publish-events) -22. [PWA Caches](#22-pwa-caches) -23. [Presigned URL Requests](#23-presigned-url-requests) +18. [Access Logs](#20-access-logs) +19. [Publish Events](#21-publish-events) +20. [PWA Caches](#22-pwa-caches) +21. [Presigned URL Requests](#23-presigned-url-requests) --- @@ -53,15 +53,15 @@ Authorization: Bearer ### Query Parameters (List Endpoints) -| Parameter | Type | Description | -|-----------|------|-------------| -| `page` | number | Page number (0-indexed) | -| `limit` | number | Items per page (default: 10) | -| `field` | string | Field to sort by | -| `sort` | string | Sort direction: `asc` or `desc` | -| `filetype` | string | Set to `csv` for CSV export | -| `` | string | Text search filter (ILIKE) | -| `Range` | string | Range filter: `[min,max]` | +| Parameter | Type | Description | +| -------------- | ------ | ------------------------------- | +| `page` | number | Page number (0-indexed) | +| `limit` | number | Items per page (default: 10) | +| `field` | string | Field to sort by | +| `sort` | string | Sort direction: `asc` or `desc` | +| `filetype` | string | Set to `csv` for CSV export | +| `` | string | Text search filter (ILIKE) | +| `Range` | string | Range filter: `[min,max]` | ### Standard List Response @@ -74,16 +74,16 @@ Authorization: Bearer ### Rate Limits -| Endpoint Type | Limit | Window | -|---------------|-------|--------| -| Auth | 10 requests | 15 minutes | -| Signup | 5 requests | 1 hour | -| Password Reset | 5 requests | 1 hour | -| API (general) | 100 requests | 1 minute | -| Upload | 10 requests | 1 minute | -| Download | 200 requests | 1 minute | -| Search | 30 requests | 1 minute | -| AI | 20 requests | 1 minute | +| Endpoint Type | Limit | Window | +| -------------- | ------------ | ---------- | +| Auth | 10 requests | 15 minutes | +| Signup | 5 requests | 1 hour | +| Password Reset | 5 requests | 1 hour | +| API (general) | 100 requests | 1 minute | +| Upload | 10 requests | 1 minute | +| Download | 200 requests | 1 minute | +| Search | 30 requests | 1 minute | +| AI | 20 requests | 1 minute | ### Rate Limit Headers @@ -106,6 +106,7 @@ Sign in with email and password. **Rate Limit**: 10/15min **Request:** + ```json { "email": "user@example.com", @@ -114,11 +115,13 @@ Sign in with email and password. ``` **Response (200):** + ```json "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." ``` **Errors:** + - `400`: Invalid credentials --- @@ -138,6 +141,7 @@ Get current authenticated user. **Authentication**: Required **Response (200):** + ```json { "id": "uuid", @@ -165,6 +169,7 @@ Reset password using token. **Authentication**: None **Request:** + ```json { "token": "reset-token-from-email", @@ -184,6 +189,7 @@ Send password reset email. **Rate Limit**: 5/hour **Request:** + ```json { "email": "user@example.com" @@ -201,6 +207,7 @@ Update password for authenticated user. **Authentication**: Required **Request:** + ```json { "currentPassword": "oldPassword", @@ -219,6 +226,7 @@ Update user profile. **Authentication**: Required **Request:** + ```json { "profile": { @@ -240,6 +248,7 @@ Verify email address. **Authentication**: None **Request:** + ```json { "token": "verification-token-from-email" @@ -267,6 +276,7 @@ Initiate Google OAuth flow. **Authentication**: None **Query Parameters:** + - `app`: Optional state parameter **Response**: Redirect to Google @@ -308,6 +318,7 @@ Health check endpoint. **Authentication**: None **Response (200):** + ```json { "status": "ok", @@ -319,6 +330,7 @@ Health check endpoint. ``` **Response (503):** (when database is down) + ```json { "status": "degraded", @@ -339,6 +351,7 @@ Get current runtime context (environment detection). **Authentication**: None **Response (200):** + ```json { "mode": "workspace", @@ -353,17 +366,17 @@ Get current runtime context (environment detection). ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/users` | Create user | -| GET | `/api/users` | List users | -| GET | `/api/users/count` | Count users | -| GET | `/api/users/autocomplete` | Autocomplete search | -| GET | `/api/users/:id` | Get user by ID | -| PUT | `/api/users/:id` | Update user | -| DELETE | `/api/users/:id` | Delete user | -| POST | `/api/users/deleteByIds` | Bulk delete | -| POST | `/api/users/bulk-import` | Bulk import | +| Method | Endpoint | Description | +| ------ | ------------------------- | ------------------- | +| POST | `/api/users` | Create user | +| GET | `/api/users` | List users | +| GET | `/api/users/count` | Count users | +| GET | `/api/users/autocomplete` | Autocomplete search | +| GET | `/api/users/:id` | Get user by ID | +| PUT | `/api/users/:id` | Update user | +| DELETE | `/api/users/:id` | Delete user | +| POST | `/api/users/deleteByIds` | Bulk delete | +| POST | `/api/users/bulk-import` | Bulk import | **Authentication**: Required **Permissions**: `CREATE_USERS`, `READ_USERS`, `UPDATE_USERS`, `DELETE_USERS` @@ -371,6 +384,7 @@ Get current runtime context (environment detection). ### POST /api/users **Request:** + ```json { "data": { @@ -391,6 +405,7 @@ Get current runtime context (environment detection). ### GET /api/users **Query Parameters:** + - `firstName`: Filter by first name - `lastName`: Filter by last name - `email`: Filter by email @@ -398,6 +413,7 @@ Get current runtime context (environment detection). - Standard pagination params **Response:** + ```json { "rows": [ @@ -422,22 +438,23 @@ Get current runtime context (environment detection). ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/roles` | Create role | -| GET | `/api/roles` | List roles | -| GET | `/api/roles/count` | Count roles | -| GET | `/api/roles/autocomplete` | Autocomplete search | -| GET | `/api/roles/:id` | Get role by ID | -| PUT | `/api/roles/:id` | Update role | -| DELETE | `/api/roles/:id` | Delete role | -| POST | `/api/roles/deleteByIds` | Bulk delete | +| Method | Endpoint | Description | +| ------ | ------------------------- | ------------------- | +| POST | `/api/roles` | Create role | +| GET | `/api/roles` | List roles | +| GET | `/api/roles/count` | Count roles | +| GET | `/api/roles/autocomplete` | Autocomplete search | +| GET | `/api/roles/:id` | Get role by ID | +| PUT | `/api/roles/:id` | Update role | +| DELETE | `/api/roles/:id` | Delete role | +| POST | `/api/roles/deleteByIds` | Bulk delete | **Permissions**: `CREATE_ROLES`, `READ_ROLES`, `UPDATE_ROLES`, `DELETE_ROLES` ### POST /api/roles **Request:** + ```json { "data": { @@ -454,22 +471,23 @@ Get current runtime context (environment detection). ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/permissions` | Create permission | -| GET | `/api/permissions` | List permissions | -| GET | `/api/permissions/count` | Count permissions | -| GET | `/api/permissions/autocomplete` | Autocomplete search | -| GET | `/api/permissions/:id` | Get permission by ID | -| PUT | `/api/permissions/:id` | Update permission | -| DELETE | `/api/permissions/:id` | Delete permission | -| POST | `/api/permissions/deleteByIds` | Bulk delete | +| Method | Endpoint | Description | +| ------ | ------------------------------- | -------------------- | +| POST | `/api/permissions` | Create permission | +| GET | `/api/permissions` | List permissions | +| GET | `/api/permissions/count` | Count permissions | +| GET | `/api/permissions/autocomplete` | Autocomplete search | +| GET | `/api/permissions/:id` | Get permission by ID | +| PUT | `/api/permissions/:id` | Update permission | +| DELETE | `/api/permissions/:id` | Delete permission | +| POST | `/api/permissions/deleteByIds` | Bulk delete | **Permissions**: `CREATE_PERMISSIONS`, `READ_PERMISSIONS`, `UPDATE_PERMISSIONS`, `DELETE_PERMISSIONS` ### POST /api/permissions **Request:** + ```json { "data": { @@ -484,16 +502,16 @@ Get current runtime context (environment detection). ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/projects` | Create project | -| GET | `/api/projects` | List projects | -| GET | `/api/projects/count` | Count projects | -| GET | `/api/projects/autocomplete` | Autocomplete search | -| GET | `/api/projects/:id` | Get project by ID | -| PUT | `/api/projects/:id` | Update project | -| DELETE | `/api/projects/:id` | Delete project | -| POST | `/api/projects/deleteByIds` | Bulk delete | +| Method | Endpoint | Description | +| ------ | ---------------------------- | ------------------- | +| POST | `/api/projects` | Create project | +| GET | `/api/projects` | List projects | +| GET | `/api/projects/count` | Count projects | +| GET | `/api/projects/autocomplete` | Autocomplete search | +| GET | `/api/projects/:id` | Get project by ID | +| PUT | `/api/projects/:id` | Update project | +| DELETE | `/api/projects/:id` | Delete project | +| POST | `/api/projects/deleteByIds` | Bulk delete | **Permissions**: `CREATE_PROJECTS`, `READ_PROJECTS`, `UPDATE_PROJECTS`, `DELETE_PROJECTS` @@ -502,6 +520,7 @@ Get current runtime context (environment detection). ### POST /api/projects **Request:** + ```json { "data": { @@ -517,14 +536,14 @@ Get current runtime context (environment detection). **Fields:** -| Field | Type | Description | -|-------|------|-------------| -| `name` | string | Project display name | -| `slug` | string | URL-safe identifier (auto-generated if not provided) | -| `description` | string | Project description | -| `logo_url` | string | Path to project logo | -| `favicon_url` | string | Path to favicon | -| `og_image_url` | string | Path to Open Graph image | +| Field | Type | Description | +| -------------- | ------ | ---------------------------------------------------- | +| `name` | string | Project display name | +| `slug` | string | URL-safe identifier (auto-generated if not provided) | +| `description` | string | Project description | +| `logo_url` | string | Path to project logo | +| `favicon_url` | string | Path to favicon | +| `og_image_url` | string | Path to Open Graph image | --- @@ -535,6 +554,7 @@ Clone an existing project with all pages and settings. **Request:** None (uses URL parameter) **Response:** + ```json { "id": "new-project-uuid", @@ -550,9 +570,11 @@ Clone an existing project with all pages and settings. Get PWA offline manifest for a project. **Query Parameters:** + - `variant`: `mobile` or `desktop` (default: `desktop`) **Response:** + ```json { "projectId": "uuid", @@ -579,20 +601,21 @@ Get PWA offline manifest for a project. ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/project_memberships` | Create membership | -| GET | `/api/project_memberships` | List memberships | -| GET | `/api/project_memberships/count` | Count memberships | -| GET | `/api/project_memberships/:id` | Get membership by ID | -| PUT | `/api/project_memberships/:id` | Update membership | -| DELETE | `/api/project_memberships/:id` | Delete membership | +| Method | Endpoint | Description | +| ------ | -------------------------------- | -------------------- | +| POST | `/api/project_memberships` | Create membership | +| GET | `/api/project_memberships` | List memberships | +| GET | `/api/project_memberships/count` | Count memberships | +| GET | `/api/project_memberships/:id` | Get membership by ID | +| PUT | `/api/project_memberships/:id` | Update membership | +| DELETE | `/api/project_memberships/:id` | Delete membership | **Permissions**: `CREATE_PROJECT_MEMBERSHIPS`, `READ_PROJECT_MEMBERSHIPS`, etc. ### POST /api/project_memberships **Request:** + ```json { "data": { @@ -604,6 +627,7 @@ Get PWA offline manifest for a project. ``` **Membership Roles:** + - `owner` - Full access including delete - `editor` - Can edit content - `viewer` - Read-only access @@ -614,17 +638,17 @@ Get PWA offline manifest for a project. ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/tour_pages` | Create page | -| GET | `/api/tour_pages` | List pages | -| GET | `/api/tour_pages/count` | Count pages | -| GET | `/api/tour_pages/:id` | Get page by ID | -| PUT | `/api/tour_pages/:id` | Update page | -| DELETE | `/api/tour_pages/:id` | Delete page | -| POST | `/api/tour_pages/deleteByIds` | Bulk delete | -| POST | `/api/tour_pages/reorder` | Reorder pages by updating `sort_order` only | -| POST | `/api/tour_pages/:id/duplicate` | Duplicate a dev page as a new independent dev page | +| Method | Endpoint | Description | +| ------ | ------------------------------- | -------------------------------------------------- | +| POST | `/api/tour_pages` | Create page | +| GET | `/api/tour_pages` | List pages | +| GET | `/api/tour_pages/count` | Count pages | +| GET | `/api/tour_pages/:id` | Get page by ID | +| PUT | `/api/tour_pages/:id` | Update page | +| DELETE | `/api/tour_pages/:id` | Delete page | +| POST | `/api/tour_pages/deleteByIds` | Bulk delete | +| POST | `/api/tour_pages/reorder` | Reorder pages by updating `sort_order` only | +| POST | `/api/tour_pages/:id/duplicate` | Duplicate a dev page as a new independent dev page | **Permissions**: `CREATE_TOUR_PAGES`, `READ_TOUR_PAGES`, etc. @@ -659,6 +683,7 @@ Reorders pages for a project in the constructor environment. ``` **Rules:** + - Only `environment: "dev"` is accepted. Stage and production are updated by publishing, not by direct reorder writes. - `orderedPageIds` must include every page in the project/dev environment @@ -669,6 +694,7 @@ Reorders pages for a project in the constructor environment. order changes after Publish. **Validation failures:** + - Missing `projectId` - Empty or invalid `orderedPageIds` - Duplicate page IDs @@ -696,6 +722,7 @@ Duplicates a constructor/dev page into a new independent dev page. ``` **Rules:** + - Only source pages in `environment: "dev"` can be duplicated. - The requested target environment must also be `dev`. - The source page must belong to the requested project. @@ -711,6 +738,7 @@ Duplicates a constructor/dev page into a new independent dev page. - Reverse-video processing uses the existing `TourPagesService` path. **Validation failures:** + - Invalid source page ID - Source page not found - Source page outside requested project @@ -719,6 +747,7 @@ Duplicates a constructor/dev page into a new independent dev page. ### POST /api/tour_pages **Request:** + ```json { "data": { @@ -741,6 +770,7 @@ Duplicates a constructor/dev page into a new independent dev page. ``` **ui_schema_json Structure:** + ```json { "elements": [ @@ -771,16 +801,16 @@ Duplicates a constructor/dev page into a new independent dev page. ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/assets` | Create asset record | -| GET | `/api/assets` | List assets | -| GET | `/api/assets/count` | Count assets | -| GET | `/api/assets/autocomplete` | Autocomplete search | -| GET | `/api/assets/:id` | Get asset by ID | -| PUT | `/api/assets/:id` | Update asset | -| DELETE | `/api/assets/:id` | Delete asset | -| POST | `/api/assets/deleteByIds` | Bulk delete | +| Method | Endpoint | Description | +| ------ | -------------------------- | ------------------- | +| POST | `/api/assets` | Create asset record | +| GET | `/api/assets` | List assets | +| GET | `/api/assets/count` | Count assets | +| GET | `/api/assets/autocomplete` | Autocomplete search | +| GET | `/api/assets/:id` | Get asset by ID | +| PUT | `/api/assets/:id` | Update asset | +| DELETE | `/api/assets/:id` | Delete asset | +| POST | `/api/assets/deleteByIds` | Bulk delete | **Permissions**: `CREATE_ASSETS`, `READ_ASSETS`, etc. @@ -789,6 +819,7 @@ Duplicates a constructor/dev page into a new independent dev page. Create an asset record with MIME type validation. **Request:** + ```json { "data": { @@ -817,15 +848,16 @@ Create an asset record with MIME type validation. The `mime_type` must match the `asset_type`: -| asset_type | Valid mime_type prefixes | -|------------|-------------------------| -| `image` | `image/` (jpeg, png, gif, webp, svg, etc.) | -| `video` | `video/` (mp4, webm, mov, etc.) | -| `audio` | `audio/` (mp3, wav, ogg, etc.) | +| asset_type | Valid mime_type prefixes | +| ---------- | ------------------------------------------ | +| `image` | `image/` (jpeg, png, gif, webp, svg, etc.) | +| `video` | `video/` (mp4, webm, mov, etc.) | +| `audio` | `audio/` (mp3, wav, ogg, etc.) | Other asset types (`document`, `other`, `file`) skip MIME validation. **Error Response (400):** + ```json { "message": "Invalid file type for image. Expected image (jpeg, png, gif, webp, svg, etc.), got \"video/mp4\"" @@ -838,19 +870,20 @@ Other asset types (`document`, `other`, `file`) skip MIME validation. ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/asset_variants` | Create variant | -| GET | `/api/asset_variants` | List variants | -| GET | `/api/asset_variants/:id` | Get variant by ID | -| PUT | `/api/asset_variants/:id` | Update variant | -| DELETE | `/api/asset_variants/:id` | Delete variant | +| Method | Endpoint | Description | +| ------ | ------------------------- | ----------------- | +| POST | `/api/asset_variants` | Create variant | +| GET | `/api/asset_variants` | List variants | +| GET | `/api/asset_variants/:id` | Get variant by ID | +| PUT | `/api/asset_variants/:id` | Update variant | +| DELETE | `/api/asset_variants/:id` | Delete variant | **Permissions**: `CREATE_ASSET_VARIANTS`, `READ_ASSET_VARIANTS`, etc. ### POST /api/asset_variants **Request:** + ```json { "data": { @@ -872,12 +905,12 @@ Other asset types (`document`, `other`, `file`) skip MIME validation. ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/project_audio_tracks` | Create audio track | -| GET | `/api/project_audio_tracks` | List audio tracks | -| GET | `/api/project_audio_tracks/:id` | Get audio track | -| PUT | `/api/project_audio_tracks/:id` | Update audio track | +| Method | Endpoint | Description | +| ------ | ------------------------------- | ------------------ | +| POST | `/api/project_audio_tracks` | Create audio track | +| GET | `/api/project_audio_tracks` | List audio tracks | +| GET | `/api/project_audio_tracks/:id` | Get audio track | +| PUT | `/api/project_audio_tracks/:id` | Update audio track | | DELETE | `/api/project_audio_tracks/:id` | Delete audio track | **Runtime Public Access**: GET endpoints accessible without auth in production mode. @@ -885,6 +918,7 @@ Other asset types (`document`, `other`, `file`) skip MIME validation. ### POST /api/project_audio_tracks **Request:** + ```json { "data": { @@ -911,47 +945,50 @@ Environment-aware CSS transition settings for page navigation. Uses **URL-path-based public access** - no headers required: -| Endpoint | Method | Environment | Auth Required | -|----------|--------|-------------|---------------| -| `/project/:id/env/production` | GET | production | **No** (public) | -| `/project/:id/env/dev` | GET | dev | JWT + READ_PAGE_ELEMENTS | -| `/project/:id/env/stage` | GET | stage | JWT + READ_PAGE_ELEMENTS | -| `/project/:id/env/*` | PUT/DELETE | any | JWT + UPDATE_PAGE_ELEMENTS | -| Standard CRUD | all | n/a | JWT + PAGE_ELEMENTS CRUD permission | +| Endpoint | Method | Environment | Auth Required | +| ----------------------------- | ---------- | ----------- | ----------------------------------- | +| `/project/:id/env/production` | GET | production | **No** (public) | +| `/project/:id/env/dev` | GET | dev | JWT + READ_PAGE_ELEMENTS | +| `/project/:id/env/stage` | GET | stage | JWT + READ_PAGE_ELEMENTS | +| `/project/:id/env/*` | PUT/DELETE | any | JWT + UPDATE_PAGE_ELEMENTS | +| Standard CRUD | all | n/a | JWT + PAGE_ELEMENTS CRUD permission | This allows public presentations (`/p/[slug]`) to fetch production transition settings without authentication in incognito mode. ### Standard CRUD Endpoints (All Require Auth) -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/project-transition-settings` | Create settings | -| GET | `/api/project-transition-settings` | List all settings | -| GET | `/api/project-transition-settings/:id` | Get by ID | -| PUT | `/api/project-transition-settings/:id` | Update by ID | -| DELETE | `/api/project-transition-settings/:id` | Delete by ID | +| Method | Endpoint | Description | +| ------ | -------------------------------------- | ----------------- | +| POST | `/api/project-transition-settings` | Create settings | +| GET | `/api/project-transition-settings` | List all settings | +| GET | `/api/project-transition-settings/:id` | Get by ID | +| PUT | `/api/project-transition-settings/:id` | Update by ID | +| DELETE | `/api/project-transition-settings/:id` | Delete by ID | ### Environment-Specific Endpoints -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/project/:projectId/env/production` | None | Get production settings (public) | -| GET | `/project/:projectId/env/dev` | JWT | Get dev settings | -| GET | `/project/:projectId/env/stage` | JWT | Get stage settings | -| PUT | `/project/:projectId/env/:environment` | JWT + UPDATE_PAGE_ELEMENTS | Create or update (upsert) | +| Method | Endpoint | Auth | Description | +| ------ | -------------------------------------- | -------------------------- | --------------------------------- | +| GET | `/project/:projectId/env/production` | None | Get production settings (public) | +| GET | `/project/:projectId/env/dev` | JWT | Get dev settings | +| GET | `/project/:projectId/env/stage` | JWT | Get stage settings | +| PUT | `/project/:projectId/env/:environment` | JWT + UPDATE_PAGE_ELEMENTS | Create or update (upsert) | | DELETE | `/project/:projectId/env/:environment` | JWT + UPDATE_PAGE_ELEMENTS | Reset settings to global defaults | ### GET /api/project-transition-settings/project/:projectId/env/:environment **Parameters:** + - `projectId` (path): Project UUID - `environment` (path): `dev`, `stage`, or `production` **Authentication:** + - `production`: None required (public access) - `dev`/`stage`: `Authorization: Bearer {token}` **Response (200):** + ```json { "id": "uuid", @@ -977,6 +1014,7 @@ Creates or updates settings for a project/environment combination. **Authentication**: Required (JWT + UPDATE_PAGE_ELEMENTS) **Request:** + ```json { "transition_type": "fade", @@ -997,21 +1035,21 @@ from global defaults to project/environment overrides and then page overrides. ### Global Defaults -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/api/global-ui-control-defaults` | None | Get singleton global UI-control defaults | -| GET | `/api/global-ui-control-defaults/:id` | None | Get defaults by ID | -| PUT | `/api/global-ui-control-defaults/:id` | JWT + UPDATE_PAGE_ELEMENTS | Update singleton defaults | +| Method | Endpoint | Auth | Description | +| ------ | ------------------------------------- | -------------------------- | ---------------------------------------- | +| GET | `/api/global-ui-control-defaults` | None | Get singleton global UI-control defaults | +| GET | `/api/global-ui-control-defaults/:id` | None | Get defaults by ID | +| PUT | `/api/global-ui-control-defaults/:id` | JWT + UPDATE_PAGE_ELEMENTS | Update singleton defaults | ### Project/Environment Overrides -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/api/project-ui-control-settings/project/:projectId/env/production` | None or JWT for private production | Get production project overrides | -| GET | `/api/project-ui-control-settings/project/:projectId/env/dev` | JWT | Get dev project overrides | -| GET | `/api/project-ui-control-settings/project/:projectId/env/stage` | JWT | Get stage project overrides | -| PUT | `/api/project-ui-control-settings/project/:projectId/env/:environment` | JWT + UPDATE_PAGE_ELEMENTS | Upsert project overrides | -| DELETE | `/api/project-ui-control-settings/project/:projectId/env/:environment` | JWT + UPDATE_PAGE_ELEMENTS | Reset project overrides to global defaults | +| Method | Endpoint | Auth | Description | +| ------ | ---------------------------------------------------------------------- | ---------------------------------- | ------------------------------------------ | +| GET | `/api/project-ui-control-settings/project/:projectId/env/production` | None or JWT for private production | Get production project overrides | +| GET | `/api/project-ui-control-settings/project/:projectId/env/dev` | JWT | Get dev project overrides | +| GET | `/api/project-ui-control-settings/project/:projectId/env/stage` | JWT | Get stage project overrides | +| PUT | `/api/project-ui-control-settings/project/:projectId/env/:environment` | JWT + UPDATE_PAGE_ELEMENTS | Upsert project overrides | +| DELETE | `/api/project-ui-control-settings/project/:projectId/env/:environment` | JWT + UPDATE_PAGE_ELEMENTS | Reset project overrides to global defaults | `production` reads follow private-production presentation rules: public presentations are public-readable, private presentations require JWT and an @@ -1054,11 +1092,11 @@ allowlist/staff access check. Platform-wide default transition settings (singleton). -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/api/global-transition-defaults` | **None** | Get defaults (public) | -| GET | `/api/global-transition-defaults/:id` | **None** | Get by ID (public) | -| PUT | `/api/global-transition-defaults/:id` | JWT + UPDATE_PAGE_ELEMENTS | Update defaults | +| Method | Endpoint | Auth | Description | +| ------ | ------------------------------------- | -------------------------- | --------------------- | +| GET | `/api/global-transition-defaults` | **None** | Get defaults (public) | +| GET | `/api/global-transition-defaults/:id` | **None** | Get by ID (public) | +| PUT | `/api/global-transition-defaults/:id` | JWT + UPDATE_PAGE_ELEMENTS | Update defaults | **Authentication Model**: GET is always public (for runtime presentations), PUT requires JWT. @@ -1070,6 +1108,7 @@ GET /api/global-transition-defaults ``` **Response (200):** + ```json { "id": "uuid", @@ -1100,15 +1139,16 @@ Content-Type: application/json ### Element Type Defaults (Global) -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/element-type-defaults` | List global defaults | -| GET | `/api/element-type-defaults/:id` | Get default by ID | -| PUT | `/api/element-type-defaults/:id` | Update default | +| Method | Endpoint | Description | +| ------ | -------------------------------- | -------------------- | +| GET | `/api/element-type-defaults` | List global defaults | +| GET | `/api/element-type-defaults/:id` | Get default by ID | +| PUT | `/api/element-type-defaults/:id` | Update default | **Alternative Path:** `/api/ui-elements` (backwards compatibility) **Element Types (11 predefined):** + - `button`, `hotspot`, `tooltip`, `gallery` - `media_player`, `text_block`, `popup` - `logo`, `spot`, `hamburger_menu`, `image` @@ -1117,19 +1157,20 @@ Content-Type: application/json ### Project Element Defaults -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/project-element-defaults` | List project defaults | -| GET | `/api/project-element-defaults/:id` | Get default by ID | -| PUT | `/api/project-element-defaults/:id` | Update default | -| POST | `/api/project-element-defaults/:id/reset` | Reset to global | -| GET | `/api/project-element-defaults/:id/diff` | Compare with global | +| Method | Endpoint | Description | +| ------ | ----------------------------------------- | --------------------- | +| GET | `/api/project-element-defaults` | List project defaults | +| GET | `/api/project-element-defaults/:id` | Get default by ID | +| PUT | `/api/project-element-defaults/:id` | Update default | +| POST | `/api/project-element-defaults/:id/reset` | Reset to global | +| GET | `/api/project-element-defaults/:id/diff` | Compare with global | ### POST /api/project-element-defaults/:id/reset Reset project element default to current global settings. **Response:** + ```json { "id": "uuid", @@ -1146,6 +1187,7 @@ Reset project element default to current global settings. Compare project default with global default. **Response:** + ```json { "hasDifferences": true, @@ -1172,6 +1214,7 @@ Publish from stage to production. **Permissions**: `CREATE_PUBLISH_EVENTS` **Request:** + ```json { "projectId": "project-uuid", @@ -1181,6 +1224,7 @@ Publish from stage to production. ``` **Response:** + ```json { "success": true, @@ -1202,6 +1246,7 @@ Copy dev content to stage environment. **Permissions**: `CREATE_PUBLISH_EVENTS` **Request:** + ```json { "projectId": "project-uuid" @@ -1209,6 +1254,7 @@ Copy dev content to stage environment. ``` **Response:** + ```json { "success": true, @@ -1221,6 +1267,7 @@ Copy dev content to stage environment. ``` **Errors:** + - `400`: Publish already in progress - `404`: Project not found @@ -1236,24 +1283,26 @@ Download a file from storage. Supports automatic client disconnect handling via **Rate Limit**: 200/min **Query Parameters:** + - `privateUrl`: Storage key (e.g., `assets/image.jpg`) **Response:** File stream with appropriate Content-Type **Error Responses:** -| HTTP Status | Condition | S3 Error Types | -|-------------|-----------|----------------| -| 400 | Missing privateUrl parameter | - | -| 401 | Expired credentials | ExpiredToken | -| 403 | Access denied | AccessDenied, InvalidAccessKeyId | -| 404 | File not found | NoSuchKey, NotFound, NoSuchBucket | -| 429 | Rate limited | ThrottlingException | -| 500 | Internal server error | InternalError | -| 503 | Service unavailable | NetworkingError, ServiceUnavailable | -| 504 | Gateway timeout | TimeoutError, RequestTimeout | +| HTTP Status | Condition | S3 Error Types | +| ----------- | ---------------------------- | ----------------------------------- | +| 400 | Missing privateUrl parameter | - | +| 401 | Expired credentials | ExpiredToken | +| 403 | Access denied | AccessDenied, InvalidAccessKeyId | +| 404 | File not found | NoSuchKey, NotFound, NoSuchBucket | +| 429 | Rate limited | ThrottlingException | +| 500 | Internal server error | InternalError | +| 503 | Service unavailable | NetworkingError, ServiceUnavailable | +| 504 | Gateway timeout | TimeoutError, RequestTimeout | **Error Response Format:** + ```json { "message": "Could not download the file. NoSuchKey: The specified key does not exist." @@ -1272,17 +1321,15 @@ Generate presigned URLs for direct S3 access. Includes path validation to preven **Rate Limit**: 200/min **Request:** + ```json { - "urls": [ - "assets/image1.jpg", - "assets/image2.jpg", - "assets/video.mp4" - ] + "urls": ["assets/image1.jpg", "assets/image2.jpg", "assets/video.mp4"] } ``` **Response:** + ```json { "presignedUrls": { @@ -1294,11 +1341,13 @@ Generate presigned URLs for direct S3 access. Includes path validation to preven ``` **Limits:** + - Maximum 50 URLs per request - Presigned URLs expire in 1 hour (configurable via `AWS_S3_PRESIGN_EXPIRY`) **Path Validation:** URLs are validated to prevent path traversal and ensure security: + - Must be non-empty strings - Must not contain `..` (parent directory traversal) - Must not start with `/` (absolute paths) @@ -1306,16 +1355,17 @@ URLs are validated to prevent path traversal and ensure security: **Error Responses:** -| HTTP Status | Condition | -|-------------|-----------| -| 400 | Missing `urls` array | -| 400 | `urls` array is empty | -| 400 | `urls` exceeds maximum of 50 | -| 400 | Invalid URL format (contains `..`, starts with `/`, etc.) | -| 500 | S3 presigning failed | -| 503 | S3 service unavailable | +| HTTP Status | Condition | +| ----------- | --------------------------------------------------------- | +| 400 | Missing `urls` array | +| 400 | `urls` array is empty | +| 400 | `urls` exceeds maximum of 50 | +| 400 | Invalid URL format (contains `..`, starts with `/`, etc.) | +| 500 | S3 presigning failed | +| 503 | S3 service unavailable | **Error Response Format:** + ```json { "message": "Invalid URL format", @@ -1335,10 +1385,12 @@ Upload a file (single request). **Content-Type**: `multipart/form-data` **Form Fields:** + - `file`: Binary file data - `filename`: Target filename **Response:** + ```json { "message": "Uploaded the file successfully: assets/hero.jpg", @@ -1357,6 +1409,7 @@ For files larger than a few MB, use chunked upload: **POST /api/file/upload-sessions/init** **Request:** + ```json { "folder": "assets", @@ -1368,6 +1421,7 @@ For files larger than a few MB, use chunked upload: ``` **Response:** + ```json { "sessionId": "session-uuid", @@ -1386,6 +1440,7 @@ For files larger than a few MB, use chunked upload: **Body**: Raw binary chunk data **Response:** + ```json { "sessionId": "session-uuid", @@ -1402,6 +1457,7 @@ For files larger than a few MB, use chunked upload: **GET /api/file/upload-sessions/:sessionId** **Response:** + ```json { "sessionId": "session-uuid", @@ -1418,6 +1474,7 @@ For files larger than a few MB, use chunked upload: **POST /api/file/upload-sessions/:sessionId/finalize** **Response:** + ```json { "message": "Uploaded the file successfully: assets/large-video.mp4", @@ -1439,6 +1496,7 @@ Global full-text search across entities. **Permissions**: `READ_SEARCH` (implicit via entity permissions) **Request:** + ```json { "searchQuery": "museum" @@ -1446,6 +1504,7 @@ Global full-text search across entities. ``` **Response:** + ```json [ { @@ -1465,18 +1524,18 @@ Global full-text search across entities. **Searchable Tables:** -| Table | Text Fields | Numeric Fields | -|-------|-------------|----------------| -| `users` | firstName, lastName, phoneNumber, email | - | -| `projects` | name, slug, description, logo_url, favicon_url, og_image_url | - | -| `assets` | name, cdn_url, storage_key, mime_type, checksum | size_mb, width_px, height_px, duration_sec | -| `asset_variants` | cdn_url | width_px, height_px, size_mb | -| `presigned_url_requests` | requested_key, mime_type, status | requested_size_mb | -| `tour_pages` | source_key, name, slug, background_image_url, background_video_url, background_audio_url, ui_schema_json | sort_order | -| `project_audio_tracks` | source_key, name, slug, url | volume, sort_order | -| `publish_events` | error_message | pages_copied, transitions_copied, audios_copied | -| `pwa_caches` | cache_version, manifest_json, asset_list_json | - | -| `access_logs` | path, ip_address, user_agent | - | +| Table | Text Fields | Numeric Fields | +| ------------------------ | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| `users` | firstName, lastName, phoneNumber, email | - | +| `projects` | name, slug, description, logo_url, favicon_url, og_image_url | - | +| `assets` | name, cdn_url, storage_key, mime_type, checksum | size_mb, width_px, height_px, duration_sec | +| `asset_variants` | cdn_url | width_px, height_px, size_mb | +| `presigned_url_requests` | requested_key, mime_type, status | requested_size_mb | +| `tour_pages` | source_key, name, slug, background_image_url, background_video_url, background_audio_url, ui_schema_json | sort_order | +| `project_audio_tracks` | source_key, name, slug, url | volume, sort_order | +| `publish_events` | error_message | pages_copied, transitions_copied, audios_copied | +| `pwa_caches` | cache_version, manifest_json, asset_list_json | - | +| `access_logs` | path, ip_address, user_agent | - | --- @@ -1484,23 +1543,25 @@ Global full-text search across entities. ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/access_logs` | List access logs | -| GET | `/api/access_logs/count` | Count access logs | -| GET | `/api/access_logs/:id` | Get access log by ID | +| Method | Endpoint | Description | +| ------ | ------------------------ | -------------------- | +| GET | `/api/access_logs` | List access logs | +| GET | `/api/access_logs/count` | Count access logs | +| GET | `/api/access_logs/:id` | Get access log by ID | **Permissions**: `READ_ACCESS_LOGS` ### GET /api/access_logs **Query Parameters:** + - `path`: Filter by path - `ip_address`: Filter by IP - `user_agent`: Filter by user agent - Standard pagination params **Response:** + ```json { "rows": [ @@ -1523,17 +1584,18 @@ Global full-text search across entities. ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/publish_events` | List publish events | -| GET | `/api/publish_events/count` | Count publish events | -| GET | `/api/publish_events/:id` | Get publish event by ID | +| Method | Endpoint | Description | +| ------ | --------------------------- | ----------------------- | +| GET | `/api/publish_events` | List publish events | +| GET | `/api/publish_events/count` | Count publish events | +| GET | `/api/publish_events/:id` | Get publish event by ID | **Permissions**: `READ_PUBLISH_EVENTS` ### GET /api/publish_events **Query Parameters:** + - `project`: Filter by project ID - `status`: Filter by status (queued, running, success, failed) - `from_environment`: Filter by source (dev, stage) @@ -1541,6 +1603,7 @@ Global full-text search across entities. - Standard pagination params **Response:** + ```json { "rows": [ @@ -1569,19 +1632,20 @@ Global full-text search across entities. ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/pwa_caches` | Create PWA cache record | -| GET | `/api/pwa_caches` | List PWA caches | -| GET | `/api/pwa_caches/:id` | Get PWA cache by ID | -| PUT | `/api/pwa_caches/:id` | Update PWA cache | -| DELETE | `/api/pwa_caches/:id` | Delete PWA cache | +| Method | Endpoint | Description | +| ------ | --------------------- | ----------------------- | +| POST | `/api/pwa_caches` | Create PWA cache record | +| GET | `/api/pwa_caches` | List PWA caches | +| GET | `/api/pwa_caches/:id` | Get PWA cache by ID | +| PUT | `/api/pwa_caches/:id` | Update PWA cache | +| DELETE | `/api/pwa_caches/:id` | Delete PWA cache | **Permissions**: `CREATE_PWA_CACHES`, `READ_PWA_CACHES`, etc. ### POST /api/pwa_caches **Request:** + ```json { "data": { @@ -1591,10 +1655,7 @@ Global full-text search across entities. "name": "My Tour", "short_name": "Tour" }, - "asset_list_json": [ - "assets/image1.jpg", - "assets/image2.jpg" - ] + "asset_list_json": ["assets/image1.jpg", "assets/image2.jpg"] } } ``` @@ -1605,12 +1666,12 @@ Global full-text search across entities. ### Standard CRUD Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/presigned_url_requests` | Create presign request | -| GET | `/api/presigned_url_requests` | List presign requests | -| GET | `/api/presigned_url_requests/:id` | Get presign request | -| PUT | `/api/presigned_url_requests/:id` | Update presign request | +| Method | Endpoint | Description | +| ------ | --------------------------------- | ---------------------- | +| POST | `/api/presigned_url_requests` | Create presign request | +| GET | `/api/presigned_url_requests` | List presign requests | +| GET | `/api/presigned_url_requests/:id` | Get presign request | +| PUT | `/api/presigned_url_requests/:id` | Update presign request | | DELETE | `/api/presigned_url_requests/:id` | Delete presign request | **Permissions**: `CREATE_PRESIGNED_URL_REQUESTS`, `READ_PRESIGNED_URL_REQUESTS`, etc. @@ -1618,6 +1679,7 @@ Global full-text search across entities. ### POST /api/presigned_url_requests **Request:** + ```json { "data": { @@ -1635,16 +1697,16 @@ Global full-text search across entities. ## Error Codes Reference -| Code | Message | Description | -|------|---------|-------------| -| 400 | Bad Request | Invalid input data or validation error | -| 401 | Unauthorized | Missing or invalid JWT token | -| 403 | Forbidden | Insufficient permissions | -| 404 | Not Found | Resource does not exist | -| 409 | Conflict | Resource conflict (e.g., duplicate) | -| 422 | Unprocessable Entity | Validation failed | -| 429 | Too Many Requests | Rate limit exceeded | -| 500 | Internal Server Error | Unexpected server error | +| Code | Message | Description | +| ---- | --------------------- | -------------------------------------- | +| 400 | Bad Request | Invalid input data or validation error | +| 401 | Unauthorized | Missing or invalid JWT token | +| 403 | Forbidden | Insufficient permissions | +| 404 | Not Found | Resource does not exist | +| 409 | Conflict | Resource conflict (e.g., duplicate) | +| 422 | Unprocessable Entity | Validation failed | +| 429 | Too Many Requests | Rate limit exceeded | +| 500 | Internal Server Error | Unexpected server error | --- @@ -1652,20 +1714,20 @@ Global full-text search across entities. ### Request Headers -| Header | Description | Required | -|--------|-------------|----------| -| `Authorization` | `Bearer ` | For protected endpoints | -| `Content-Type` | `application/json` | For JSON bodies | -| `X-Runtime-Environment` | `dev`, `stage`, or `production` | For runtime context | -| `X-Request-Id` | UUID for request tracing | Optional | +| Header | Description | Required | +| ----------------------- | ------------------------------- | ----------------------- | +| `Authorization` | `Bearer ` | For protected endpoints | +| `Content-Type` | `application/json` | For JSON bodies | +| `X-Runtime-Environment` | `dev`, `stage`, or `production` | For runtime context | +| `X-Request-Id` | UUID for request tracing | Optional | ### Response Headers -| Header | Description | -|--------|-------------| -| `X-Request-Id` | Request ID for tracing | -| `X-RateLimit-Limit` | Rate limit maximum | -| `X-RateLimit-Remaining` | Remaining requests | -| `X-RateLimit-Reset` | Reset timestamp | -| `Retry-After` | Seconds until rate limit resets (on 429) | -| `Cross-Origin-Resource-Policy` | `cross-origin` (on file downloads) | +| Header | Description | +| ------------------------------ | ---------------------------------------- | +| `X-Request-Id` | Request ID for tracing | +| `X-RateLimit-Limit` | Rate limit maximum | +| `X-RateLimit-Remaining` | Remaining requests | +| `X-RateLimit-Reset` | Reset timestamp | +| `Retry-After` | Seconds until rate limit resets (on 429) | +| `Cross-Origin-Resource-Policy` | `cross-origin` (on file downloads) | diff --git a/backend/docs/backend-architecture.md b/backend/docs/backend-architecture.md index f841882..c855d79 100644 --- a/backend/docs/backend-architecture.md +++ b/backend/docs/backend-architecture.md @@ -213,17 +213,35 @@ Base class with configurable hooks for entity-specific behavior: ```javascript class AssetsDBApi extends GenericDBApi { // Required: Define the Sequelize model - static get MODEL() { return db.assets; } + static get MODEL() { + return db.assets; + } // Configurable behavior via static getters - static get SEARCHABLE_FIELDS() { return ['name', 'cdn_url']; } - static get RANGE_FIELDS() { return ['size_mb', 'width_px']; } - static get ENUM_FIELDS() { return ['asset_type', 'is_public']; } - static get JSON_FIELDS() { return ['settings_json']; } - static get FIELD_DEFAULTS() { return { type: { default: 'general' } }; } - static get ASSOCIATIONS() { return [{ field: 'project', setter: 'setProject' }]; } - static get FIND_BY_INCLUDES() { return [{ association: 'project' }]; } - static get FIND_ALL_INCLUDES() { return [{ model: db.projects, as: 'project' }]; } + static get SEARCHABLE_FIELDS() { + return ['name', 'cdn_url']; + } + static get RANGE_FIELDS() { + return ['size_mb', 'width_px']; + } + static get ENUM_FIELDS() { + return ['asset_type', 'is_public']; + } + static get JSON_FIELDS() { + return ['settings_json']; + } + static get FIELD_DEFAULTS() { + return { type: { default: 'general' } }; + } + static get ASSOCIATIONS() { + return [{ field: 'project', setter: 'setProject' }]; + } + static get FIND_BY_INCLUDES() { + return [{ association: 'project' }]; + } + static get FIND_ALL_INCLUDES() { + return [{ model: db.projects, as: 'project' }]; + } // Custom field transformation static getFieldMapping(data) { @@ -253,6 +271,7 @@ BaseStorageProvider (abstract) The storage provider base, S3 provider, and local provider are migrated TS/ESM modules. The S3 implementation uses official AWS SDK v3 types; shared provider-domain contracts are in `src/types/file.ts`. Interface: + - `upload(key, data, options)` → `{ key, url }` - `download(key)` → `{ body, contentType }` - `delete(key)` → `void` @@ -292,16 +311,17 @@ Application bootstrap: ```javascript // Key route mounting patterns -app.use('/api/auth', authRoutes); // No JWT required -app.use('/api/users', jwtAuth, usersRoutes); // JWT required +app.use('/api/auth', authRoutes); // No JWT required +app.use('/api/users', jwtAuth, usersRoutes); // JWT required // Runtime public routes (production content accessible without auth) const mountRuntimeEntityRoute = (path, entityName, router) => { - app.use(path, - requireRuntimeReadOrAuth, // JWT or public production + app.use( + path, + requireRuntimeReadOrAuth, // JWT or public production blockNonPublicRuntimeListEndpoints, // Block non-list endpoints sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields - router + router, ); }; mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes); @@ -312,27 +332,27 @@ mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes); **Factory-Generated Routes** provide standard CRUD: -| Method | Path | Description | -|--------|------|-------------| -| POST | `/` | Create record | -| POST | `/bulk-import` | Bulk import from CSV | -| PUT | `/:id` | Update record | -| DELETE | `/:id` | Delete record | -| POST | `/deleteByIds` | Bulk delete | -| GET | `/` | List with pagination & filters | -| GET | `/count` | Count only | -| GET | `/autocomplete` | Autocomplete search | -| GET | `/:id` | Get single record | +| Method | Path | Description | +| ------ | --------------- | ------------------------------ | +| POST | `/` | Create record | +| POST | `/bulk-import` | Bulk import from CSV | +| PUT | `/:id` | Update record | +| DELETE | `/:id` | Delete record | +| POST | `/deleteByIds` | Bulk delete | +| GET | `/` | List with pagination & filters | +| GET | `/count` | Count only | +| GET | `/autocomplete` | Autocomplete search | +| GET | `/:id` | Get single record | **Custom Routes** (auth, file, publish, search, runtime-context): -| Route | Endpoints | -|-------|-----------| -| `/api/auth` | signin, signup, me, password-reset, verify-email, Google/Microsoft OAuth | -| `/api/file` | upload, download, presign, upload-sessions (chunked) | -| `/api/publish` | publish (stage→production), save-to-stage (dev→stage) | -| `/api/search` | Global full-text search | -| `/api/runtime-context` | Runtime environment detection | +| Route | Endpoints | +| ---------------------- | ------------------------------------------------------------------------ | +| `/api/auth` | signin, signup, me, password-reset, verify-email, Google/Microsoft OAuth | +| `/api/file` | upload, download, presign, upload-sessions (chunked) | +| `/api/publish` | publish (stage→production), save-to-stage (dev→stage) | +| `/api/search` | Global full-text search | +| `/api/runtime-context` | Runtime environment detection | ### Service Layer @@ -356,6 +376,7 @@ static async create({ data, currentUser, transaction: externalTransaction, runti **Publish Service** (`services/publish.ts`): Implements the dev→stage→production workflow with: + - Transaction locking to prevent concurrent publishes - Source key tracking for content lineage - Bulk copy operations for pages and audio tracks @@ -364,14 +385,14 @@ Implements the dev→stage→production workflow with: **Query Building** in `findAll()`: -| Filter Type | Example | SQL | -|-------------|---------|-----| -| Text search | `?name=foo` | `name ILIKE '%foo%'` | -| Range | `?size_mbRange=[0,100]` | `size_mb >= 0 AND size_mb <= 100` | -| Enum | `?asset_type=image` | `asset_type = 'image'` | -| Relation | `?project=uuid` | JOIN with projects table | -| Sort | `?field=name&sort=asc` | `ORDER BY name ASC` | -| Pagination | `?page=1&limit=10` | `OFFSET 0 LIMIT 10` | +| Filter Type | Example | SQL | +| ----------- | ----------------------- | --------------------------------- | +| Text search | `?name=foo` | `name ILIKE '%foo%'` | +| Range | `?size_mbRange=[0,100]` | `size_mb >= 0 AND size_mb <= 100` | +| Enum | `?asset_type=image` | `asset_type = 'image'` | +| Relation | `?project=uuid` | JOIN with projects table | +| Sort | `?field=name&sort=asc` | `ORDER BY name ASC` | +| Pagination | `?page=1&limit=10` | `OFFSET 0 LIMIT 10` | --- @@ -402,6 +423,7 @@ Implements the dev→stage→production workflow with: 4. Fallback to Public role for unauthenticated **Permission Naming Convention**: + - `CREATE_` - Create records - `READ_` - Read records - `UPDATE_` - Modify records @@ -420,15 +442,16 @@ For production content accessible without authentication: ```javascript const requireRuntimeReadOrAuth = (req, res, next) => { - const isPublicEnvironment = req.runtimeContext?.headerEnvironment === 'production'; + const isPublicEnvironment = + req.runtimeContext?.headerEnvironment === 'production'; const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method); if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) { req.isRuntimePublicRequest = true; - return next(); // Allow without JWT + return next(); // Allow without JWT } - return jwtAuth(req, res, next); // Require JWT + return jwtAuth(req, res, next); // Require JWT }; ``` @@ -438,16 +461,17 @@ const requireRuntimeReadOrAuth = (req, res, next) => { Pre-configured limiters (`middlewares/rateLimiter.ts`): -| Limiter | Window | Max Requests | Use Case | -|---------|--------|--------------|----------| -| `authLimiter` | 15 min | 10 | Authentication endpoints | -| `passwordResetLimiter` | 1 hour | 5 | Password reset | -| `apiLimiter` | 1 min | 100 | General API | -| `uploadLimiter` | 1 min | 10 | File uploads | -| `downloadLimiter` | 1 min | 200 | File downloads | -| `searchLimiter` | 1 min | 30 | Search queries | +| Limiter | Window | Max Requests | Use Case | +| ---------------------- | ------ | ------------ | ------------------------ | +| `authLimiter` | 15 min | 10 | Authentication endpoints | +| `passwordResetLimiter` | 1 hour | 5 | Password reset | +| `apiLimiter` | 1 min | 100 | General API | +| `uploadLimiter` | 1 min | 10 | File uploads | +| `downloadLimiter` | 1 min | 200 | File downloads | +| `searchLimiter` | 1 min | 30 | Search queries | Headers returned: + - `X-RateLimit-Limit`: Maximum requests - `X-RateLimit-Remaining`: Remaining requests - `X-RateLimit-Reset`: Reset time (ISO timestamp) @@ -460,24 +484,26 @@ Headers returned: **Storage Provider Selection**: ```javascript -const provider = config.fileStorage.provider || +const provider = + config.fileStorage.provider || (hasS3Credentials ? 's3' : hasGCloudCredentials ? 'gcloud' : 'local'); ``` **S3 Operations**: -| Operation | Method | Description | -|-----------|--------|-------------| -| Upload | `upload(key, data, options)` | Put object with metadata | -| Download | `download(key)` | Get object stream | -| Presign | `getSignedUrl(key, expiresIn)` | Generate presigned URL | -| Delete | `delete(key)` / `deleteMany(keys)` | Remove objects | -| Check | `exists(key)` | Head object | -| List | `list(prefix)` | List objects with prefix | +| Operation | Method | Description | +| --------- | ---------------------------------- | ------------------------ | +| Upload | `upload(key, data, options)` | Put object with metadata | +| Download | `download(key)` | Get object stream | +| Presign | `getSignedUrl(key, expiresIn)` | Generate presigned URL | +| Delete | `delete(key)` / `deleteMany(keys)` | Remove objects | +| Check | `exists(key)` | Head object | +| List | `list(prefix)` | List objects with prefix | **Chunked Uploads** (`UploadSessionManager`): For large files, supports multipart upload sessions: + 1. `POST /upload-sessions/init` - Create session 2. `POST /upload-sessions/:id/chunk` - Upload chunk 3. `POST /upload-sessions/:id/finalize` - Complete upload @@ -498,11 +524,21 @@ class AppError extends Error { } } -class NotFoundError extends AppError { statusCode = 404 } -class ValidationError extends AppError { statusCode = 400 } -class ForbiddenError extends AppError { statusCode = 403 } -class UnauthorizedError extends AppError { statusCode = 401 } -class ConflictError extends AppError { statusCode = 409 } +class NotFoundError extends AppError { + statusCode = 404; +} +class ValidationError extends AppError { + statusCode = 400; +} +class ForbiddenError extends AppError { + statusCode = 403; +} +class UnauthorizedError extends AppError { + statusCode = 401; +} +class ConflictError extends AppError { + statusCode = 409; +} ``` **Async Handler** (`helpers.ts`): @@ -563,12 +599,15 @@ function requestLogger(req, res, next) { res.setHeader('X-Request-Id', requestId); res.on('finish', () => { - req.log.info({ - method: req.method, - url: req.originalUrl, - status: res.statusCode, - duration: Date.now() - start, - }, 'Request completed'); + req.log.info( + { + method: req.method, + url: req.originalUrl, + status: res.statusCode, + duration: Date.now() - start, + }, + 'Request completed', + ); }); } ``` @@ -579,30 +618,30 @@ function requestLogger(req, res, next) { **Environment Variables** (`config.ts`): -| Variable | Description | Default | -|----------|-------------|---------| -| `SECRET_KEY` | JWT signing key | UUID-based default | -| `ADMIN_EMAIL` | Admin user email | `admin@flatlogic.com` | -| `ADMIN_PASS` | Admin user password | Generated | -| `AWS_S3_BUCKET` | S3 bucket name | - | -| `AWS_S3_REGION` | S3 region | `us-east-1` | -| `AWS_ACCESS_KEY_ID` | AWS access key | - | -| `AWS_SECRET_ACCESS_KEY` | AWS secret key | - | -| `GOOGLE_CLIENT_ID` | Google OAuth client ID | - | -| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | - | -| `MS_CLIENT_ID` | Microsoft OAuth client ID | - | -| `MS_CLIENT_SECRET` | Microsoft OAuth client secret | - | -| `EMAIL_USER` | SMTP username | - | -| `EMAIL_PASS` | SMTP password | - | -| `LOG_LEVEL` | Logging level | `info` | +| Variable | Description | Default | +| ----------------------- | ----------------------------- | --------------------- | +| `SECRET_KEY` | JWT signing key | UUID-based default | +| `ADMIN_EMAIL` | Admin user email | `admin@flatlogic.com` | +| `ADMIN_PASS` | Admin user password | Generated | +| `AWS_S3_BUCKET` | S3 bucket name | - | +| `AWS_S3_REGION` | S3 region | `us-east-1` | +| `AWS_ACCESS_KEY_ID` | AWS access key | - | +| `AWS_SECRET_ACCESS_KEY` | AWS secret key | - | +| `GOOGLE_CLIENT_ID` | Google OAuth client ID | - | +| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | - | +| `MS_CLIENT_ID` | Microsoft OAuth client ID | - | +| `MS_CLIENT_SECRET` | Microsoft OAuth client secret | - | +| `EMAIL_USER` | SMTP username | - | +| `EMAIL_PASS` | SMTP password | - | +| `LOG_LEVEL` | Logging level | `info` | **Database Configuration** (`db/db-config.ts`): -| Environment | Database | Logging | -|-------------|----------|---------| -| `production` | `DB_*` env vars | Disabled | -| `development` | `db_tour_builder_platform` | Console | -| `dev_stage` | `DB_*` env vars | Console | +| Environment | Database | Logging | +| ------------- | -------------------------- | -------- | +| `production` | `DB_*` env vars | Disabled | +| `development` | `db_tour_builder_platform` | Console | +| `dev_stage` | `DB_*` env vars | Console | --- @@ -647,23 +686,23 @@ GET /api/health ## Key Implementation Files -| File | Purpose | -|------|---------| -| `src/index.ts` | Application entry, middleware setup, route mounting | -| `src/config.ts` | Environment configuration | -| `src/helpers.ts` | wrapAsync, commonErrorHandler, jwtSign, isUuidV4 | -| `src/auth/auth.ts` | Passport strategies (JWT, Google, Microsoft) | -| `src/factories/router.factory.ts` | Route generator for entities | -| `src/factories/service.factory.ts` | Service generator for entities | -| `src/db/api/base.api.ts` | GenericDBApi base class | -| `src/middlewares/check-permissions.ts` | RBAC permission checking | -| `src/middlewares/rateLimiter.ts` | Rate limiting configuration | -| `src/middlewares/runtime-context.ts` | Runtime environment detection | -| `src/middlewares/runtime-public.ts` | Public runtime access control & field sanitization | -| `src/services/publish.ts` | Publishing workflow service | +| File | Purpose | +| ---------------------------------------- | --------------------------------------------------------- | +| `src/index.ts` | Application entry, middleware setup, route mounting | +| `src/config.ts` | Environment configuration | +| `src/helpers.ts` | wrapAsync, commonErrorHandler, jwtSign, isUuidV4 | +| `src/auth/auth.ts` | Passport strategies (JWT, Google, Microsoft) | +| `src/factories/router.factory.ts` | Route generator for entities | +| `src/factories/service.factory.ts` | Service generator for entities | +| `src/db/api/base.api.ts` | GenericDBApi base class | +| `src/middlewares/check-permissions.ts` | RBAC permission checking | +| `src/middlewares/rateLimiter.ts` | Rate limiting configuration | +| `src/middlewares/runtime-context.ts` | Runtime environment detection | +| `src/middlewares/runtime-public.ts` | Public runtime access control & field sanitization | +| `src/services/publish.ts` | Publishing workflow service | | `src/services/file/S3StorageProvider.ts` | S3 storage implementation using official AWS SDK v3 types | -| `src/utils/logger.ts` | Pino logger configuration | -| `src/utils/errors.ts` | Error class definitions | +| `src/utils/logger.ts` | Pino logger configuration | +| `src/utils/errors.ts` | Error class definitions | --- diff --git a/backend/docs/database-schema.md b/backend/docs/database-schema.md index a144a12..63ead00 100644 --- a/backend/docs/database-schema.md +++ b/backend/docs/database-schema.md @@ -15,13 +15,14 @@ This document provides a comprehensive analysis of the Tour Builder Platform dat Configuration is environment-based in `backend/src/db/db-config.ts`: -| Environment | Database | Logging | -|-------------|----------|---------| -| production | `DB_*` env vars | Disabled | -| development | `db_tour_builder_platform` | Console | -| dev_stage | `DB_*` env vars | Console | +| Environment | Database | Logging | +| ----------- | -------------------------- | -------- | +| production | `DB_*` env vars | Disabled | +| development | `db_tour_builder_platform` | Console | +| dev_stage | `DB_*` env vars | Console | **Migration Settings:** + - Migration storage: `sequelize` (SequelizeMeta table) - Seeder storage: `sequelize` @@ -71,32 +72,34 @@ Configuration is environment-based in `backend/src/db/db-config.ts`: User accounts for authentication and authorization. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `firstName` | TEXT | nullable | User's first name | -| `lastName` | TEXT | nullable | User's last name | -| `phoneNumber` | TEXT | nullable | Contact phone | -| `email` | TEXT | NOT NULL, UNIQUE | Email (validated) | -| `disabled` | BOOLEAN | NOT NULL, default: false | Account disabled flag | -| `password` | TEXT | NOT NULL | Bcrypt hashed password | -| `emailVerified` | BOOLEAN | NOT NULL, default: false | Email verification status | -| `emailVerificationToken` | TEXT | nullable | Token for email verification | -| `emailVerificationTokenExpiresAt` | DATE | nullable | Token expiry | -| `passwordResetToken` | TEXT | nullable | Password reset token | -| `passwordResetTokenExpiresAt` | DATE | nullable | Reset token expiry | -| `provider` | TEXT | NOT NULL, default: 'local' | Auth provider (local, google, microsoft) | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication key | -| `app_roleId` | UUID | FK → roles.id | User's application role | -| `createdById` | UUID | FK → users.id | Record creator | -| `updatedById` | UUID | FK → users.id | Last modifier | +| Field | Type | Constraints | Description | +| --------------------------------- | ----------- | -------------------------- | ---------------------------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `firstName` | TEXT | nullable | User's first name | +| `lastName` | TEXT | nullable | User's last name | +| `phoneNumber` | TEXT | nullable | Contact phone | +| `email` | TEXT | NOT NULL, UNIQUE | Email (validated) | +| `disabled` | BOOLEAN | NOT NULL, default: false | Account disabled flag | +| `password` | TEXT | NOT NULL | Bcrypt hashed password | +| `emailVerified` | BOOLEAN | NOT NULL, default: false | Email verification status | +| `emailVerificationToken` | TEXT | nullable | Token for email verification | +| `emailVerificationTokenExpiresAt` | DATE | nullable | Token expiry | +| `passwordResetToken` | TEXT | nullable | Password reset token | +| `passwordResetTokenExpiresAt` | DATE | nullable | Reset token expiry | +| `provider` | TEXT | NOT NULL, default: 'local' | Auth provider (local, google, microsoft) | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication key | +| `app_roleId` | UUID | FK → roles.id | User's application role | +| `createdById` | UUID | FK → users.id | Record creator | +| `updatedById` | UUID | FK → users.id | Last modifier | **Indexes:** + - `email` (unique) - `app_roleId` - `deletedAt` **Associations:** + - `belongsTo` roles (as `app_role`) - `hasMany` project_memberships - `hasMany` presigned_url_requests @@ -106,6 +109,7 @@ User accounts for authentication and authorization. - `hasMany` file (as `avatar`, polymorphic) **Hooks:** + - `beforeCreate`: Trims string fields, auto-generates password for OAuth users, sets `emailVerified: true` for OAuth - `beforeUpdate`: Trims string fields @@ -115,18 +119,20 @@ User accounts for authentication and authorization. Application-level roles for RBAC. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `name` | TEXT | NOT NULL, len: 1-100 | Role name | -| `role_customization` | TEXT | nullable | Custom role settings | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| -------------------- | ----------- | -------------------- | -------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `name` | TEXT | NOT NULL, len: 1-100 | Role name | +| `role_customization` | TEXT | nullable | Custom role settings | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Associations:** + - `hasMany` users (as `users_app_role`) - `belongsToMany` permissions (through `rolesPermissionsPermissions`) **Seeded Roles:** + - Administrator (full access) - Platform Owner - Account Manager @@ -141,13 +147,14 @@ Application-level roles for RBAC. Individual permission definitions for RBAC. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `name` | TEXT | NOT NULL, UNIQUE, len: 1-100 | Permission name | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| ------------ | ----------- | ---------------------------- | -------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `name` | TEXT | NOT NULL, UNIQUE, len: 1-100 | Permission name | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Permission Naming Convention:** + - `CREATE_` - Create records - `READ_` - Read records - `UPDATE_` - Modify records @@ -168,25 +175,27 @@ customer grants in `production_presentation_access`. Virtual tour projects - the main organizational unit. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `name` | TEXT | NOT NULL, len: 1-255 | Project name | -| `slug` | TEXT | NOT NULL, UNIQUE, regex: `^[a-z0-9_-]+$/i`, len: 1-255 | URL-safe identifier | -| `description` | TEXT | nullable | Project description | -| `logo_url` | TEXT | nullable | Project logo URL | -| `favicon_url` | TEXT | nullable | Favicon URL | -| `og_image_url` | TEXT | nullable | Open Graph image URL | -| `production_presentation_visibility` | ENUM | NOT NULL, default: 'public' | Production runtime visibility: `public`, `private` | -| `design_width` | INTEGER | nullable, default: 1920 | Design canvas width (px) | -| `design_height` | INTEGER | nullable, default: 1080 | Design canvas height (px) | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| ------------------------------------ | ----------- | ------------------------------------------------------ | -------------------------------------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `name` | TEXT | NOT NULL, len: 1-255 | Project name | +| `slug` | TEXT | NOT NULL, UNIQUE, regex: `^[a-z0-9_-]+$/i`, len: 1-255 | URL-safe identifier | +| `description` | TEXT | nullable | Project description | +| `logo_url` | TEXT | nullable | Project logo URL | +| `favicon_url` | TEXT | nullable | Favicon URL | +| `og_image_url` | TEXT | nullable | Open Graph image URL | +| `production_presentation_visibility` | ENUM | NOT NULL, default: 'public' | Production runtime visibility: `public`, `private` | +| `design_width` | INTEGER | nullable, default: 1920 | Design canvas width (px) | +| `design_height` | INTEGER | nullable, default: 1080 | Design canvas height (px) | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Indexes:** + - `slug` (unique) - `deletedAt` **Associations:** + - `hasMany` project_memberships - `hasMany` assets - `hasMany` presigned_url_requests @@ -223,30 +232,31 @@ and `defaultBorderColor`/`activeBorderColor`. Singleton table for platform-wide defaults. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default UUIDv4 | Primary identifier | -| `settings_json` | JSON | NOT NULL | Defaults for `offline`, `fullscreen`, `sound` | -| `createdById` | UUID | nullable FK users | Creator | -| `updatedById` | UUID | nullable FK users | Last updater | -| `createdAt`, `updatedAt`, `deletedAt` | DATE | paranoid timestamps | Lifecycle fields | +| Field | Type | Constraints | Description | +| ------------------------------------- | ---- | ------------------- | --------------------------------------------- | +| `id` | UUID | PK, default UUIDv4 | Primary identifier | +| `settings_json` | JSON | NOT NULL | Defaults for `offline`, `fullscreen`, `sound` | +| `createdById` | UUID | nullable FK users | Creator | +| `updatedById` | UUID | nullable FK users | Last updater | +| `createdAt`, `updatedAt`, `deletedAt` | DATE | paranoid timestamps | Lifecycle fields | #### `project_ui_control_settings` Project/environment-level overrides. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default UUIDv4 | Primary identifier | -| `projectId` | UUID | NOT NULL, FK projects, cascade delete | Owning project | -| `environment` | ENUM | NOT NULL: `dev`, `stage`, `production` | Content environment | -| `source_key` | TEXT | nullable | Snapshot/publish/clone provenance | -| `settings_json` | JSON | NOT NULL | Project-level UI-control overrides | -| `importHash` | STRING(255) | nullable unique | Import deduplication | -| `createdById`, `updatedById` | UUID | nullable FK users | Audit users | -| `createdAt`, `updatedAt`, `deletedAt` | DATE | paranoid timestamps | Lifecycle fields | +| Field | Type | Constraints | Description | +| ------------------------------------- | ----------- | -------------------------------------- | ---------------------------------- | +| `id` | UUID | PK, default UUIDv4 | Primary identifier | +| `projectId` | UUID | NOT NULL, FK projects, cascade delete | Owning project | +| `environment` | ENUM | NOT NULL: `dev`, `stage`, `production` | Content environment | +| `source_key` | TEXT | nullable | Snapshot/publish/clone provenance | +| `settings_json` | JSON | NOT NULL | Project-level UI-control overrides | +| `importHash` | STRING(255) | nullable unique | Import deduplication | +| `createdById`, `updatedById` | UUID | nullable FK users | Audit users | +| `createdAt`, `updatedAt`, `deletedAt` | DATE | paranoid timestamps | Lifecycle fields | **Indexes:** + - unique partial index on `("projectId", environment)` where `deletedAt IS NULL` - index on `deletedAt` @@ -256,21 +266,23 @@ Project/environment-level overrides. Explicit customer access grants for private production presentations. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `projectId` | UUID | FK → projects.id, NOT NULL | Private production presentation project | -| `userId` | UUID | FK → users.id, NOT NULL | Customer user allowed to view the presentation | -| `createdById` | UUID | FK → users.id | Record creator | -| `updatedById` | UUID | FK → users.id | Last modifier | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| ------------- | ----------- | -------------------------- | ---------------------------------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `projectId` | UUID | FK → projects.id, NOT NULL | Private production presentation project | +| `userId` | UUID | FK → users.id, NOT NULL | Customer user allowed to view the presentation | +| `createdById` | UUID | FK → users.id | Record creator | +| `updatedById` | UUID | FK → users.id | Last modifier | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Indexes:** + - `projectId` - `userId` - `projectId, userId` (unique for active rows where `deletedAt IS NULL`) **Access Rules:** + - Public production projects do not require rows in this table. - Staff users with any RBAC permission can view every private production presentation. - Public-role customer users with no RBAC permissions need an active row for each private production presentation. @@ -281,18 +293,19 @@ Explicit customer access grants for private production presentations. Junction table linking users to projects with access levels. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `access_level` | ENUM | NOT NULL, default: 'viewer' | Access level: `owner`, `editor`, `reviewer`, `viewer` | -| `is_active` | BOOLEAN | NOT NULL, default: false | Membership active status | -| `invited_at` | DATE | nullable | Invitation timestamp | -| `accepted_at` | DATE | nullable | Acceptance timestamp | -| `projectId` | UUID | FK → projects.id | Associated project | -| `userId` | UUID | FK → users.id | Associated user | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| -------------- | ----------- | --------------------------- | ----------------------------------------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `access_level` | ENUM | NOT NULL, default: 'viewer' | Access level: `owner`, `editor`, `reviewer`, `viewer` | +| `is_active` | BOOLEAN | NOT NULL, default: false | Membership active status | +| `invited_at` | DATE | nullable | Invitation timestamp | +| `accepted_at` | DATE | nullable | Acceptance timestamp | +| `projectId` | UUID | FK → projects.id | Associated project | +| `userId` | UUID | FK → users.id | Associated user | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Indexes:** + - `projectId` - `userId` - `projectId, userId` (unique composite) @@ -309,43 +322,45 @@ Junction table linking users to projects with access levels. Individual pages/scenes within a tour. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `environment` | ENUM | NOT NULL, default: 'dev' | Environment: `dev`, `stage`, `production` | -| `source_key` | TEXT | nullable | Reference to source version when published | -| `name` | TEXT | NOT NULL, len: 1-255 | Page display name | -| `slug` | TEXT | NOT NULL, regex: `^[a-z0-9_-]+$/i`, len: 1-255 | URL-safe identifier | -| `sort_order` | INTEGER | NOT NULL, default: 0 | Presentation display order; first sorted page is the entry page | -| `background_image_url` | TEXT | nullable | Background image URL | -| `background_video_url` | TEXT | nullable | Background video URL | -| `background_embed_url` | TEXT | nullable | Background 360/embed URL | -| `background_audio_url` | TEXT | nullable | Background audio URL | -| `background_loop` | BOOLEAN | NOT NULL, default: false | Loop background media | -| `background_video_autoplay` | BOOLEAN | NOT NULL, default: true | Autoplay background video | -| `background_video_loop` | BOOLEAN | NOT NULL, default: true | Loop background video | -| `background_video_muted` | BOOLEAN | NOT NULL, default: true | Mute background video | -| `background_video_start_time` | DECIMAL(10,1) | nullable | Background video start time (seconds) | -| `background_video_end_time` | DECIMAL(10,1) | nullable | Background video end time (seconds) | -| `background_video_play_once` | BOOLEAN | NOT NULL, default: false | Play video only once per session (show last frame on revisit) | -| `background_audio_autoplay` | BOOLEAN | NOT NULL, default: true | Autoplay background audio | -| `background_audio_loop` | BOOLEAN | NOT NULL, default: true | Loop background audio | -| `background_audio_start_time` | DECIMAL(10,1) | nullable | Background audio start time (seconds) | -| `background_audio_end_time` | DECIMAL(10,1) | nullable | Background audio end time (seconds) | -| `design_width` | INTEGER | nullable, default: null | Design canvas width (px) - copied from project on save | -| `design_height` | INTEGER | nullable, default: null | Design canvas height (px) - copied from project on save | -| `requires_auth` | BOOLEAN | NOT NULL, default: false | Requires authentication | -| `ui_schema_json` | JSON | nullable | UI element schema | -| `projectId` | UUID | FK → projects.id | Parent project | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| ----------------------------- | ------------- | ---------------------------------------------- | --------------------------------------------------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `environment` | ENUM | NOT NULL, default: 'dev' | Environment: `dev`, `stage`, `production` | +| `source_key` | TEXT | nullable | Reference to source version when published | +| `name` | TEXT | NOT NULL, len: 1-255 | Page display name | +| `slug` | TEXT | NOT NULL, regex: `^[a-z0-9_-]+$/i`, len: 1-255 | URL-safe identifier | +| `sort_order` | INTEGER | NOT NULL, default: 0 | Presentation display order; first sorted page is the entry page | +| `background_image_url` | TEXT | nullable | Background image URL | +| `background_video_url` | TEXT | nullable | Background video URL | +| `background_embed_url` | TEXT | nullable | Background 360/embed URL | +| `background_audio_url` | TEXT | nullable | Background audio URL | +| `background_loop` | BOOLEAN | NOT NULL, default: false | Loop background media | +| `background_video_autoplay` | BOOLEAN | NOT NULL, default: true | Autoplay background video | +| `background_video_loop` | BOOLEAN | NOT NULL, default: true | Loop background video | +| `background_video_muted` | BOOLEAN | NOT NULL, default: true | Mute background video | +| `background_video_start_time` | DECIMAL(10,1) | nullable | Background video start time (seconds) | +| `background_video_end_time` | DECIMAL(10,1) | nullable | Background video end time (seconds) | +| `background_video_play_once` | BOOLEAN | NOT NULL, default: false | Play video only once per session (show last frame on revisit) | +| `background_audio_autoplay` | BOOLEAN | NOT NULL, default: true | Autoplay background audio | +| `background_audio_loop` | BOOLEAN | NOT NULL, default: true | Loop background audio | +| `background_audio_start_time` | DECIMAL(10,1) | nullable | Background audio start time (seconds) | +| `background_audio_end_time` | DECIMAL(10,1) | nullable | Background audio end time (seconds) | +| `design_width` | INTEGER | nullable, default: null | Design canvas width (px) - copied from project on save | +| `design_height` | INTEGER | nullable, default: null | Design canvas height (px) - copied from project on save | +| `requires_auth` | BOOLEAN | NOT NULL, default: false | Requires authentication | +| `ui_schema_json` | JSON | nullable | UI element schema | +| `projectId` | UUID | FK → projects.id | Parent project | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Indexes:** + - `projectId` - `projectId, environment, slug` (unique composite) - `projectId, environment, sort_order` - `deletedAt` **Ordering semantics:** + - Constructor page reordering updates `sort_order` for dev pages only. - Constructor page duplication creates a new dev row with a unique slug, fresh page ID, fresh inline element IDs in `ui_schema_json`, and @@ -359,10 +374,12 @@ Individual pages/scenes within a tour. `sort_order` to production. **Associations:** + - `belongsTo` projects (as `project`) **UI Schema JSON Structure:** The `ui_schema_json` field contains all page elements and navigation configuration: + ```json { "elements": [{ @@ -382,6 +399,7 @@ The `ui_schema_json` field contains all page elements and navigation configurati ``` **Element Types:** + - `navigation_next` - Forward navigation button - `navigation_prev` - Back navigation button - `spot` - Hotspot/clickable area @@ -390,6 +408,13 @@ The `ui_schema_json` field contains all page elements and navigation configurati - `gallery` - Image gallery - `carousel` - Image carousel - `logo` - Logo element + +**Navigation transition note:** Forward navigation elements own selected +transition videos. Targeted `navigation_prev` elements may contain derived +transition fields in `ui_schema_json`, but `TourPagesService` refreshes those +fields from the matching incoming forward element before save-time reverse-video +validation and clears stale values when the forward transition was removed. + - `video_player` - Video player - `audio_player` - Audio player - `popup` - Popup/modal @@ -402,26 +427,27 @@ The `ui_schema_json` field contains all page elements and navigation configurati Media files (images, videos, audio, documents) used in tours. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `name` | TEXT | nullable, len: 0-255 | Asset display name | -| `asset_type` | ENUM | NOT NULL | Media type: `image`, `video`, `audio`, `file` | -| `type` | ENUM | NOT NULL, default: 'general' | Usage type (see values below) | -| `cdn_url` | TEXT | nullable | Public CDN URL | -| `storage_key` | TEXT | nullable | S3/storage key | -| `mime_type` | TEXT | nullable, validated | MIME type (e.g., `image/png`) | -| `size_mb` | DECIMAL | nullable | File size in MB | -| `width_px` | INTEGER | nullable | Width in pixels | -| `height_px` | INTEGER | nullable | Height in pixels | -| `duration_sec` | DECIMAL | nullable | Duration for audio/video | -| `frame_rate` | DECIMAL | nullable | Video FPS from backend ffprobe | -| `checksum` | TEXT | nullable | File checksum | -| `is_public` | BOOLEAN | NOT NULL, default: false | Publicly accessible | -| `projectId` | UUID | FK → projects.id | Parent project | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| -------------- | ----------- | ---------------------------- | --------------------------------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `name` | TEXT | nullable, len: 0-255 | Asset display name | +| `asset_type` | ENUM | NOT NULL | Media type: `image`, `video`, `audio`, `file` | +| `type` | ENUM | NOT NULL, default: 'general' | Usage type (see values below) | +| `cdn_url` | TEXT | nullable | Public CDN URL | +| `storage_key` | TEXT | nullable | S3/storage key | +| `mime_type` | TEXT | nullable, validated | MIME type (e.g., `image/png`) | +| `size_mb` | DECIMAL | nullable | File size in MB | +| `width_px` | INTEGER | nullable | Width in pixels | +| `height_px` | INTEGER | nullable | Height in pixels | +| `duration_sec` | DECIMAL | nullable | Duration for audio/video | +| `frame_rate` | DECIMAL | nullable | Video FPS from backend ffprobe | +| `checksum` | TEXT | nullable | File checksum | +| `is_public` | BOOLEAN | NOT NULL, default: false | Publicly accessible | +| `projectId` | UUID | FK → projects.id | Parent project | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Asset Usage Types:** + - `icon` - UI icons - `background_image` - Page backgrounds - `audio` - Audio files @@ -433,6 +459,7 @@ Media files (images, videos, audio, documents) used in tours. - `general` - General assets **Indexes:** + - `projectId` - `storage_key` partial active-row index (`assets_storage_key_active`, where `deletedAt IS NULL` and `storage_key IS NOT NULL`) for transition/reversed-video lookup by canonical storage key - `asset_type` @@ -441,6 +468,7 @@ Media files (images, videos, audio, documents) used in tours. - `deletedAt` **Associations:** + - `belongsTo` projects (as `project`) - `hasMany` asset_variants (as `asset_variants_asset`) @@ -450,19 +478,20 @@ Media files (images, videos, audio, documents) used in tours. Processed variants of assets (thumbnails, transcoded videos, reversed videos, etc.). -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `variant_type` | ENUM | nullable | Variant type (see values below) | -| `cdn_url` | TEXT | nullable, len: 0-2048, URL validated | Variant CDN URL | -| `storage_key` | TEXT | nullable | Private storage path (e.g., `assets/{assetId}/reversed.mp4`) | -| `width_px` | INTEGER | nullable, min: 0 | Width in pixels | -| `height_px` | INTEGER | nullable, min: 0 | Height in pixels | -| `size_mb` | DECIMAL | nullable, min: 0 | File size in MB | -| `assetId` | UUID | FK → assets.id | Parent asset | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| -------------- | ----------- | ------------------------------------ | ------------------------------------------------------------ | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `variant_type` | ENUM | nullable | Variant type (see values below) | +| `cdn_url` | TEXT | nullable, len: 0-2048, URL validated | Variant CDN URL | +| `storage_key` | TEXT | nullable | Private storage path (e.g., `assets/{assetId}/reversed.mp4`) | +| `width_px` | INTEGER | nullable, min: 0 | Width in pixels | +| `height_px` | INTEGER | nullable, min: 0 | Height in pixels | +| `size_mb` | DECIMAL | nullable, min: 0 | File size in MB | +| `assetId` | UUID | FK → assets.id | Parent asset | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Variant Types:** + - `thumbnail` - Small preview - `preview` - Medium preview - `webp` - WebP format @@ -474,6 +503,7 @@ Processed variants of assets (thumbnails, transcoded videos, reversed videos, et **Cascade Behavior:** Deleted when parent asset is deleted. **Indexes:** + - `assetId, variant_type` partial active-row index (`asset_variants_asset_id_variant_type_active`, where `deletedAt IS NULL`) for asset variant joins and reversed-variant lookup --- @@ -482,19 +512,19 @@ Processed variants of assets (thumbnails, transcoded videos, reversed videos, et Tracks presigned URL requests for secure uploads/downloads. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `purpose` | ENUM | nullable | Purpose: `upload`, `download` | -| `asset_type` | ENUM | nullable | Asset type: `image`, `video`, `audio`, `file` | -| `requested_key` | TEXT | nullable, len: 0-1024 | Requested storage key | -| `mime_type` | TEXT | nullable, len: 0-255, validated | Expected MIME type | -| `requested_size_mb` | DECIMAL | nullable, min: 0 | Expected file size | -| `expires_at` | DATE | nullable | URL expiration time | -| `status` | TEXT | nullable | Request status | -| `projectId` | UUID | FK → projects.id | Associated project | -| `userId` | UUID | FK → users.id | Requesting user | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| ------------------- | ----------- | ------------------------------- | --------------------------------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `purpose` | ENUM | nullable | Purpose: `upload`, `download` | +| `asset_type` | ENUM | nullable | Asset type: `image`, `video`, `audio`, `file` | +| `requested_key` | TEXT | nullable, len: 0-1024 | Requested storage key | +| `mime_type` | TEXT | nullable, len: 0-255, validated | Expected MIME type | +| `requested_size_mb` | DECIMAL | nullable, min: 0 | Expected file size | +| `expires_at` | DATE | nullable | URL expiration time | +| `status` | TEXT | nullable | Request status | +| `projectId` | UUID | FK → projects.id | Associated project | +| `userId` | UUID | FK → users.id | Requesting user | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | --- @@ -504,20 +534,20 @@ Tracks presigned URL requests for secure uploads/downloads. Background audio tracks for projects. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `environment` | ENUM | NOT NULL, default: 'dev' | Environment: `dev`, `stage`, `production` | -| `source_key` | TEXT | nullable | Reference to source version when published | -| `name` | TEXT | nullable, len: 0-255 | Track name | -| `slug` | TEXT | nullable | URL-safe identifier | -| `url` | TEXT | nullable | Audio file URL | -| `loop` | BOOLEAN | NOT NULL, default: false | Loop playback | -| `volume` | DECIMAL | nullable, min: 0, max: 1 | Volume level (0.0-1.0) | -| `sort_order` | INTEGER | nullable | Playback order | -| `is_enabled` | BOOLEAN | NOT NULL, default: false | Track enabled | -| `projectId` | UUID | FK → projects.id | Parent project | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| ------------- | ----------- | ------------------------ | ------------------------------------------ | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `environment` | ENUM | NOT NULL, default: 'dev' | Environment: `dev`, `stage`, `production` | +| `source_key` | TEXT | nullable | Reference to source version when published | +| `name` | TEXT | nullable, len: 0-255 | Track name | +| `slug` | TEXT | nullable | URL-safe identifier | +| `url` | TEXT | nullable | Audio file URL | +| `loop` | BOOLEAN | NOT NULL, default: false | Loop playback | +| `volume` | DECIMAL | nullable, min: 0, max: 1 | Volume level (0.0-1.0) | +| `sort_order` | INTEGER | nullable | Playback order | +| `is_enabled` | BOOLEAN | NOT NULL, default: false | Track enabled | +| `projectId` | UUID | FK → projects.id | Parent project | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Note:** The `environment` field is NOT NULL with default 'dev' for consistency with `tour_pages`. @@ -527,30 +557,33 @@ Background audio tracks for projects. Environment-aware project-level transition settings for CSS-based page transitions. Settings cascade: Element → Project → Global → Hardcoded defaults. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `environment` | ENUM | NOT NULL | Environment: `dev`, `stage`, `production` | -| `source_key` | TEXT | nullable | Reference to source record when published | -| `transition_type` | TEXT | NOT NULL, default: 'fade' | CSS transition type (`fade`, `none`) | -| `duration_ms` | INTEGER | NOT NULL, default: 700 | Transition duration in milliseconds | -| `easing` | TEXT | NOT NULL, default: 'ease-in-out' | CSS easing function | -| `overlay_color` | TEXT | NOT NULL, default: '#000000' | Transition overlay color | -| `projectId` | UUID | FK → projects.id, NOT NULL | Parent project | -| `createdById` | UUID | FK → users.id, nullable | Creator user | -| `updatedById` | UUID | FK → users.id, nullable | Last updater | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| ----------------- | ----------- | -------------------------------- | ----------------------------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `environment` | ENUM | NOT NULL | Environment: `dev`, `stage`, `production` | +| `source_key` | TEXT | nullable | Reference to source record when published | +| `transition_type` | TEXT | NOT NULL, default: 'fade' | CSS transition type (`fade`, `none`) | +| `duration_ms` | INTEGER | NOT NULL, default: 700 | Transition duration in milliseconds | +| `easing` | TEXT | NOT NULL, default: 'ease-in-out' | CSS easing function | +| `overlay_color` | TEXT | NOT NULL, default: '#000000' | Transition overlay color | +| `projectId` | UUID | FK → projects.id, NOT NULL | Parent project | +| `createdById` | UUID | FK → users.id, nullable | Creator user | +| `updatedById` | UUID | FK → users.id, nullable | Last updater | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Indexes:** + - `project_transition_settings_project_env_unique` - UNIQUE on (projectId, environment) WHERE deletedAt IS NULL **Associations:** + - `belongsTo` projects (as `project`) - CASCADE on delete - `belongsTo` users (as `createdBy`, `updatedBy`) **Publishing Integration:** Copied between environments during Save to Stage (dev → stage) and Publish (stage → production). The `source_key` tracks lineage. **Cascade Resolution:** When determining transition settings: + 1. Element-level settings (from `ui_schema_json`) 2. Project-level settings (this table, environment-specific) 3. Global defaults (`global_transition_defaults`) @@ -564,24 +597,25 @@ Environment-aware project-level transition settings for CSS-based page transitio Records of content publishing between environments. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `title` | STRING | nullable, len: 0-255 | Event title | -| `description` | TEXT | nullable, len: 0-5000 | Event description | -| `from_environment` | ENUM | NOT NULL | Source: `dev`, `stage`, `production` | -| `to_environment` | ENUM | NOT NULL | Target: `dev`, `stage`, `production` | -| `started_at` | DATE | nullable | Start timestamp | -| `finished_at` | DATE | nullable | Completion timestamp | -| `status` | ENUM | NOT NULL, default: 'queued' | Status: `queued`, `running`, `success`, `failed` | -| `error_message` | TEXT | nullable | Error details | -| `pages_copied` | INTEGER | nullable, min: 0 | Pages copied count | -| `audios_copied` | INTEGER | nullable, min: 0 | Audio tracks copied count | -| `projectId` | UUID | FK → projects.id | Published project | -| `userId` | UUID | FK → users.id | Publishing user | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| ------------------ | ----------- | --------------------------- | ------------------------------------------------ | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `title` | STRING | nullable, len: 0-255 | Event title | +| `description` | TEXT | nullable, len: 0-5000 | Event description | +| `from_environment` | ENUM | NOT NULL | Source: `dev`, `stage`, `production` | +| `to_environment` | ENUM | NOT NULL | Target: `dev`, `stage`, `production` | +| `started_at` | DATE | nullable | Start timestamp | +| `finished_at` | DATE | nullable | Completion timestamp | +| `status` | ENUM | NOT NULL, default: 'queued' | Status: `queued`, `running`, `success`, `failed` | +| `error_message` | TEXT | nullable | Error details | +| `pages_copied` | INTEGER | nullable, min: 0 | Pages copied count | +| `audios_copied` | INTEGER | nullable, min: 0 | Audio tracks copied count | +| `projectId` | UUID | FK → projects.id | Published project | +| `userId` | UUID | FK → users.id | Publishing user | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Indexes:** + - `projectId` - `userId` - `status` @@ -595,17 +629,17 @@ Records of content publishing between environments. PWA cache configurations for offline support. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `environment` | ENUM | nullable | Environment: `dev`, `stage`, `production` | -| `cache_version` | TEXT | nullable, len: 0-255 | Cache version string | -| `manifest_json` | JSON | nullable | PWA manifest configuration | -| `asset_list_json` | JSON | nullable | List of cached assets | -| `generated_at` | DATE | nullable | Generation timestamp | -| `is_active` | BOOLEAN | NOT NULL, default: false | Cache active status | -| `projectId` | UUID | FK → projects.id | Parent project | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| ----------------- | ----------- | ------------------------ | ----------------------------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `environment` | ENUM | nullable | Environment: `dev`, `stage`, `production` | +| `cache_version` | TEXT | nullable, len: 0-255 | Cache version string | +| `manifest_json` | JSON | nullable | PWA manifest configuration | +| `asset_list_json` | JSON | nullable | List of cached assets | +| `generated_at` | DATE | nullable | Generation timestamp | +| `is_active` | BOOLEAN | NOT NULL, default: false | Cache active status | +| `projectId` | UUID | FK → projects.id | Parent project | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | --- @@ -615,19 +649,20 @@ PWA cache configurations for offline support. Audit trail for tour access. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `environment` | ENUM | NOT NULL | Access context: `admin`, `stage`, `production` | -| `path` | TEXT | nullable, len: 0-2048 | Accessed path | -| `ip_address` | TEXT | nullable, len: 0-45 | Client IP (IPv4/IPv6) | -| `user_agent` | TEXT | nullable, len: 0-1024 | Browser user agent | -| `accessed_at` | DATE | NOT NULL, default: NOW | Access timestamp | -| `projectId` | UUID | FK → projects.id | Accessed project | -| `userId` | UUID | FK → users.id | Accessing user (if authenticated) | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| ------------- | ----------- | ---------------------- | ---------------------------------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `environment` | ENUM | NOT NULL | Access context: `admin`, `stage`, `production` | +| `path` | TEXT | nullable, len: 0-2048 | Accessed path | +| `ip_address` | TEXT | nullable, len: 0-45 | Client IP (IPv4/IPv6) | +| `user_agent` | TEXT | nullable, len: 0-1024 | Browser user agent | +| `accessed_at` | DATE | NOT NULL, default: NOW | Access timestamp | +| `projectId` | UUID | FK → projects.id | Accessed project | +| `userId` | UUID | FK → users.id | Accessing user (if authenticated) | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Indexes:** + - `projectId` - `environment` - `userId` @@ -640,6 +675,7 @@ Audit trail for tour access. ## Element Default Settings Models These models implement a two-tier settings hierarchy for UI elements: + 1. **Global defaults** (`element_type_defaults`) - Platform-wide default settings per element type 2. **Project defaults** (`project_element_defaults`) - Project-specific overrides, snapshotted from global on project creation @@ -651,31 +687,34 @@ Global platform-wide default settings for each element type. These serve as temp **Note:** This table was renamed from `ui_elements` to `element_type_defaults` for clarity. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `element_type` | TEXT | NOT NULL, UNIQUE, len: 1-100 | Element type identifier | -| `name` | TEXT | NOT NULL, len: 1-255 | Display name | -| `sort_order` | INTEGER | NOT NULL, default: 0 | Display order in UI | -| `is_active` | VIRTUAL | getter: true | Virtual active field | -| `settings_json` | TEXT | nullable | Default settings JSON | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| --------------- | ----------- | ---------------------------- | ----------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `element_type` | TEXT | NOT NULL, UNIQUE, len: 1-100 | Element type identifier | +| `name` | TEXT | NOT NULL, len: 1-255 | Display name | +| `sort_order` | INTEGER | NOT NULL, default: 0 | Display order in UI | +| `is_active` | VIRTUAL | getter: true | Virtual active field | +| `settings_json` | TEXT | nullable | Default settings JSON | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Field Aliasing:** The model exposes `default_settings_json` as a property name, but it maps to the `settings_json` column in the database via `field: 'settings_json'`. This provides a clearer API name while maintaining backward compatibility with the database schema. **Virtual Field:** `is_active` is a VIRTUAL field that always returns `true` (computed, not stored in database). **Indexes:** + - `element_type` (unique) - `sort_order` - `deletedAt` **Associations:** + - `hasMany` project_element_defaults (as `project_defaults`) **Auto-Initialization:** The API includes an `ensureInitialized()` method that automatically seeds default records if the table is empty. This runs before any CRUD operation. **Seeded Element Types (11 types auto-seeded):** + - `navigation_next` - Forward navigation button (sort_order: 1) - `navigation_prev` - Back navigation button (sort_order: 2) - `tooltip` - Hover tooltip (sort_order: 3) @@ -694,19 +733,20 @@ Global platform-wide default settings for each element type. These serve as temp Project-specific element default settings. Created automatically when a project is created by snapshotting all global `element_type_defaults`. Can be customized per-project without affecting global defaults or other projects. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `element_type` | TEXT | NOT NULL, len: 1-100 | Element type identifier | -| `name` | TEXT | nullable, len: 0-255 | Custom display name | -| `sort_order` | INTEGER | NOT NULL, default: 0 | Display order in UI | -| `settings_json` | TEXT | nullable | Project-specific settings JSON | -| `source_element_id` | UUID | FK → element_type_defaults.id, nullable | Reference to global default (for reset/diff) | -| `snapshot_version` | INTEGER | NOT NULL, default: 1 | Version counter (incremented on reset) | -| `projectId` | UUID | FK → projects.id, NOT NULL | Parent project | -| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | +| Field | Type | Constraints | Description | +| ------------------- | ----------- | --------------------------------------- | -------------------------------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `element_type` | TEXT | NOT NULL, len: 1-100 | Element type identifier | +| `name` | TEXT | nullable, len: 0-255 | Custom display name | +| `sort_order` | INTEGER | NOT NULL, default: 0 | Display order in UI | +| `settings_json` | TEXT | nullable | Project-specific settings JSON | +| `source_element_id` | UUID | FK → element_type_defaults.id, nullable | Reference to global default (for reset/diff) | +| `snapshot_version` | INTEGER | NOT NULL, default: 1 | Version counter (incremented on reset) | +| `projectId` | UUID | FK → projects.id, NOT NULL | Parent project | +| `importHash` | STRING(255) | UNIQUE, nullable | Import deduplication | **Indexes:** + - `projectId` - `projectId, element_type` (unique composite) - `element_type` @@ -714,11 +754,13 @@ Project-specific element default settings. Created automatically when a project - `deletedAt` **Associations:** + - `belongsTo` projects (as `project`) - CASCADE on delete - `belongsTo` element_type_defaults (as `source_element`) - SET NULL on delete **Custom findAll with Project Filtering:** The API implements a custom `findAll()` method that supports filtering by project: + - Query params: `?projectId=` or `?project=` - Supports multiple values: `?projectId=uuid1|uuid2` - Can filter by project UUID or project name (case-insensitive) @@ -742,16 +784,16 @@ Polymorphic file attachments (e.g., user avatars). **Note:** This model does NOT have `freezeTableName: true`, so the table name is pluralized to `files`. -| Field | Type | Constraints | Description | -|-------|------|-------------|-------------| -| `id` | UUID | PK, default: UUIDv4 | Primary identifier | -| `belongsTo` | STRING(255) | nullable | Parent table name | -| `belongsToId` | UUID | nullable | Parent record ID | -| `belongsToColumn` | STRING(255) | nullable | Column name | -| `name` | STRING(2083) | NOT NULL | File name | -| `sizeInBytes` | INTEGER | nullable | File size | -| `privateUrl` | STRING(2083) | nullable | Private storage URL | -| `publicUrl` | STRING(2083) | NOT NULL | Public access URL | +| Field | Type | Constraints | Description | +| ----------------- | ------------ | ------------------- | ------------------- | +| `id` | UUID | PK, default: UUIDv4 | Primary identifier | +| `belongsTo` | STRING(255) | nullable | Parent table name | +| `belongsToId` | UUID | nullable | Parent record ID | +| `belongsToColumn` | STRING(255) | nullable | Column name | +| `name` | STRING(2083) | NOT NULL | File name | +| `sizeInBytes` | INTEGER | nullable | File size | +| `privateUrl` | STRING(2083) | nullable | Private storage URL | +| `publicUrl` | STRING(2083) | NOT NULL | Public access URL | **Polymorphic Usage:** Files are attached to records via `belongsTo`/`belongsToId`/`belongsToColumn` columns. @@ -765,57 +807,58 @@ Example: User avatar has `belongsTo: 'users'`, `belongsToId: `, `belong Many-to-many relationship between roles and permissions. -| Field | Type | Constraints | -|-------|------|-------------| -| `roles_permissionsId` | UUID | PK, FK → roles.id | -| `permissionId` | UUID | PK, FK → permissions.id | -| `createdAt` | TIMESTAMP WITH TIME ZONE | NOT NULL | -| `updatedAt` | TIMESTAMP WITH TIME ZONE | NOT NULL | +| Field | Type | Constraints | +| --------------------- | ------------------------ | ----------------------- | +| `roles_permissionsId` | UUID | PK, FK → roles.id | +| `permissionId` | UUID | PK, FK → permissions.id | +| `createdAt` | TIMESTAMP WITH TIME ZONE | NOT NULL | +| `updatedAt` | TIMESTAMP WITH TIME ZONE | NOT NULL | **Indexes:** + - `permissionId` ### usersCustom_permissionsPermissions Many-to-many relationship for user custom permissions. -| Field | Type | Constraints | -|-------|------|-------------| -| `users_custom_permissionsId` | UUID | FK → users.id | -| `permissionId` | UUID | FK → permissions.id | +| Field | Type | Constraints | +| ---------------------------- | ---- | ------------------- | +| `users_custom_permissionsId` | UUID | FK → users.id | +| `permissionId` | UUID | FK → permissions.id | --- ## Database Indexes Summary -| Table | Index | Fields | Type | -|-------|-------|--------|------| -| users | email | email | unique | -| users | app_roleId | app_roleId | - | -| users | deletedAt | deletedAt | - | -| projects | slug | slug | unique | -| projects | deletedAt | deletedAt | - | -| production_presentation_access | projectId | projectId | - | -| production_presentation_access | userId | userId | - | -| production_presentation_access | composite | projectId, userId | unique active rows | -| project_memberships | composite | projectId, userId | unique | -| tour_pages | composite | projectId, environment, slug | unique | -| tour_pages | sort | projectId, environment, sort_order | - | -| assets | projectId | projectId | - | -| assets | asset_type | asset_type | - | -| assets | type | type | - | -| assets | is_public | is_public | - | -| element_type_defaults | element_type | element_type | unique | -| element_type_defaults | sort_order | sort_order | - | -| element_type_defaults | deletedAt | deletedAt | - | -| project_element_defaults | projectId | projectId | - | -| project_element_defaults | composite | projectId, element_type | unique | -| project_element_defaults | element_type | element_type | - | -| project_element_defaults | source_element_id | source_element_id | - | -| project_element_defaults | deletedAt | deletedAt | - | -| publish_events | status | status | - | -| publish_events | started_at | started_at | - | -| access_logs | accessed_at | accessed_at | - | +| Table | Index | Fields | Type | +| ------------------------------ | ----------------- | ---------------------------------- | ------------------ | +| users | email | email | unique | +| users | app_roleId | app_roleId | - | +| users | deletedAt | deletedAt | - | +| projects | slug | slug | unique | +| projects | deletedAt | deletedAt | - | +| production_presentation_access | projectId | projectId | - | +| production_presentation_access | userId | userId | - | +| production_presentation_access | composite | projectId, userId | unique active rows | +| project_memberships | composite | projectId, userId | unique | +| tour_pages | composite | projectId, environment, slug | unique | +| tour_pages | sort | projectId, environment, sort_order | - | +| assets | projectId | projectId | - | +| assets | asset_type | asset_type | - | +| assets | type | type | - | +| assets | is_public | is_public | - | +| element_type_defaults | element_type | element_type | unique | +| element_type_defaults | sort_order | sort_order | - | +| element_type_defaults | deletedAt | deletedAt | - | +| project_element_defaults | projectId | projectId | - | +| project_element_defaults | composite | projectId, element_type | unique | +| project_element_defaults | element_type | element_type | - | +| project_element_defaults | source_element_id | source_element_id | - | +| project_element_defaults | deletedAt | deletedAt | - | +| publish_events | status | status | - | +| publish_events | started_at | started_at | - | +| access_logs | accessed_at | accessed_at | - | --- @@ -823,75 +866,76 @@ Many-to-many relationship for user custom permissions. All foreign key constraints are enforced at the database level via migration `20260319000001-add-foreign-key-constraints.js`. -| Child Table | Column | Parent Table | On Delete | On Update | -|-------------|--------|--------------|-----------|-----------| -| asset_variants | assetId | assets | CASCADE | CASCADE | -| assets | projectId | projects | CASCADE | CASCADE | -| tour_pages | projectId | projects | CASCADE | CASCADE | -| project_memberships | projectId | projects | CASCADE | CASCADE | -| project_memberships | userId | users | CASCADE | CASCADE | -| production_presentation_access | projectId | projects | CASCADE | CASCADE | -| production_presentation_access | userId | users | CASCADE | CASCADE | -| production_presentation_access | createdById | users | SET NULL | CASCADE | -| production_presentation_access | updatedById | users | SET NULL | CASCADE | -| presigned_url_requests | projectId | projects | CASCADE | CASCADE | -| presigned_url_requests | userId | users | CASCADE | CASCADE | -| project_audio_tracks | projectId | projects | CASCADE | CASCADE | -| project_element_defaults | projectId | projects | CASCADE | CASCADE | -| project_element_defaults | source_element_id | element_type_defaults | SET NULL | CASCADE | -| publish_events | projectId | projects | CASCADE | CASCADE | -| publish_events | userId | users | SET NULL | CASCADE | -| pwa_caches | projectId | projects | CASCADE | CASCADE | -| access_logs | projectId | projects | CASCADE | CASCADE | -| access_logs | userId | users | SET NULL | CASCADE | -| users | app_roleId | roles | SET NULL | CASCADE | +| Child Table | Column | Parent Table | On Delete | On Update | +| ------------------------------ | ----------------- | --------------------- | --------- | --------- | +| asset_variants | assetId | assets | CASCADE | CASCADE | +| assets | projectId | projects | CASCADE | CASCADE | +| tour_pages | projectId | projects | CASCADE | CASCADE | +| project_memberships | projectId | projects | CASCADE | CASCADE | +| project_memberships | userId | users | CASCADE | CASCADE | +| production_presentation_access | projectId | projects | CASCADE | CASCADE | +| production_presentation_access | userId | users | CASCADE | CASCADE | +| production_presentation_access | createdById | users | SET NULL | CASCADE | +| production_presentation_access | updatedById | users | SET NULL | CASCADE | +| presigned_url_requests | projectId | projects | CASCADE | CASCADE | +| presigned_url_requests | userId | users | CASCADE | CASCADE | +| project_audio_tracks | projectId | projects | CASCADE | CASCADE | +| project_element_defaults | projectId | projects | CASCADE | CASCADE | +| project_element_defaults | source_element_id | element_type_defaults | SET NULL | CASCADE | +| publish_events | projectId | projects | CASCADE | CASCADE | +| publish_events | userId | users | SET NULL | CASCADE | +| pwa_caches | projectId | projects | CASCADE | CASCADE | +| access_logs | projectId | projects | CASCADE | CASCADE | +| access_logs | userId | users | SET NULL | CASCADE | +| users | app_roleId | roles | SET NULL | CASCADE | --- ## Migration History -| Migration | Description | -|-----------|-------------| -| `20260319000001-add-foreign-key-constraints.js` | Adds all FK constraints to enforce referential integrity | -| `20260319000002-remove-redundant-deletion-columns.js` | Removes deprecated `is_deleted` and `deleted_at_time` columns from assets and projects | -| `20260326000001-rename-ui-elements-to-element-type-defaults.js` | Renames `ui_elements` table to `element_type_defaults` for clarity | -| `20260326000002-convert-element-type-enum-to-text.js` | Converts element type from ENUM to TEXT for flexibility | -| `20260326000003-create-project-element-defaults.js` | Creates `project_element_defaults` table for project-specific element settings | -| `20260326000004-backfill-project-element-defaults.js` | Backfills `project_element_defaults` for existing projects by snapshotting global defaults | -| `20260326000005-fix-project-audio-tracks-environment.js` | Fixes `project_audio_tracks.environment` to NOT NULL with default 'dev' | -| `20260326000006-copy-dev-to-stage.js` | Copies existing dev content to stage environment for all projects (initializes dev→stage workflow) | -| `20260326043002-enforce-environment-not-null.js` | Enforces NOT NULL constraint on environment columns in tour_pages and transitions | -| `20260326050442-remove-project-phase-column.js` | Removes redundant `phase` column from projects table (environment is on tour_pages) | -| `20260326054410-remove-entry-page-slug-column.js` | Removes `entry_page_slug` from projects (entry page is first by sort_order) | -| `20260326060000-convert-targetpageid-to-slug.js` | Converts `targetPageId` to `targetPageSlug` in ui_schema_json for environment-safe navigation | -| `20260326060001-drop-page-elements-table.js` | Drops unused `page_elements` table (data stored in ui_schema_json) | -| `20260326060002-drop-page-links-table.js` | Drops unused `page_links` table (navigation stored in ui_schema_json) | -| `20260326060003-drop-transitions-table.js` | Drops unused `transitions` table (transitionVideoUrl stored in ui_schema_json) | -| `20260326171017-add-missing-element-type-defaults.js` | Adds missing element types (spot, logo, popup) to element_type_defaults and backfills project_element_defaults | -| `20260327000001-sync-all-element-type-defaults.js` | Syncs all 11 element types with correct sort_order and backfills missing project_element_defaults for all projects | -| `20260331024423-remove-unused-theme-columns-from-projects.js` | Removes unused `theme_config_json`, `custom_css_json`, `cdn_base_url` columns from projects table | -| `20260331054340-remove-duplicate-element-type-defaults.js` | Removes duplicate element_type_defaults records created during earlier migrations | -| `20260331063424-cleanup-invalid-element-type-defaults.js` | Cleans up invalid element_type_defaults entries and ensures data integrity | -| `20260403000001-add-background-video-settings.js` | Adds background video playback settings to tour_pages (autoplay, loop, muted, start_time, end_time) | -| `20260409000001-add-design-dimensions-to-projects.js` | Adds design_width and design_height columns to projects table for canvas scaling | -| `20260409111309-add-design-dimensions-to-tour-pages.js` | Adds design_width and design_height columns to tour_pages table for presentation isolation | -| `20260422000001-add-background-video-play-once.js` | Adds background_video_play_once column to tour_pages for session-scoped single playback | -| `20260605000001-add-background-audio-settings.js` | Adds background audio playback settings to tour_pages (autoplay, loop, start_time, end_time) | -| `20260613000001-add-background-embed-url-to-tour-pages.js` | Adds background_embed_url to tour_pages for 360/embed page backgrounds | -| `20260626000001-add-private-production-presentation-access.js` | Adds project production visibility and customer access grants for private production presentations | -| `20260626000002-grant-account-manager-create-users.js` | Grants `CREATE_USERS` to Account Manager for customer viewer creation | +| Migration | Description | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `20260319000001-add-foreign-key-constraints.js` | Adds all FK constraints to enforce referential integrity | +| `20260319000002-remove-redundant-deletion-columns.js` | Removes deprecated `is_deleted` and `deleted_at_time` columns from assets and projects | +| `20260326000001-rename-ui-elements-to-element-type-defaults.js` | Renames `ui_elements` table to `element_type_defaults` for clarity | +| `20260326000002-convert-element-type-enum-to-text.js` | Converts element type from ENUM to TEXT for flexibility | +| `20260326000003-create-project-element-defaults.js` | Creates `project_element_defaults` table for project-specific element settings | +| `20260326000004-backfill-project-element-defaults.js` | Backfills `project_element_defaults` for existing projects by snapshotting global defaults | +| `20260326000005-fix-project-audio-tracks-environment.js` | Fixes `project_audio_tracks.environment` to NOT NULL with default 'dev' | +| `20260326000006-copy-dev-to-stage.js` | Copies existing dev content to stage environment for all projects (initializes dev→stage workflow) | +| `20260326043002-enforce-environment-not-null.js` | Enforces NOT NULL constraint on environment columns in tour_pages and transitions | +| `20260326050442-remove-project-phase-column.js` | Removes redundant `phase` column from projects table (environment is on tour_pages) | +| `20260326054410-remove-entry-page-slug-column.js` | Removes `entry_page_slug` from projects (entry page is first by sort_order) | +| `20260326060000-convert-targetpageid-to-slug.js` | Converts `targetPageId` to `targetPageSlug` in ui_schema_json for environment-safe navigation | +| `20260326060001-drop-page-elements-table.js` | Drops unused `page_elements` table (data stored in ui_schema_json) | +| `20260326060002-drop-page-links-table.js` | Drops unused `page_links` table (navigation stored in ui_schema_json) | +| `20260326060003-drop-transitions-table.js` | Drops unused `transitions` table (transitionVideoUrl stored in ui_schema_json) | +| `20260326171017-add-missing-element-type-defaults.js` | Adds missing element types (spot, logo, popup) to element_type_defaults and backfills project_element_defaults | +| `20260327000001-sync-all-element-type-defaults.js` | Syncs all 11 element types with correct sort_order and backfills missing project_element_defaults for all projects | +| `20260331024423-remove-unused-theme-columns-from-projects.js` | Removes unused `theme_config_json`, `custom_css_json`, `cdn_base_url` columns from projects table | +| `20260331054340-remove-duplicate-element-type-defaults.js` | Removes duplicate element_type_defaults records created during earlier migrations | +| `20260331063424-cleanup-invalid-element-type-defaults.js` | Cleans up invalid element_type_defaults entries and ensures data integrity | +| `20260403000001-add-background-video-settings.js` | Adds background video playback settings to tour_pages (autoplay, loop, muted, start_time, end_time) | +| `20260409000001-add-design-dimensions-to-projects.js` | Adds design_width and design_height columns to projects table for canvas scaling | +| `20260409111309-add-design-dimensions-to-tour-pages.js` | Adds design_width and design_height columns to tour_pages table for presentation isolation | +| `20260422000001-add-background-video-play-once.js` | Adds background_video_play_once column to tour_pages for session-scoped single playback | +| `20260605000001-add-background-audio-settings.js` | Adds background audio playback settings to tour_pages (autoplay, loop, start_time, end_time) | +| `20260613000001-add-background-embed-url-to-tour-pages.js` | Adds background_embed_url to tour_pages for 360/embed page backgrounds | +| `20260626000001-add-private-production-presentation-access.js` | Adds project production visibility and customer access grants for private production presentations | +| `20260626000002-grant-account-manager-create-users.js` | Grants `CREATE_USERS` to Account Manager for customer viewer creation | --- ## Seeders -| Seeder | Description | -|--------|-------------| -| `20200430130759-admin-user.js` | Creates initial admin and test users | -| `20200430130760-user-roles.js` | Creates roles, permissions, and role-permission assignments | +| Seeder | Description | +| ------------------------------- | ----------------------------------------------------------- | +| `20200430130759-admin-user.js` | Creates initial admin and test users | +| `20200430130760-user-roles.js` | Creates roles, permissions, and role-permission assignments | | `20231127130745-sample-data.js` | Creates sample projects, pages, assets, and other demo data | **Initial Users:** + - Admin: `admin@flatlogic.com` (admin password from config) - John Doe: `john@doe.com` (user password from config) - Client: `client@hello.com` (user password from config) @@ -903,6 +947,7 @@ All foreign key constraints are enforced at the database level via migration `20 All entity DB APIs extend `GenericDBApi` which provides: ### Configurable Properties + - `MODEL` - Sequelize model reference (required, must be defined in subclass) - `TABLE_NAME` - Derived from MODEL.getTableName() - `SEARCHABLE_FIELDS` - Text search fields (ILIKE) @@ -917,6 +962,7 @@ All entity DB APIs extend `GenericDBApi` which provides: - `getFieldMapping(data)` - Transform input data before save (default: returns data unchanged) ### Standard Methods + - `create(data, options)` - Create record with associations - `bulkImport(data, options)` - Bulk create records - `update({ id, data, currentUser, transaction, runtimeContext })` - Update record @@ -950,12 +996,15 @@ The platform uses an environment-based content model for publishing workflow: ``` ### Tables with Environment Column + - `tour_pages` - Pages with `environment` column - `project_audio_tracks` - Audio tracks with `environment` column - `project_transition_settings` - CSS transition settings with `environment` column ### Source Key Tracking + When content is copied between environments, the `source_key` field stores the ID of the source record. This enables: + - Tracking content lineage - Identifying which stage records came from dev - Rolling back changes if needed @@ -964,11 +1013,11 @@ When content is copied between environments, the `source_key` field stores the I Access to content is controlled by environment with strict isolation: -| Environment | Authentication | Access | Use Case | -|-------------|----------------|--------|----------| -| **dev** | Required (JWT) | Admin/Constructor only | Editing in constructor | -| **stage** | Required (JWT) | Authenticated users | Review workspace before publish | -| **production** | Public (no auth) | Anyone | Published public tours | +| Environment | Authentication | Access | Use Case | +| -------------- | ---------------- | ---------------------- | ------------------------------- | +| **dev** | Required (JWT) | Admin/Constructor only | Editing in constructor | +| **stage** | Required (JWT) | Authenticated users | Review workspace before publish | +| **production** | Public (no auth) | Anyone | Published public tours | **Security Layers:** @@ -989,24 +1038,25 @@ Access to content is controlled by environment with strict isolation: ## Data Types Reference -| Sequelize Type | PostgreSQL Type | Usage | -|----------------|-----------------|-------| -| UUID | uuid | Primary keys, foreign keys | -| TEXT | text | Long strings (unlimited) | -| STRING(n) | varchar(n) | Limited strings | -| INTEGER | integer | Whole numbers | -| DECIMAL | numeric | Precise decimals | -| BOOLEAN | boolean | True/false | -| DATE | timestamp with time zone | Dates and times | -| JSON | jsonb | Structured data | -| ENUM | enum type | Fixed value sets | -| VIRTUAL | (none) | Computed fields | +| Sequelize Type | PostgreSQL Type | Usage | +| -------------- | ------------------------ | -------------------------- | +| UUID | uuid | Primary keys, foreign keys | +| TEXT | text | Long strings (unlimited) | +| STRING(n) | varchar(n) | Limited strings | +| INTEGER | integer | Whole numbers | +| DECIMAL | numeric | Precise decimals | +| BOOLEAN | boolean | True/false | +| DATE | timestamp with time zone | Dates and times | +| JSON | jsonb | Structured data | +| ENUM | enum type | Fixed value sets | +| VIRTUAL | (none) | Computed fields | --- ## Common Model Options Most models share these Sequelize options: + ```javascript { timestamps: true, // Adds createdAt, updatedAt @@ -1018,6 +1068,7 @@ Most models share these Sequelize options: **Exception:** The `file` model does not set `freezeTableName: true`, so its table name is `files` (pluralized by Sequelize default). All records include audit fields: + - `createdAt` - Creation timestamp - `updatedAt` - Last modification timestamp - `deletedAt` - Soft deletion timestamp (null if not deleted) diff --git a/backend/docs/modules/auth.md b/backend/docs/modules/auth.md index b16fad2..0f3d917 100644 --- a/backend/docs/modules/auth.md +++ b/backend/docs/modules/auth.md @@ -5,14 +5,15 @@ The Auth module provides comprehensive authentication and authorization for the application. It supports local email/password authentication, OAuth 2.0 (Google, Microsoft), JWT-based session management, email verification, and password reset flows. **Files:** -| File | Purpose | -|------|---------| -| `src/auth/auth.ts` | Passport.js strategy configurations (JWT, Google, Microsoft) | -| `src/services/auth.ts` | Auth business logic (signin, password reset/update, email verification) | -| `src/routes/auth.ts` | REST API endpoints for authentication | -| `src/helpers.ts` | JWT signing utility (`jwtSign`) | -| `src/db/api/users.js` | User database operations (tokens, password updates) | -| `src/middlewares/rateLimiter.js` | Auth-specific rate limiters | + +| File | Purpose | +| -------------------------------- | ----------------------------------------------------------------------- | +| `src/auth/auth.ts` | Passport.js strategy configurations (JWT, Google, Microsoft) | +| `src/services/auth.ts` | Auth business logic (signin, password reset/update, email verification) | +| `src/routes/auth.ts` | REST API endpoints for authentication | +| `src/helpers.ts` | JWT signing utility (`jwtSign`) | +| `src/db/api/users.js` | User database operations (tokens, password updates) | +| `src/middlewares/rateLimiter.js` | Auth-specific rate limiters | --- @@ -103,26 +104,31 @@ The Auth module provides comprehensive authentication and authorization for the Used for API authentication on all protected routes. **Configuration (auth/auth.ts):** + ```javascript passport.use( - new JWTstrategy({ - passReqToCallback: true, - secretOrKey: config.secret_key, - jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(), - }, async (req, token, done) => { - const user = await UsersDBApi.findBy({ email: token.user.email }); + new JWTstrategy( + { + passReqToCallback: true, + secretOrKey: config.secret_key, + jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(), + }, + async (req, token, done) => { + const user = await UsersDBApi.findBy({ email: token.user.email }); - if (user && user.disabled) { - return done(new Error(`User '${user.email}' is disabled`)); - } + if (user && user.disabled) { + return done(new Error(`User '${user.email}' is disabled`)); + } - req.currentUser = user; - return done(null, user); - }) + req.currentUser = user; + return done(null, user); + }, + ), ); ``` **Token Structure:** + ```javascript { user: { @@ -135,6 +141,7 @@ passport.use( ``` **Usage:** + ```javascript // Protect route with JWT router.get('/me', passport.authenticate('jwt', { session: false }), handler); @@ -146,23 +153,28 @@ const currentUser = req.currentUser; ### 2. Google OAuth Strategy **Configuration (auth/auth.ts):** + ```javascript passport.use( - new GoogleStrategy({ - clientID: config.google.clientId, - clientSecret: config.google.clientSecret, - callbackURL: config.apiUrl + '/auth/signin/google/callback', - passReqToCallback: true, - }, (request, accessToken, refreshToken, profile, done) => { - socialStrategy(profile.email, profile, providers.GOOGLE, done); - }) + new GoogleStrategy( + { + clientID: config.google.clientId, + clientSecret: config.google.clientSecret, + callbackURL: config.apiUrl + '/auth/signin/google/callback', + passReqToCallback: true, + }, + (request, accessToken, refreshToken, profile, done) => { + socialStrategy(profile.email, profile, providers.GOOGLE, done); + }, + ), ); ``` **Environment Variables:** -| Variable | Description | -|----------|-------------| -| `GOOGLE_CLIENT_ID` | Google OAuth client ID | + +| Variable | Description | +| ---------------------- | -------------------------- | +| `GOOGLE_CLIENT_ID` | Google OAuth client ID | | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | **OAuth Scopes:** `profile`, `email` @@ -170,24 +182,29 @@ passport.use( ### 3. Microsoft OAuth Strategy **Configuration (auth/auth.ts):** + ```javascript passport.use( - new MicrosoftStrategy({ - clientID: config.microsoft.clientId, - clientSecret: config.microsoft.clientSecret, - callbackURL: config.apiUrl + '/auth/signin/microsoft/callback', - passReqToCallback: true, - }, (request, accessToken, refreshToken, profile, done) => { - const email = profile._json.mail || profile._json.userPrincipalName; - socialStrategy(email, profile, providers.MICROSOFT, done); - }) + new MicrosoftStrategy( + { + clientID: config.microsoft.clientId, + clientSecret: config.microsoft.clientSecret, + callbackURL: config.apiUrl + '/auth/signin/microsoft/callback', + passReqToCallback: true, + }, + (request, accessToken, refreshToken, profile, done) => { + const email = profile._json.mail || profile._json.userPrincipalName; + socialStrategy(email, profile, providers.MICROSOFT, done); + }, + ), ); ``` **Environment Variables:** -| Variable | Description | -|----------|-------------| -| `MS_CLIENT_ID` | Microsoft OAuth client ID | + +| Variable | Description | +| ------------------ | ----------------------------- | +| `MS_CLIENT_ID` | Microsoft OAuth client ID | | `MS_CLIENT_SECRET` | Microsoft OAuth client secret | **OAuth Scopes:** `https://graph.microsoft.com/user.read`, `openid` @@ -222,13 +239,13 @@ Core authentication business logic. ```typescript class Auth { - static async signin(email, password) - static async verifyEmail(token, options) - static async passwordUpdate(currentPassword, newPassword, options) - static async passwordReset(token, password, options) - static async sendEmailAddressVerificationEmail(email, host) - static async sendPasswordResetEmail(email, type, host) - static async updateProfile(data, currentUser) + static async signin(email, password); + static async verifyEmail(token, options); + static async passwordUpdate(currentPassword, newPassword, options); + static async passwordReset(token, password, options); + static async sendEmailAddressVerificationEmail(email, host); + static async sendPasswordResetEmail(email, type, host); + static async updateProfile(data, currentUser); } ``` @@ -237,6 +254,7 @@ class Auth { Registers a new user or updates password for existing unverified user. **Flow:** + ``` 1. Check if user exists by email ├── User exists with authenticationUid → Error: emailAlreadyInUse @@ -257,6 +275,7 @@ Registers a new user or updates password for existing unverified user. Authenticates user with email and password. **Flow:** + ``` 1. Find user by email └── Not found → Error: userNotFound @@ -278,6 +297,7 @@ Authenticates user with email and password. Verifies user email address using token. **Flow:** + ``` 1. Find user by email verification token └── Not found or expired → Error: invalidToken @@ -290,6 +310,7 @@ Verifies user email address using token. Updates password for authenticated user. **Flow:** + ``` 1. Verify currentUser exists └── Not authenticated → ForbiddenError @@ -305,6 +326,7 @@ Updates password for authenticated user. Resets password using reset token. **Flow:** + ``` 1. Find user by password reset token └── Not found or expired → Error: invalidToken @@ -320,27 +342,28 @@ REST API endpoints for authentication. #### Endpoints Overview -| Method | Path | Auth | Rate Limit | Description | -|--------|------|------|------------|-------------| -| POST | `/signin/local` | No | authLimiter | Login with email/password | -| GET | `/me` | JWT | - | Get current user | -| PUT | `/password-reset` | No | - | Reset password with token | -| PUT | `/password-update` | JWT | - | Change password | -| PUT | `/profile` | JWT | - | Update user profile | -| PUT | `/verify-email` | No | - | Verify email with token | -| POST | `/send-email-address-verification-email` | JWT | - | Resend verification email | -| POST | `/send-password-reset-email` | No | passwordResetLimiter | Send password reset email | -| GET | `/email-configured` | No | - | Check if email is configured | -| GET | `/signin/google` | No | - | Initiate Google OAuth | -| GET | `/signin/google/callback` | No | - | Google OAuth callback | -| GET | `/signin/microsoft` | No | - | Initiate Microsoft OAuth | -| GET | `/signin/microsoft/callback` | No | - | Microsoft OAuth callback | +| Method | Path | Auth | Rate Limit | Description | +| ------ | ---------------------------------------- | ---- | -------------------- | ---------------------------- | +| POST | `/signin/local` | No | authLimiter | Login with email/password | +| GET | `/me` | JWT | - | Get current user | +| PUT | `/password-reset` | No | - | Reset password with token | +| PUT | `/password-update` | JWT | - | Change password | +| PUT | `/profile` | JWT | - | Update user profile | +| PUT | `/verify-email` | No | - | Verify email with token | +| POST | `/send-email-address-verification-email` | JWT | - | Resend verification email | +| POST | `/send-password-reset-email` | No | passwordResetLimiter | Send password reset email | +| GET | `/email-configured` | No | - | Check if email is configured | +| GET | `/signin/google` | No | - | Initiate Google OAuth | +| GET | `/signin/google/callback` | No | - | Google OAuth callback | +| GET | `/signin/microsoft` | No | - | Initiate Microsoft OAuth | +| GET | `/signin/microsoft/callback` | No | - | Microsoft OAuth callback | #### POST /api/auth/signin/local Login with email and password. **Request:** + ```json { "email": "user@example.com", @@ -349,18 +372,20 @@ Login with email and password. ``` **Response (200):** + ```json "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." ``` **Errors:** -| Code | Message | Cause | -|------|---------|-------| -| 400 | `auth.userNotFound` | User doesn't exist | -| 400 | `auth.userDisabled` | User account is disabled | -| 400 | `auth.wrongPassword` | Invalid password | -| 400 | `auth.userNotVerified` | Email not verified | -| 429 | Too Many Requests | Rate limit exceeded | + +| Code | Message | Cause | +| ---- | ---------------------- | ------------------------ | +| 400 | `auth.userNotFound` | User doesn't exist | +| 400 | `auth.userDisabled` | User account is disabled | +| 400 | `auth.wrongPassword` | Invalid password | +| 400 | `auth.userNotVerified` | Email not verified | +| 429 | Too Many Requests | Rate limit exceeded | #### Self-Registration @@ -373,11 +398,13 @@ invitation/setup link. Get current authenticated user. **Headers:** + ``` Authorization: Bearer ``` **Response (200):** + ```json { "id": "uuid", @@ -404,6 +431,7 @@ Authorization: Bearer Reset password using token from email. **Request:** + ```json { "token": "abc123...", @@ -412,6 +440,7 @@ Reset password using token from email. ``` **Response (200):** + ```json { "success": true } ``` @@ -421,11 +450,13 @@ Reset password using token from email. Change password for authenticated user. **Headers:** + ``` Authorization: Bearer ``` **Request:** + ```json { "currentPassword": "oldPassword123", @@ -434,22 +465,25 @@ Authorization: Bearer ``` **Errors:** -| Code | Message | Cause | -|------|---------|-------| -| 400 | `auth.wrongPassword` | Current password incorrect | -| 400 | `auth.passwordUpdate.samePassword` | New password same as old | -| 403 | Forbidden | Not authenticated | + +| Code | Message | Cause | +| ---- | ---------------------------------- | -------------------------- | +| 400 | `auth.wrongPassword` | Current password incorrect | +| 400 | `auth.passwordUpdate.samePassword` | New password same as old | +| 403 | Forbidden | Not authenticated | #### PUT /api/auth/profile Update user profile. **Headers:** + ``` Authorization: Bearer ``` **Request:** + ```json { "profile": { @@ -463,18 +497,22 @@ Authorization: Bearer #### OAuth Endpoints **GET /api/auth/signin/google** + - Redirects to Google OAuth consent screen - Query param: `app` (passed as state) **GET /api/auth/signin/google/callback** + - Handles Google OAuth callback - Redirects to: `{uiUrl}/login?token={jwt}` **GET /api/auth/signin/microsoft** + - Redirects to Microsoft OAuth consent screen - Query param: `app` (passed as state) **GET /api/auth/signin/microsoft/callback** + - Handles Microsoft OAuth callback - Redirects to: `{uiUrl}/login?token={jwt}` @@ -495,11 +533,12 @@ static jwtSign(data) { ``` **Configuration:** -| Setting | Value | Description | -|---------|-------|-------------| + +| Setting | Value | Description | +| ---------- | ------------------- | ------------------------- | | Secret Key | `config.secret_key` | From `SECRET_KEY` env var | -| Expiration | `6h` | Token valid for 6 hours | -| Algorithm | `HS256` | Default HMAC SHA-256 | +| Expiration | `6h` | Token valid for 6 hours | +| Algorithm | `HS256` | Default HMAC SHA-256 | --- @@ -549,17 +588,19 @@ static async generateEmailVerificationToken(email, options) { ``` **Token Properties:** -| Property | Value | -|----------|-------| -| Length | 40 hex characters | -| Expiry | 24 hours | -| Storage | `emailVerificationToken` column | + +| Property | Value | +| -------- | ------------------------------- | +| Length | 40 hex characters | +| Expiry | 24 hours | +| Storage | `emailVerificationToken` column | #### Method: generatePasswordResetToken(email) Generates secure token for password reset. Same implementation as `generateEmailVerificationToken` but stores in: + - `passwordResetToken` - `passwordResetTokenExpiresAt` @@ -610,11 +651,11 @@ const authLimiter = createRateLimiter({ }); ``` -| Setting | Value | -|---------|-------| -| Window | 15 minutes | -| Max Requests | 10 | -| Applied To | `/signin/local` | +| Setting | Value | +| ------------ | --------------- | +| Window | 15 minutes | +| Max Requests | 10 | +| Applied To | `/signin/local` | ### Signup Limiter @@ -631,11 +672,11 @@ const passwordResetLimiter = createRateLimiter({ }); ``` -| Setting | Value | -|---------|-------| -| Window | 1 hour | -| Max Requests | 5 | -| Applied To | `/send-password-reset-email` | +| Setting | Value | +| ------------ | ---------------------------- | +| Window | 1 hour | +| Max Requests | 5 | +| Applied To | `/send-password-reset-email` | ### Rate Limit Response @@ -648,6 +689,7 @@ const passwordResetLimiter = createRateLimiter({ ``` **Headers:** + ``` X-RateLimit-Limit: 10 X-RateLimit-Remaining: 0 @@ -668,15 +710,16 @@ bcrypt: { } ``` -| Setting | Value | Security Impact | -|---------|-------|-----------------| -| Algorithm | bcrypt | Industry standard | -| Salt Rounds | 12 | ~200ms hash time | -| Salt | Auto-generated | Per-password unique | +| Setting | Value | Security Impact | +| ----------- | -------------- | ------------------- | +| Algorithm | bcrypt | Industry standard | +| Salt Rounds | 12 | ~200ms hash time | +| Salt | Auto-generated | Per-password unique | ### Password Validation Passwords are: + 1. Hashed before storage (never stored in plain text) 2. Compared using `bcrypt.compare()` (timing-attack safe) 3. Required for local authentication @@ -696,6 +739,7 @@ if (EmailSender.isConfigured) { ``` When email is NOT configured: + - Signup succeeds without verification email - Users are auto-verified on signin - Password reset emails not sent @@ -705,6 +749,7 @@ When email is NOT configured: **Email Class:** `EmailAddressVerificationEmail` **Link Format:** + ``` {host}/verify-email?token={token} ``` @@ -712,10 +757,12 @@ When email is NOT configured: ### Password Reset Email **Email Classes:** + - `PasswordResetEmail` - Standard reset - `InvitationEmail` - New user invitation **Link Format:** + ``` {host}/password-reset?token={token} ``` @@ -726,16 +773,16 @@ When email is NOT configured: ### Environment Variables -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `SECRET_KEY` | Yes | `88dbeaf8-e906-405e-9e41-c3baadeda5c6` | JWT signing secret | -| `GOOGLE_CLIENT_ID` | No | - | Google OAuth client ID | -| `GOOGLE_CLIENT_SECRET` | No | - | Google OAuth client secret | -| `MS_CLIENT_ID` | No | - | Microsoft OAuth client ID | -| `MS_CLIENT_SECRET` | No | - | Microsoft OAuth client secret | -| `ADMIN_EMAIL` | No | `admin@flatlogic.com` | Default admin email | -| `ADMIN_PASS` | No | `88dbeaf8` | Default admin password | -| `USER_PASS` | No | `c3baadeda5c6` | Default user password | +| Variable | Required | Default | Description | +| ---------------------- | -------- | -------------------------------------- | ----------------------------- | +| `SECRET_KEY` | Yes | `88dbeaf8-e906-405e-9e41-c3baadeda5c6` | JWT signing secret | +| `GOOGLE_CLIENT_ID` | No | - | Google OAuth client ID | +| `GOOGLE_CLIENT_SECRET` | No | - | Google OAuth client secret | +| `MS_CLIENT_ID` | No | - | Microsoft OAuth client ID | +| `MS_CLIENT_SECRET` | No | - | Microsoft OAuth client secret | +| `ADMIN_EMAIL` | No | `admin@flatlogic.com` | Default admin email | +| `ADMIN_PASS` | No | `88dbeaf8` | Default admin password | +| `USER_PASS` | No | `c3baadeda5c6` | Default user password | ### config.ts Settings @@ -862,18 +909,18 @@ When email is NOT configured: ## Error Codes -| Error Key | HTTP Status | Description | -|-----------|-------------|-------------| -| `auth.userNotFound` | 400 | User with email doesn't exist | -| `auth.userDisabled` | 400 | User account is disabled | -| `auth.wrongPassword` | 400 | Password doesn't match | -| `auth.userNotVerified` | 400 | Email not verified | -| `auth.emailAlreadyInUse` | 400 | Email already registered | -| `auth.passwordUpdate.samePassword` | 400 | New password same as current | -| `auth.passwordReset.error` | 400 | Token generation failed | -| `auth.passwordReset.invalidToken` | 400 | Invalid or expired reset token | -| `auth.emailAddressVerificationEmail.error` | 400 | Verification email failed | -| `auth.emailAddressVerificationEmail.invalidToken` | 400 | Invalid verification token | +| Error Key | HTTP Status | Description | +| ------------------------------------------------- | ----------- | ------------------------------ | +| `auth.userNotFound` | 400 | User with email doesn't exist | +| `auth.userDisabled` | 400 | User account is disabled | +| `auth.wrongPassword` | 400 | Password doesn't match | +| `auth.userNotVerified` | 400 | Email not verified | +| `auth.emailAlreadyInUse` | 400 | Email already registered | +| `auth.passwordUpdate.samePassword` | 400 | New password same as current | +| `auth.passwordReset.error` | 400 | Token generation failed | +| `auth.passwordReset.invalidToken` | 400 | Invalid or expired reset token | +| `auth.emailAddressVerificationEmail.error` | 400 | Verification email failed | +| `auth.emailAddressVerificationEmail.invalidToken` | 400 | Invalid verification token | --- @@ -913,18 +960,18 @@ Use the authenticated Users API/UI to create invited users. ## Dependencies -| Package | Version | Purpose | -|---------|---------|---------| -| `passport` | ^0.6.0 | Authentication middleware | -| `passport-jwt` | ^4.0.0 | JWT strategy for Passport | -| `passport-google-oauth2` | ^0.2.0 | Google OAuth strategy | -| `passport-microsoft` | ^2.0.0 | Microsoft OAuth strategy | -| `@types/passport-jwt` | ^4.0.1 | Maintained TypeScript definitions for JWT Passport strategy | -| `@types/passport-google-oauth2` | ^0.1.10 | Maintained TypeScript definitions for Google OAuth Passport strategy | -| `@types/passport-microsoft` | ^2.1.1 | Maintained TypeScript definitions for Microsoft Passport strategy | -| `jsonwebtoken` | ^9.0.0 | JWT sign/verify | -| `bcrypt` | ^5.1.0 | Password hashing | -| `crypto` | built-in | Token generation | +| Package | Version | Purpose | +| ------------------------------- | -------- | -------------------------------------------------------------------- | +| `passport` | ^0.6.0 | Authentication middleware | +| `passport-jwt` | ^4.0.0 | JWT strategy for Passport | +| `passport-google-oauth2` | ^0.2.0 | Google OAuth strategy | +| `passport-microsoft` | ^2.0.0 | Microsoft OAuth strategy | +| `@types/passport-jwt` | ^4.0.1 | Maintained TypeScript definitions for JWT Passport strategy | +| `@types/passport-google-oauth2` | ^0.1.10 | Maintained TypeScript definitions for Google OAuth Passport strategy | +| `@types/passport-microsoft` | ^2.1.1 | Maintained TypeScript definitions for Microsoft Passport strategy | +| `jsonwebtoken` | ^9.0.0 | JWT sign/verify | +| `bcrypt` | ^5.1.0 | Password hashing | +| `crypto` | built-in | Token generation | --- diff --git a/backend/docs/modules/core.md b/backend/docs/modules/core.md index 2126472..270e887 100644 --- a/backend/docs/modules/core.md +++ b/backend/docs/modules/core.md @@ -4,13 +4,13 @@ The Core module provides the foundational components of the backend application: ## Overview -| File | Purpose | Lines | -|------|---------|-------| -| `src/index.ts` | Application entry point, Express setup, middleware, route mounting | varies | -| `src/config.ts` | Environment configuration and settings | varies | -| `src/helpers.js` | Utility functions (wrapAsync, JWT, validation) | 32 | -| `src/types/` | Shared strict TypeScript contracts for migrated backend code | varies | -| `src/load-env.ts` | Central backend `.env` bootstrap for app and DB entrypoints | varies | +| File | Purpose | Lines | +| ----------------- | ------------------------------------------------------------------ | ------ | +| `src/index.ts` | Application entry point, Express setup, middleware, route mounting | varies | +| `src/config.ts` | Environment configuration and settings | varies | +| `src/helpers.js` | Utility functions (wrapAsync, JWT, validation) | 32 | +| `src/types/` | Shared strict TypeScript contracts for migrated backend code | varies | +| `src/load-env.ts` | Central backend `.env` bootstrap for app and DB entrypoints | varies | --- @@ -29,11 +29,18 @@ import express from 'express'; import helmet from 'helmet'; import * as swaggerUI from 'swagger-ui-express'; -import { authenticateJwt, authenticateJwtWithCallback } from './auth/passport-middleware.ts'; +import { + authenticateJwt, + authenticateJwtWithCallback, +} from './auth/passport-middleware.ts'; import config from './config.ts'; import { wrapAsync } from './helpers.ts'; import { runtimeContextMiddleware } from './middlewares/runtime-context.ts'; -import { downloadLimiter, searchLimiter, uploadLimiter } from './middlewares/rateLimiter.ts'; +import { + downloadLimiter, + searchLimiter, + uploadLimiter, +} from './middlewares/rateLimiter.ts'; import { createOpenApiDocument } from './openapi/document.ts'; import { exitAfterLogging, @@ -159,21 +166,25 @@ app.use('/api/file', fileRoutes) // File download/presign (partial) #### Protected Routes (JWT Required) ```javascript -app.use('/api/users', jwtAuth, usersRoutes) -app.use('/api/roles', jwtAuth, rolesRoutes) -app.use('/api/permissions', jwtAuth, permissionsRoutes) -app.use('/api/project_memberships', jwtAuth, project_membershipsRoutes) -app.use('/api/assets', jwtAuth, assetsRoutes) -app.use('/api/asset_variants', jwtAuth, asset_variantsRoutes) -app.use('/api/presigned_url_requests', jwtAuth, presigned_url_requestsRoutes) -app.use('/api/publish_events', jwtAuth, publish_eventsRoutes) -app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes) -app.use('/api/access_logs', jwtAuth, access_logsRoutes) -app.use('/api/element-type-defaults', jwtAuth, element_type_defaultsRoutes) -app.use('/api/ui-elements', jwtAuth, element_type_defaultsRoutes) // Alias -app.use('/api/project-element-defaults', jwtAuth, project_element_defaultsRoutes) -app.use('/api/publish', jwtAuth, publishRoutes) -app.use('/api/search', jwtAuth, searchLimiter, searchRoutes) +app.use('/api/users', jwtAuth, usersRoutes); +app.use('/api/roles', jwtAuth, rolesRoutes); +app.use('/api/permissions', jwtAuth, permissionsRoutes); +app.use('/api/project_memberships', jwtAuth, project_membershipsRoutes); +app.use('/api/assets', jwtAuth, assetsRoutes); +app.use('/api/asset_variants', jwtAuth, asset_variantsRoutes); +app.use('/api/presigned_url_requests', jwtAuth, presigned_url_requestsRoutes); +app.use('/api/publish_events', jwtAuth, publish_eventsRoutes); +app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes); +app.use('/api/access_logs', jwtAuth, access_logsRoutes); +app.use('/api/element-type-defaults', jwtAuth, element_type_defaultsRoutes); +app.use('/api/ui-elements', jwtAuth, element_type_defaultsRoutes); // Alias +app.use( + '/api/project-element-defaults', + jwtAuth, + project_element_defaultsRoutes, +); +app.use('/api/publish', jwtAuth, publishRoutes); +app.use('/api/search', jwtAuth, searchLimiter, searchRoutes); ``` #### Runtime Public Routes (Production Content Without Auth) @@ -182,9 +193,13 @@ app.use('/api/search', jwtAuth, searchLimiter, searchRoutes) // These routes use requireRuntimeReadOrAuth middleware // Allows unauthenticated GET requests in production environment -mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes) -mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes) -mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', project_audio_tracksRoutes) +mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes); +mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes); +mountRuntimeEntityRoute( + '/api/project_audio_tracks', + 'project_audio_tracks', + project_audio_tracksRoutes, +); ``` ### Key Functions @@ -204,11 +219,11 @@ const requireRuntimeReadOrAuth = (req, res, next) => { if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) { req.isRuntimePublicRequest = true; - return next(); // Allow without JWT + return next(); // Allow without JWT } req.isRuntimePublicRequest = false; - return jwtAuth(req, res, next); // Require JWT + return jwtAuth(req, res, next); // Require JWT }; ``` @@ -220,9 +235,9 @@ Helper to mount routes with runtime public access middleware stack: const mountRuntimeEntityRoute = (path, entityName, router) => { app.use( path, - requireRuntimeReadOrAuth, // JWT or public production - blockNonPublicRuntimeListEndpoints, // Block non-list for public - sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields + requireRuntimeReadOrAuth, // JWT or public production + blockNonPublicRuntimeListEndpoints, // Block non-list for public + sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields router, ); }; @@ -309,10 +324,7 @@ breaker rejections use status `503` instead of being collapsed to `500`. const PORT = config.server.port; const server = app.listen(PORT, () => { - logger.info( - { port: PORT, env: config.server.env }, - 'Server started', - ); + logger.info({ port: PORT, env: config.server.env }, 'Server started'); }); server.on('error', (err) => { @@ -454,33 +466,32 @@ const config = { port: serverPort, swaggerServerUrl, }, - }; ``` ### Environment Variables Reference -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `NODE_ENV` | string | `development` | Environment: `development`, `production`, `dev_stage`, `test` | -| `PORT` | number | `8080` | Server port | -| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) | -| `ADMIN_EMAIL` | string | `admin@flatlogic.com` | Admin user email | -| `ADMIN_PASS` | string | Generated | Admin user password | -| `USER_PASS` | string | Generated | Default user password | -| `AWS_S3_BUCKET` | string | - | S3 bucket name | -| `AWS_S3_REGION` | string | `us-east-1` | S3 region | -| `AWS_ACCESS_KEY_ID` | string | - | AWS access key | -| `AWS_SECRET_ACCESS_KEY` | string | - | AWS secret key | -| `AWS_S3_PREFIX` | string | Hash | S3 key prefix | -| `GOOGLE_CLIENT_ID` | string | - | Google OAuth client ID | -| `GOOGLE_CLIENT_SECRET` | string | - | Google OAuth client secret | -| `MS_CLIENT_ID` | string | - | Microsoft OAuth client ID | -| `MS_CLIENT_SECRET` | string | - | Microsoft OAuth client secret | -| `EMAIL_USER` | string | - | SMTP username | -| `EMAIL_PASS` | string | - | SMTP password | -| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS validation | -| `LOG_LEVEL` | string | `info` | Pino log level | +| Variable | Type | Default | Description | +| ------------------------------- | ------ | --------------------- | ------------------------------------------------------------- | +| `NODE_ENV` | string | `development` | Environment: `development`, `production`, `dev_stage`, `test` | +| `PORT` | number | `8080` | Server port | +| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) | +| `ADMIN_EMAIL` | string | `admin@flatlogic.com` | Admin user email | +| `ADMIN_PASS` | string | Generated | Admin user password | +| `USER_PASS` | string | Generated | Default user password | +| `AWS_S3_BUCKET` | string | - | S3 bucket name | +| `AWS_S3_REGION` | string | `us-east-1` | S3 region | +| `AWS_ACCESS_KEY_ID` | string | - | AWS access key | +| `AWS_SECRET_ACCESS_KEY` | string | - | AWS secret key | +| `AWS_S3_PREFIX` | string | Hash | S3 key prefix | +| `GOOGLE_CLIENT_ID` | string | - | Google OAuth client ID | +| `GOOGLE_CLIENT_SECRET` | string | - | Google OAuth client secret | +| `MS_CLIENT_ID` | string | - | Microsoft OAuth client ID | +| `MS_CLIENT_SECRET` | string | - | Microsoft OAuth client secret | +| `EMAIL_USER` | string | - | SMTP username | +| `EMAIL_PASS` | string | - | SMTP password | +| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS validation | +| `LOG_LEVEL` | string | `info` | Pino log level | ### Environment Validation @@ -520,7 +531,7 @@ function validateEnv() { logger.error({ errors: messages }, 'Environment validation failed'); if (process.env.NODE_ENV === 'production') { - process.exit(1); // Fatal in production + process.exit(1); // Fatal in production } else { logger.warn('Continuing with default values in non-production mode'); } @@ -617,10 +628,13 @@ router.get('/', async (req, res, next) => { // With wrapAsync - cleaner code const wrapAsync = require('../helpers').wrapAsync; -router.get('/', wrapAsync(async (req, res) => { - const data = await Service.findAll(); - res.json(data); -})); +router.get( + '/', + wrapAsync(async (req, res) => { + const data = await Service.findAll(); + res.json(data); + }), +); ``` #### commonErrorHandler @@ -634,10 +648,10 @@ router.use('/', commonErrorHandler); // Errors with code/status are returned as-is const error = new Error('Not found'); error.code = 404; -throw error; // → 404 "Not found" +throw error; // → 404 "Not found" // Unknown errors return 500 -throw new Error('Database connection failed'); // → 500 "Internal server error" +throw new Error('Database connection failed'); // → 500 "Internal server error" ``` #### jwtSign @@ -663,10 +677,10 @@ const token = jwtSign({ const { isUuidV4 } = require('./helpers'); // Validate UUID format -isUuidV4('550e8400-e29b-41d4-a716-446655440000'); // true -isUuidV4('550e8400-e29b-31d4-a716-446655440000'); // false (version 3) -isUuidV4('not-a-uuid'); // false -isUuidV4(''); // false +isUuidV4('550e8400-e29b-41d4-a716-446655440000'); // true +isUuidV4('550e8400-e29b-31d4-a716-446655440000'); // false (version 3) +isUuidV4('not-a-uuid'); // false +isUuidV4(''); // false ``` --- @@ -767,12 +781,12 @@ External Dependencies: ## Server Modes -| NODE_ENV | Port | Database | Swagger | Description | -|----------|------|----------|---------|-------------| -| `development` | 8080 | Local | localhost:8080 | Legacy local development | -| `dev_stage` | 3000 | Remote | localhost:3000 | Staging preview | -| `production` | 8080 | Remote | Disabled | Production deployment | -| `test` | 8080 | Test DB | Disabled | Automated testing | +| NODE_ENV | Port | Database | Swagger | Description | +| ------------- | ---- | -------- | -------------- | ------------------------ | +| `development` | 8080 | Local | localhost:8080 | Legacy local development | +| `dev_stage` | 3000 | Remote | localhost:3000 | Staging preview | +| `production` | 8080 | Remote | Disabled | Production deployment | +| `test` | 8080 | Test DB | Disabled | Automated testing | **Standard VM note:** the VM PM2 setup runs the backend with `NODE_ENV=dev_stage`, so the backend listens on port `3000`. The frontend runs diff --git a/backend/docs/modules/db-api.md b/backend/docs/modules/db-api.md index 8173c1d..39e2050 100644 --- a/backend/docs/modules/db-api.md +++ b/backend/docs/modules/db-api.md @@ -8,30 +8,30 @@ The DB API module provides the data access layer that sits between services and **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 | - | +| 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 | - | --- @@ -111,23 +111,23 @@ The base class provides a Template Method pattern where subclasses configure beh ### 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 | +| 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 @@ -320,14 +320,14 @@ static async findAll(filter = {}, options = {}) { #### 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 | +| 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 | --- @@ -421,13 +421,25 @@ Extend `GenericDBApi` with minimal configuration. Only override static getters a ```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 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 { + static override getFieldMapping( + data: PermissionData, + ): PermissionFieldMapping { return { id: data.id || undefined, name: data.name || null, @@ -437,6 +449,7 @@ class PermissionsDBApi extends GenericDBApi { ``` **Entities using this pattern:** + - `PermissionsDBApi` - `AssetsDBApi` - `Asset_variantsDBApi` @@ -542,6 +555,7 @@ class ProjectsDBApi extends GenericDBApi { ``` **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 @@ -642,6 +656,7 @@ 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 @@ -651,13 +666,16 @@ Complex user management with: class UsersDBApi { static async create(options: UserCreateOptions): Promise { 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 }); + 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) { @@ -698,9 +716,11 @@ class UsersDBApi { } static async _generateToken(keyNames, email, options) { - const users = await db.users.findOne({ where: { email: email.toLowerCase() } }); + 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 + const tokenExpiresAt = Date.now() + 24 * 60 * 60 * 1000; // 24 hours await users.update({ [keyNames[0]]: token, @@ -719,7 +739,7 @@ class UsersDBApi { }, }); } -}; +} ``` **Example: FileDBApi** @@ -733,7 +753,11 @@ export default class FileDBApi { assert(relation.belongsToColumn); assert(relation.belongsToId); - const files = Array.isArray(rawFiles) ? rawFiles : rawFiles ? [rawFiles] : []; + const files = Array.isArray(rawFiles) + ? rawFiles + : rawFiles + ? [rawFiles] + : []; await this._removeLegacyFiles(relation, files, options); await this._addFiles(relation, files, options); @@ -743,14 +767,17 @@ export default class FileDBApi { 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 }); + 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 }, + ); } } @@ -769,7 +796,7 @@ export default class FileDBApi { await file.destroy({ transaction: options.transaction }); } } -}; +} ``` --- @@ -875,14 +902,15 @@ module.exports = class Utils { **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 | +| 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 @@ -895,8 +923,12 @@ module.exports = class Utils { ```javascript class AssetsDBApi extends GenericDBApi { - static get MODEL() { return db.assets; } - static get TABLE_NAME() { return 'assets'; } + 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']; @@ -928,7 +960,12 @@ class AssetsDBApi extends GenericDBApi { static get RELATION_FILTERS() { return [ - { filterKey: 'project', model: db.projects, as: 'project', searchField: 'name' }, + { + filterKey: 'project', + model: db.projects, + as: 'project', + searchField: 'name', + }, ]; } @@ -958,6 +995,7 @@ class AssetsDBApi extends GenericDBApi { **Two ways to handle foreign keys:** 1. **Direct field mapping** (preferred for programmatic use): + ```javascript // In getFieldMapping() static getFieldMapping(data) { @@ -973,6 +1011,7 @@ 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() { @@ -996,28 +1035,30 @@ undefined source instance and fail before the foreign key can be saved. ```typescript class RolesDBApi extends GenericDBApi { - static override get MODEL(): unknown { return db.roles; } + 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' }, - ]; + return [{ association: 'users_app_role' }, { association: 'permissions' }]; } static override get FIND_ALL_INCLUDES(): unknown[] { - return [ - { model: db.permissions, as: 'permissions', required: false }, - ]; + return [{ model: db.permissions, as: 'permissions', required: false }]; } static get RELATION_FILTERS() { return [ - { filterKey: 'permissions', model: db.permissions, as: 'permissions_filter', searchField: 'name' }, + { + filterKey: 'permissions', + model: db.permissions, + as: 'permissions_filter', + searchField: 'name', + }, ]; } } @@ -1060,18 +1101,18 @@ class Element_type_defaultsDBApi extends GenericDBApi { ## 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 | +| 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 | --- diff --git a/backend/docs/modules/db-config.md b/backend/docs/modules/db-config.md index bad02bf..74ed221 100644 --- a/backend/docs/modules/db-config.md +++ b/backend/docs/modules/db-config.md @@ -7,6 +7,7 @@ The DB Config module manages database connection settings, environment validatio **Location:** `backend/src/db/` **Key Files:** + - `db-config.ts` - Typed ESM database connection settings per environment - `umzug.ts` - Typed Umzug runner for migrations, seeders, create/drop - `utils.ts` - Database utility functions @@ -14,6 +15,7 @@ The DB Config module manages database connection settings, environment validatio - `reset.ts` - Database reset script **Related Files:** + - `backend/src/config.ts` - Application configuration - `backend/src/utils/env-validation.ts` - Environment variable validation @@ -58,14 +60,14 @@ env var is absent or invalid, the `port` property is omitted. ### Environment Comparison -| Setting | Production | Development | Dev Stage | -|---------|------------|-------------|-----------| -| **Dialect** | postgres | postgres | postgres | -| **Credentials** | Env vars | Hardcoded | Env vars | -| **Logging** | Disabled | Pino debug | Pino debug | -| **Host** | Env var | localhost | Env var | +| Setting | Production | Development | Dev Stage | +| --------------------- | ------------- | ------------- | ------------- | +| **Dialect** | postgres | postgres | postgres | +| **Credentials** | Env vars | Hardcoded | Env vars | +| **Logging** | Disabled | Pino debug | Pino debug | +| **Host** | Env var | localhost | Env var | | **Migration Storage** | SequelizeMeta | SequelizeMeta | SequelizeMeta | -| **Seeder Storage** | SequelizeData | SequelizeData | SequelizeData | +| **Seeder Storage** | SequelizeData | SequelizeData | SequelizeData | --- @@ -73,14 +75,14 @@ env var is absent or invalid, the `port` property is omitted. ### Database Variables -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `NODE_ENV` | No | development | Environment selection | -| `DB_HOST` | Prod/Stage | localhost | Database host | -| `DB_PORT` | Prod/Stage | 5432 | Database port | -| `DB_NAME` | Prod/Stage | db_tour_builder_platform | Database name | -| `DB_USER` | Prod/Stage | postgres | Database username | -| `DB_PASS` | Prod/Stage | (empty) | Database password | +| Variable | Required | Default | Description | +| ---------- | ---------- | ------------------------ | --------------------- | +| `NODE_ENV` | No | development | Environment selection | +| `DB_HOST` | Prod/Stage | localhost | Database host | +| `DB_PORT` | Prod/Stage | 5432 | Database port | +| `DB_NAME` | Prod/Stage | db_tour_builder_platform | Database name | +| `DB_USER` | Prod/Stage | postgres | Database username | +| `DB_PASS` | Prod/Stage | (empty) | Database password | ### Environment Validation @@ -112,47 +114,47 @@ const envSchema = Joi.object({ ### Complete Environment Variable Schema -| Category | Variable | Validation | Default | -|----------|----------|------------|---------| -| **Server** | NODE_ENV | enum: development, test, production, dev_stage | development | -| | PORT | number | 8080 | -| **Database** | DB_HOST | string | localhost | -| | DB_PORT | number | 5432 | -| | DB_NAME | string | db_tour_builder_platform | -| | DB_USER | string | postgres | -| | DB_PASS | string (allow empty) | (empty) | -| **Auth** | SECRET_KEY | string, min 16 chars | (default UUID) | -| | ADMIN_PASS | string | 88dbeaf8 | -| | USER_PASS | string | c3baadeda5c6 | -| | ADMIN_EMAIL | email | admin@flatlogic.com | -| **OAuth** | GOOGLE_CLIENT_ID | string (allow empty) | (empty) | -| | GOOGLE_CLIENT_SECRET | string (allow empty) | (empty) | -| | MS_CLIENT_ID | string (allow empty) | (empty) | -| | MS_CLIENT_SECRET | string (allow empty) | (empty) | -| **AWS S3** | AWS_ACCESS_KEY_ID | string (allow empty) | (empty) | -| | AWS_SECRET_ACCESS_KEY | string (allow empty) | (empty) | -| | AWS_S3_BUCKET | string (allow empty) | (empty) | -| | AWS_S3_REGION | string | us-east-1 | -| | AWS_S3_PREFIX | string | (default hash) | -| | AWS_S3_CONNECTION_TIMEOUT | number (ms) | 5000 | -| | AWS_S3_REQUEST_TIMEOUT | number (ms) | 30000 | -| | AWS_S3_MAX_ATTEMPTS | number | 3 | -| | AWS_S3_MAX_SOCKETS | number | 50 | -| | AWS_S3_KEEP_ALIVE | boolean string | true | -| | AWS_S3_PRESIGN_EXPIRY | number (seconds) | 3600 | -| **Email** | EMAIL_USER | string (allow empty) | (empty) | -| | EMAIL_PASS | string (allow empty) | (empty) | -| | EMAIL_TLS_REJECT_UNAUTHORIZED | enum: true, false | true | -| **External APIs** | PEXELS_KEY | string (allow empty) | (empty) | -| **Logging** | LOG_LEVEL | enum: fatal, error, warn, info, debug, trace | info | +| Category | Variable | Validation | Default | +| ----------------- | ----------------------------- | ---------------------------------------------- | ------------------------ | +| **Server** | NODE_ENV | enum: development, test, production, dev_stage | development | +| | PORT | number | 8080 | +| **Database** | DB_HOST | string | localhost | +| | DB_PORT | number | 5432 | +| | DB_NAME | string | db_tour_builder_platform | +| | DB_USER | string | postgres | +| | DB_PASS | string (allow empty) | (empty) | +| **Auth** | SECRET_KEY | string, min 16 chars | (default UUID) | +| | ADMIN_PASS | string | 88dbeaf8 | +| | USER_PASS | string | c3baadeda5c6 | +| | ADMIN_EMAIL | email | admin@flatlogic.com | +| **OAuth** | GOOGLE_CLIENT_ID | string (allow empty) | (empty) | +| | GOOGLE_CLIENT_SECRET | string (allow empty) | (empty) | +| | MS_CLIENT_ID | string (allow empty) | (empty) | +| | MS_CLIENT_SECRET | string (allow empty) | (empty) | +| **AWS S3** | AWS_ACCESS_KEY_ID | string (allow empty) | (empty) | +| | AWS_SECRET_ACCESS_KEY | string (allow empty) | (empty) | +| | AWS_S3_BUCKET | string (allow empty) | (empty) | +| | AWS_S3_REGION | string | us-east-1 | +| | AWS_S3_PREFIX | string | (default hash) | +| | AWS_S3_CONNECTION_TIMEOUT | number (ms) | 5000 | +| | AWS_S3_REQUEST_TIMEOUT | number (ms) | 30000 | +| | AWS_S3_MAX_ATTEMPTS | number | 3 | +| | AWS_S3_MAX_SOCKETS | number | 50 | +| | AWS_S3_KEEP_ALIVE | boolean string | true | +| | AWS_S3_PRESIGN_EXPIRY | number (seconds) | 3600 | +| **Email** | EMAIL_USER | string (allow empty) | (empty) | +| | EMAIL_PASS | string (allow empty) | (empty) | +| | EMAIL_TLS_REJECT_UNAUTHORIZED | enum: true, false | true | +| **External APIs** | PEXELS_KEY | string (allow empty) | (empty) | +| **Logging** | LOG_LEVEL | enum: fatal, error, warn, info, debug, trace | info | ### Validation Behavior ```javascript function validateEnv() { const { error, value } = envSchema.validate(process.env, { - abortEarly: false, // Report all errors, not just first - stripUnknown: false, // Keep unknown env vars + abortEarly: false, // Report all errors, not just first + stripUnknown: false, // Keep unknown env vars }); if (error) { @@ -160,7 +162,7 @@ function validateEnv() { logger.error({ errors: messages }, 'Environment validation failed'); if (process.env.NODE_ENV === 'production') { - process.exit(1); // Fatal in production + process.exit(1); // Fatal in production } else { logger.warn('Continuing with default values in non-production mode'); } @@ -178,13 +180,13 @@ The database command entrypoint is `backend/src/db/umzug.ts`. ### Runtime Paths -| Setting | Path | -|---------|------| -| Config | `src/db/db-config.ts` | -| Runner | `src/db/umzug.ts` | -| Models | `src/db/models/` | -| Seeders | `src/db/seeders/` | -| Migrations | `src/db/migrations/` | +| Setting | Path | +| ---------- | --------------------- | +| Config | `src/db/db-config.ts` | +| Runner | `src/db/umzug.ts` | +| Models | `src/db/models/` | +| Seeders | `src/db/seeders/` | +| Migrations | `src/db/migrations/` | --- @@ -245,11 +247,11 @@ through `src/db/models/index.ts`, whose typed facade is provided by import Utils from '../db/utils.ts'; // UUID validation -Utils.isValidUuid('550e8400-e29b-41d4-a716-446655440000'); // true -Utils.isValidUuid('not-a-uuid'); // false +Utils.isValidUuid('550e8400-e29b-41d4-a716-446655440000'); // true +Utils.isValidUuid('not-a-uuid'); // false // Generate new UUID -const id = Utils.generateUuid(); // Returns new UUID v4 +const id = Utils.generateUuid(); // Returns new UUID v4 // Filter array to valid UUIDs only const validIds = Utils.filterValidUuids(['uuid1', 'invalid', 'uuid2']); @@ -260,7 +262,7 @@ const where = { Utils.ilike('users', 'firstName', searchTerm), Utils.ilike('users', 'lastName', searchTerm), Utils.ilike('users', 'email', searchTerm), - ] + ], }; ``` @@ -276,7 +278,9 @@ Synchronizes models to database schema using Sequelize's `alter` mode. async function syncDatabase() { // Safety check - never run in production if (process.env.NODE_ENV === 'production') { - console.error('ERROR: sync.ts should not be run in production. Use migrations instead.'); + console.error( + 'ERROR: sync.ts should not be run in production. Use migrations instead.', + ); process.exit(1); } @@ -293,17 +297,18 @@ async function syncDatabase() { ``` **Usage:** + ```bash node src/db/sync.ts ``` **Sync Modes:** -| Mode | Description | Use Case | -|------|-------------|----------| -| `{ force: true }` | Drop and recreate all tables | Fresh start | -| `{ alter: true }` | Modify tables to match models | Development | -| (none) | Create only missing tables | Safe default | +| Mode | Description | Use Case | +| ----------------- | ----------------------------- | ------------ | +| `{ force: true }` | Drop and recreate all tables | Fresh start | +| `{ alter: true }` | Modify tables to match models | Development | +| (none) | Create only missing tables | Safe default | ### reset.ts @@ -324,6 +329,7 @@ db.sequelize ``` **Usage:** + ```bash node src/db/reset.ts ``` @@ -397,45 +403,46 @@ const config = { ### Storage Configuration -| Provider | Variables | Purpose | -|----------|-----------|---------| -| **AWS S3** | AWS_S3_BUCKET, AWS_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | File storage | -| **GCloud** | (hardcoded bucket) | Legacy support | -| **Local** | uploadDir (os.tmpdir()) | Development fallback | +| Provider | Variables | Purpose | +| ---------- | ---------------------------------------------------------------------- | -------------------- | +| **AWS S3** | AWS_S3_BUCKET, AWS_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | File storage | +| **GCloud** | (hardcoded bucket) | Legacy support | +| **Local** | uploadDir (os.tmpdir()) | Development fallback | ### S3 Performance Tuning -| Variable | Default | Description | -|----------|---------|-------------| -| AWS_S3_CONNECTION_TIMEOUT | 5000ms | TCP connection timeout | -| AWS_S3_REQUEST_TIMEOUT | 30000ms | Total request timeout | -| AWS_S3_MAX_ATTEMPTS | 3 | Retry attempts on failure | -| AWS_S3_MAX_SOCKETS | 50 | Connection pool size | -| AWS_S3_KEEP_ALIVE | true | Reuse TCP connections | -| AWS_S3_PRESIGN_EXPIRY | 3600s | Presigned URL validity (1 hour) | +| Variable | Default | Description | +| ------------------------- | ------- | ------------------------------- | +| AWS_S3_CONNECTION_TIMEOUT | 5000ms | TCP connection timeout | +| AWS_S3_REQUEST_TIMEOUT | 30000ms | Total request timeout | +| AWS_S3_MAX_ATTEMPTS | 3 | Retry attempts on failure | +| AWS_S3_MAX_SOCKETS | 50 | Connection pool size | +| AWS_S3_KEEP_ALIVE | true | Reuse TCP connections | +| AWS_S3_PRESIGN_EXPIRY | 3600s | Presigned URL validity (1 hour) | ### Security Configuration -| Setting | Value | Purpose | -|---------|-------|---------| -| bcrypt.saltRounds | 12 | Password hashing strength | -| SECRET_KEY | 16+ char string | JWT signing key | -| EMAIL_TLS_REJECT_UNAUTHORIZED | true/false | TLS certificate validation | +| Setting | Value | Purpose | +| ----------------------------- | --------------- | -------------------------- | +| bcrypt.saltRounds | 12 | Password hashing strength | +| SECRET_KEY | 16+ char string | JWT signing key | +| EMAIL_TLS_REJECT_UNAUTHORIZED | true/false | TLS certificate validation | ### URL Configuration -| URL | Development | Production | -|-----|-------------|------------| -| apiUrl | http://localhost:3000/api | (remote)/api | -| swaggerUrl | http://localhost:3000 | (remote) | -| uiUrl | http://localhost:3001/# | (remote)/# | -| backUrl | http://localhost:3001 | (remote) | +| URL | Development | Production | +| ---------- | ------------------------- | ------------ | +| apiUrl | http://localhost:3000/api | (remote)/api | +| swaggerUrl | http://localhost:3000 | (remote) | +| uiUrl | http://localhost:3001/# | (remote)/# | +| backUrl | http://localhost:3001 | (remote) | --- ## Running Commands ### Development + ```bash cd backend npm run start-dev @@ -447,6 +454,7 @@ DB config selection; when `NODE_ENV` is absent it defaults to `dev_stage`, which matches the standard VM backend flow and listens on port `3000`. ### VM / Dev Stage + ```bash cd backend npm run start @@ -465,12 +473,14 @@ flow loads `.env` through `src/load-env.ts` and defaults missing `NODE_ENV` to ## Best Practices ### 1. Never Commit Secrets + ```bash # .env file should be in .gitignore # Use environment variables in deployment ``` ### 2. Use Migrations in Production + ```javascript // Never use sync.ts or reset.ts in production if (process.env.NODE_ENV === 'production') { @@ -479,13 +489,15 @@ if (process.env.NODE_ENV === 'production') { ``` ### 3. Validate Environment Early + ```javascript // config.ts loads validation at import time import { validateEnv } from './utils/env-validation.ts'; -validateEnv(); // Called before app starts +validateEnv(); // Called before app starts ``` ### 4. Environment-Specific Logging + ```javascript // Production: logging disabled (performance) // Development/dev_stage: SQL logs use structured Pino debug entries diff --git a/backend/docs/modules/db-migrations.md b/backend/docs/modules/db-migrations.md index e023a3e..d6d1f18 100644 --- a/backend/docs/modules/db-migrations.md +++ b/backend/docs/modules/db-migrations.md @@ -65,15 +65,16 @@ backend/ `backend/src/db/umzug.ts` owns migration and seeder execution. It uses official Umzug types, `SequelizeStorage`, and the existing storage tables: -| Flow | Files | Storage Table | Stored Names | -|------|-------|---------------|--------------| -| Migrations | `src/db/migrations/*.js` | `SequelizeMeta` | `*.js` | -| Seeders | `src/db/seeders/*.ts` in source, `dist/src/db/seeders/*.js` in build | `SequelizeData` | stable `*.js` names | +| Flow | Files | Storage Table | Stored Names | +| ---------- | -------------------------------------------------------------------- | --------------- | ------------------- | +| Migrations | `src/db/migrations/*.js` | `SequelizeMeta` | `*.js` | +| Seeders | `src/db/seeders/*.ts` in source, `dist/src/db/seeders/*.js` in build | `SequelizeData` | stable `*.js` names | Seeder files are typed ESM source, and the runner stores stable execution names so already executed seeders are not treated as pending. ### NPM Scripts + ```bash # Run pending migrations npm run db:migrate @@ -99,6 +100,7 @@ npm run db:seed ## Migration File Structure ### Standard Template + ```javascript 'use strict'; @@ -122,6 +124,7 @@ Use one project-wide migration template for new schema changes. Do not modify already applied migration files to match newer style choices. ### Naming Convention + ``` YYYYMMDDHHMMSS-descriptive-name.js @@ -138,6 +141,7 @@ Examples: ## Migration Patterns ### 1. Transaction Wrapper Pattern + **Purpose:** Ensure atomic operations - all changes succeed or all fail. ```javascript @@ -164,6 +168,7 @@ module.exports = { --- ### 2. Idempotent Check Pattern + **Purpose:** Safely re-run migrations without errors. ```javascript @@ -188,6 +193,7 @@ await queryInterface.addColumn('tableName', 'columnName', { ... }); --- ### 3. Helper Function Pattern + **Purpose:** Reduce repetition for bulk operations. ```javascript @@ -196,14 +202,19 @@ module.exports = { const transaction = await queryInterface.sequelize.transaction(); // Define reusable helper - const addForeignKey = async (tableName, columnName, references, onDelete) => { + const addForeignKey = async ( + tableName, + columnName, + references, + onDelete, + ) => { const constraintName = `${tableName}_${columnName}_fkey`; // Check existence const [results] = await queryInterface.sequelize.query( `SELECT constraint_name FROM information_schema.table_constraints WHERE table_name = '${tableName}' AND constraint_name = '${constraintName}'`, - { transaction } + { transaction }, ); if (results.length === 0) { @@ -221,8 +232,18 @@ module.exports = { }; // Use helper multiple times - await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE'); - await addForeignKey('tour_pages', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE'); + await addForeignKey( + 'assets', + 'projectId', + { table: 'projects', field: 'id' }, + 'CASCADE', + ); + await addForeignKey( + 'tour_pages', + 'projectId', + { table: 'projects', field: 'id' }, + 'CASCADE', + ); // ... more FKs }, }; @@ -233,6 +254,7 @@ module.exports = { --- ### 4. Safe Table Drop Pattern + **Purpose:** Prevent accidental data loss when dropping tables. ```javascript @@ -270,6 +292,7 @@ module.exports = { --- ### 5. ENUM to TEXT Conversion Pattern + **Purpose:** Convert restrictive ENUMs to flexible TEXT while preserving data. ```javascript @@ -279,33 +302,45 @@ module.exports = { try { // 1. Create temporary TEXT column - await queryInterface.addColumn('table', 'column_text', { - type: Sequelize.TEXT, - allowNull: true, - }, { transaction }); + await queryInterface.addColumn( + 'table', + 'column_text', + { + type: Sequelize.TEXT, + allowNull: true, + }, + { transaction }, + ); // 2. Copy ENUM values to TEXT await queryInterface.sequelize.query( `UPDATE table SET column_text = column::TEXT`, - { transaction } + { transaction }, ); // 3. Drop old ENUM column await queryInterface.removeColumn('table', 'column', { transaction }); // 4. Rename TEXT column - await queryInterface.renameColumn('table', 'column_text', 'column', { transaction }); + await queryInterface.renameColumn('table', 'column_text', 'column', { + transaction, + }); // 5. Add NOT NULL constraint - await queryInterface.changeColumn('table', 'column', { - type: Sequelize.TEXT, - allowNull: false, - }, { transaction }); + await queryInterface.changeColumn( + 'table', + 'column', + { + type: Sequelize.TEXT, + allowNull: false, + }, + { transaction }, + ); // 6. Drop ENUM type await queryInterface.sequelize.query( `DROP TYPE IF EXISTS "enum_table_column"`, - { transaction } + { transaction }, ); await transaction.commit(); @@ -330,6 +365,7 @@ module.exports = { --- ### 6. Data Backfill Pattern + **Purpose:** Populate new tables/columns with data from existing records. ```javascript @@ -383,6 +419,7 @@ module.exports = { --- ### 7. Cross-Environment Data Copy Pattern + **Purpose:** Copy content between environments (dev → stage → production). ```javascript @@ -390,14 +427,14 @@ module.exports = { async up(queryInterface, Sequelize) { const projects = await queryInterface.sequelize.query( `SELECT id FROM projects WHERE "deletedAt" IS NULL`, - { type: Sequelize.QueryTypes.SELECT } + { type: Sequelize.QueryTypes.SELECT }, ); for (const project of projects) { // Check if target environment already has content const [stageCheck] = await queryInterface.sequelize.query( `SELECT COUNT(*)::int as count FROM tour_pages - WHERE "projectId" = '${project.id}' AND environment = 'stage'` + WHERE "projectId" = '${project.id}' AND environment = 'stage'`, ); if (stageCheck?.count > 0) continue; @@ -433,7 +470,7 @@ module.exports = { async down(queryInterface) { // Delete records with source_key (created by migration) await queryInterface.sequelize.query( - `DELETE FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL` + `DELETE FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL`, ); }, }; @@ -444,6 +481,7 @@ module.exports = { --- ### 8. JSON Field Transformation Pattern + **Purpose:** Transform data stored in JSON columns. ```javascript @@ -456,24 +494,27 @@ module.exports = { const [records] = await queryInterface.sequelize.query( `SELECT id, "projectId", environment, slug, json_column FROM table_name WHERE json_column IS NOT NULL`, - { transaction } + { transaction }, ); // Build lookup maps for ID → slug transformations const slugById = new Map(); - records.forEach(r => slugById.set(r.id, { projectId: r.projectId, slug: r.slug })); + records.forEach((r) => + slugById.set(r.id, { projectId: r.projectId, slug: r.slug }), + ); // Transform each record for (const record of records) { - const jsonData = typeof record.json_column === 'string' - ? JSON.parse(record.json_column) - : record.json_column; + const jsonData = + typeof record.json_column === 'string' + ? JSON.parse(record.json_column) + : record.json_column; let hasChanges = false; // Transform JSON structure if (jsonData.elements) { - jsonData.elements.forEach(element => { + jsonData.elements.forEach((element) => { if (element.targetPageId) { const target = slugById.get(element.targetPageId); if (target) { @@ -490,8 +531,8 @@ module.exports = { `UPDATE table_name SET json_column = :json WHERE id = :id`, { replacements: { json: JSON.stringify(jsonData), id: record.id }, - transaction - } + transaction, + }, ); } } @@ -510,6 +551,7 @@ module.exports = { --- ### 9. Constraint Enforcement Pattern + **Purpose:** Add NOT NULL constraints after fixing existing NULL values. ```javascript @@ -517,7 +559,7 @@ module.exports = { async up(queryInterface) { // First, fix any NULL values await queryInterface.sequelize.query( - `UPDATE table_name SET column = 'default' WHERE column IS NULL` + `UPDATE table_name SET column = 'default' WHERE column IS NULL`, ); // Then add NOT NULL constraint with default @@ -543,6 +585,7 @@ module.exports = { --- ### 10. Safe Down Migration Pattern + **Purpose:** Handle cases where down migration isn't meaningful. ```javascript @@ -554,7 +597,9 @@ module.exports = { async down(_queryInterface, _Sequelize) { // This migration only adds missing data, not destructive - console.log('No down migration needed - this migration only adds missing data.'); + console.log( + 'No down migration needed - this migration only adds missing data.', + ); }, }; ``` @@ -566,60 +611,74 @@ module.exports = { ## Migration Categories ### Schema Changes -| Migration | Description | -|-----------|-------------| -| `add-foreign-key-constraints` | Add FK constraints to all model associations | -| `create-project-element-defaults` | Create new table with indexes | -| `drop-page-elements-table` | Drop unused table | -| `drop-page-links-table` | Drop unused table | -| `drop-transitions-table` | Drop unused table | + +| Migration | Description | +| --------------------------------- | -------------------------------------------- | +| `add-foreign-key-constraints` | Add FK constraints to all model associations | +| `create-project-element-defaults` | Create new table with indexes | +| `drop-page-elements-table` | Drop unused table | +| `drop-page-links-table` | Drop unused table | +| `drop-transitions-table` | Drop unused table | ### Column Modifications -| Migration | Description | -|-----------|-------------| -| `remove-redundant-deletion-columns` | Remove `is_deleted`, `deleted_at_time` | -| `remove-project-phase-column` | Remove redundant `phase` column | -| `remove-entry-page-slug-column` | Remove unused column | -| `convert-element-type-enum-to-text` | ENUM → TEXT for flexibility | -| `enforce-environment-not-null` | Add NOT NULL constraint | -| `remove-unused-theme-columns-from-projects` | Remove `theme_config_json`, `custom_css_json`, `cdn_base_url` | -| `add-background-video-settings` | Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages | -| `add-design-dimensions-to-projects` | Add `design_width`, `design_height` to projects table | -| `add-design-dimensions-to-tour-pages` | Add `design_width`, `design_height` to tour_pages table | + +| Migration | Description | +| ------------------------------------------- | --------------------------------------------------------------------------------- | +| `remove-redundant-deletion-columns` | Remove `is_deleted`, `deleted_at_time` | +| `remove-project-phase-column` | Remove redundant `phase` column | +| `remove-entry-page-slug-column` | Remove unused column | +| `convert-element-type-enum-to-text` | ENUM → TEXT for flexibility | +| `enforce-environment-not-null` | Add NOT NULL constraint | +| `remove-unused-theme-columns-from-projects` | Remove `theme_config_json`, `custom_css_json`, `cdn_base_url` | +| `add-background-video-settings` | Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages | +| `add-design-dimensions-to-projects` | Add `design_width`, `design_height` to projects table | +| `add-design-dimensions-to-tour-pages` | Add `design_width`, `design_height` to tour_pages table | ### Table Renames -| Migration | Description | -|-----------|-------------| + +| Migration | Description | +| --------------------------------------------- | ------------------ | | `rename-ui-elements-to-element-type-defaults` | Rename for clarity | ### Data Migrations -| Migration | Description | -|-----------|-------------| -| `backfill-project-element-defaults` | Populate new table for existing projects | -| `copy-dev-to-stage` | Initialize stage environment | -| `convert-targetpageid-to-slug` | Transform JSON navigation references | -| `fix-project-audio-tracks-environment` | Fix environment values | -| `add-missing-element-type-defaults` | Insert missing default rows | -| `sync-all-element-type-defaults` | Full sync of all 11 element types | + +| Migration | Description | +| ---------------------------------------- | ---------------------------------------------------------- | +| `backfill-project-element-defaults` | Populate new table for existing projects | +| `copy-dev-to-stage` | Initialize stage environment | +| `convert-targetpageid-to-slug` | Transform JSON navigation references | +| `fix-project-audio-tracks-environment` | Fix environment values | +| `add-missing-element-type-defaults` | Insert missing default rows | +| `sync-all-element-type-defaults` | Full sync of all 11 element types | | `remove-duplicate-element-type-defaults` | Remove duplicate records created during earlier migrations | -| `cleanup-invalid-element-type-defaults` | Clean up invalid entries and ensure data integrity | +| `cleanup-invalid-element-type-defaults` | Clean up invalid entries and ensure data integrity | --- ## Foreign Key Strategies -| Strategy | When to Use | Example | -|----------|-------------|---------| -| `CASCADE` | Delete child when parent deleted | `assets.projectId → projects.id` | -| `SET NULL` | Preserve record, nullify FK | `publish_events.userId → users.id` (audit trail) | -| `SET NULL` + `allowNull: true` | Optional FK | `users.app_roleId → roles.id` | +| Strategy | When to Use | Example | +| ------------------------------ | -------------------------------- | ------------------------------------------------ | +| `CASCADE` | Delete child when parent deleted | `assets.projectId → projects.id` | +| `SET NULL` | Preserve record, nullify FK | `publish_events.userId → users.id` (audit trail) | +| `SET NULL` + `allowNull: true` | Optional FK | `users.app_roleId → roles.id` | ```javascript // CASCADE - delete assets when project is deleted -await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE'); +await addForeignKey( + 'assets', + 'projectId', + { table: 'projects', field: 'id' }, + 'CASCADE', +); // SET NULL - preserve audit log when user is deleted -await addForeignKey('access_logs', 'userId', { table: 'users', field: 'id' }, 'SET NULL'); +await addForeignKey( + 'access_logs', + 'userId', + { table: 'users', field: 'id' }, + 'SET NULL', +); ``` --- @@ -627,6 +686,7 @@ await addForeignKey('access_logs', 'userId', { table: 'users', field: 'id' }, 'S ## Best Practices ### 1. Always Use Transactions + ```javascript const transaction = await queryInterface.sequelize.transaction(); try { @@ -639,20 +699,23 @@ try { ``` ### 2. Check Before Modify + ```javascript // Always check existence before adding/removing const tableExists = await queryInterface.sequelize.query( - `SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'name')` + `SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'name')`, ); ``` ### 3. Log Progress + ```javascript console.log(`Migrating project ${projectId}: ${addedCount} records added`); console.log('Migration complete: All foreign keys added'); ``` ### 4. Safe Drops + ```javascript // Never drop non-empty tables silently if (count > 0) { @@ -661,6 +724,7 @@ if (count > 0) { ``` ### 5. Reversible Operations + ```javascript // Down migration should restore previous state async down(queryInterface, Sequelize) { @@ -671,16 +735,17 @@ async down(queryInterface, Sequelize) { ``` ### 6. Use Parameterized Queries + ```javascript // Good - prevents SQL injection await queryInterface.sequelize.query( `UPDATE table SET column = :value WHERE id = :id`, - { replacements: { value: 'safe', id: record.id } } + { replacements: { value: 'safe', id: record.id } }, ); // Avoid - SQL injection risk await queryInterface.sequelize.query( - `UPDATE table SET column = '${unsafeValue}' WHERE id = '${unsafeId}'` + `UPDATE table SET column = '${unsafeValue}' WHERE id = '${unsafeId}'`, ); ``` @@ -689,13 +754,16 @@ await queryInterface.sequelize.query( ## Running Migrations ### Development + ```bash cd backend npm run db:migrate ``` ### Server Startup + Migrations run automatically via `npm start`: + ```json { "scripts": { @@ -705,11 +773,13 @@ Migrations run automatically via `npm start`: ``` ### Migration Status + ```bash npm run db:migrate:status ``` ### Undo Migrations + ```bash # Undo last migration npm run db:migrate:undo @@ -728,32 +798,32 @@ explicit rollback/backup plan. ## Current Migration Inventory -| # | Timestamp | Name | Type | -|---|-----------|------|------| -| 1 | 20260319000001 | add-foreign-key-constraints | Schema | -| 2 | 20260319000002 | remove-redundant-deletion-columns | Column | -| 3 | 20260326000001 | rename-ui-elements-to-element-type-defaults | Rename | -| 4 | 20260326000002 | convert-element-type-enum-to-text | Column | -| 5 | 20260326000003 | create-project-element-defaults | Schema | -| 6 | 20260326000004 | backfill-project-element-defaults | Data | -| 7 | 20260326000005 | fix-project-audio-tracks-environment | Data | -| 8 | 20260326000006 | copy-dev-to-stage | Data | -| 9 | 20260326043002 | enforce-environment-not-null | Column | -| 10 | 20260326050442 | remove-project-phase-column | Column | -| 11 | 20260326054410 | remove-entry-page-slug-column | Column | -| 12 | 20260326060000 | convert-targetpageid-to-slug | Data | -| 13 | 20260326060001 | drop-page-elements-table | Schema | -| 14 | 20260326060002 | drop-page-links-table | Schema | -| 15 | 20260326060003 | drop-transitions-table | Schema | -| 16 | 20260326171017 | add-missing-element-type-defaults | Data | -| 17 | 20260327000001 | sync-all-element-type-defaults | Data | -| 18 | 20260331024423 | remove-unused-theme-columns-from-projects | Column | -| 19 | 20260331054340 | remove-duplicate-element-type-defaults | Data | -| 20 | 20260331063424 | cleanup-invalid-element-type-defaults | Data | -| 21 | 20260403000001 | add-background-video-settings | Column | -| 22 | 20260409000001 | add-design-dimensions-to-projects | Column | -| 23 | 20260409111309 | add-design-dimensions-to-tour-pages | Column | -| 24 | 20260605000001 | add-background-audio-settings | Column | +| # | Timestamp | Name | Type | +| --- | -------------- | ------------------------------------------- | ------ | +| 1 | 20260319000001 | add-foreign-key-constraints | Schema | +| 2 | 20260319000002 | remove-redundant-deletion-columns | Column | +| 3 | 20260326000001 | rename-ui-elements-to-element-type-defaults | Rename | +| 4 | 20260326000002 | convert-element-type-enum-to-text | Column | +| 5 | 20260326000003 | create-project-element-defaults | Schema | +| 6 | 20260326000004 | backfill-project-element-defaults | Data | +| 7 | 20260326000005 | fix-project-audio-tracks-environment | Data | +| 8 | 20260326000006 | copy-dev-to-stage | Data | +| 9 | 20260326043002 | enforce-environment-not-null | Column | +| 10 | 20260326050442 | remove-project-phase-column | Column | +| 11 | 20260326054410 | remove-entry-page-slug-column | Column | +| 12 | 20260326060000 | convert-targetpageid-to-slug | Data | +| 13 | 20260326060001 | drop-page-elements-table | Schema | +| 14 | 20260326060002 | drop-page-links-table | Schema | +| 15 | 20260326060003 | drop-transitions-table | Schema | +| 16 | 20260326171017 | add-missing-element-type-defaults | Data | +| 17 | 20260327000001 | sync-all-element-type-defaults | Data | +| 18 | 20260331024423 | remove-unused-theme-columns-from-projects | Column | +| 19 | 20260331054340 | remove-duplicate-element-type-defaults | Data | +| 20 | 20260331063424 | cleanup-invalid-element-type-defaults | Data | +| 21 | 20260403000001 | add-background-video-settings | Column | +| 22 | 20260409000001 | add-design-dimensions-to-projects | Column | +| 23 | 20260409111309 | add-design-dimensions-to-tour-pages | Column | +| 24 | 20260605000001 | add-background-audio-settings | Column | --- diff --git a/backend/docs/modules/db-models.md b/backend/docs/modules/db-models.md index 9115ad7..6b7bf46 100644 --- a/backend/docs/modules/db-models.md +++ b/backend/docs/modules/db-models.md @@ -10,31 +10,31 @@ The DB Models module defines the Sequelize ORM models that map to PostgreSQL dat 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 | +| 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 | --- @@ -128,6 +128,7 @@ bridge or immutable migration. ## Model Loader **Locations:** + - `backend/src/db/models/loader.ts` - `backend/src/db/models/index.ts` - `backend/src/types/db-models.ts` @@ -143,11 +144,11 @@ for service-specific model calls. **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 | +| 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 | --- @@ -167,13 +168,13 @@ All models share these Sequelize options: ### Common Fields -| Field | Type | Description | -|-------|------|-------------| -| `id` | `UUID` | Primary key (auto-generated UUIDv4) | +| 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) | +| `createdAt` | `DATE` | Auto-managed creation timestamp | +| `updatedAt` | `DATE` | Auto-managed update timestamp | +| `deletedAt` | `DATE` | Soft delete timestamp (paranoid mode) | ### Common Associations @@ -193,40 +194,49 @@ db.MODEL.belongsTo(db.users, { as: 'updatedBy' }); **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 | +| 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.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' } }); +users.hasMany(file, { + as: 'avatar', + scope: { belongsTo: 'users', belongsToColumn: 'avatar' }, +}); ``` **Hooks:** + ```javascript users.beforeCreate((user) => { // Trim string fields @@ -245,34 +255,60 @@ users.beforeUpdate((user) => { **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 | +| 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(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(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(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' }); +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', +}); ``` --- @@ -282,26 +318,40 @@ projects.hasMany(production_presentation_access, { as: 'production_presentation_ **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 | +| 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' }); +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', +}); ``` --- @@ -310,23 +360,24 @@ production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SE **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 | +| 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 @@ -340,15 +391,19 @@ production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SE **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 | +| 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.belongsToMany(permissions, { + as: 'permissions', + through: 'rolesPermissionsPermissions', +}); roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' }); ``` @@ -358,10 +413,10 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' }); **Purpose:** Individual permission definitions. -| Field | Type | Nullable | Default | Validation | -|-------|------|----------|---------|------------| -| `id` | UUID | No | UUIDv4 | - | -| `name` | TEXT | No | - | notEmpty, unique, len[1,100] | +| 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`) @@ -373,24 +428,25 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' }); **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 | +| 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` @@ -398,8 +454,12 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' }); - `deletedAt` **Associations:** + ```javascript -assets.hasMany(asset_variants, { as: 'asset_variants_asset', onDelete: 'CASCADE' }); +assets.hasMany(asset_variants, { + as: 'asset_variants_asset', + onDelete: 'CASCADE', +}); assets.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' }); ``` @@ -409,17 +469,18 @@ assets.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' }); **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 | +| 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' }); ``` @@ -432,26 +493,28 @@ asset_variants.belongsTo(assets, { as: 'asset', onDelete: 'CASCADE' }); **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 | +| 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' + onDelete: 'SET NULL', }); ``` @@ -463,18 +526,19 @@ element_type_defaults.hasMany(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 | +| 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` @@ -482,11 +546,15 @@ element_type_defaults.hasMany(project_element_defaults, { - `deletedAt` **Associations:** + ```javascript -project_element_defaults.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' }); +project_element_defaults.belongsTo(projects, { + as: 'project', + onDelete: 'CASCADE', +}); project_element_defaults.belongsTo(element_type_defaults, { as: 'source_element', - onDelete: 'SET NULL' + onDelete: 'SET NULL', }); ``` @@ -498,24 +566,25 @@ project_element_defaults.belongsTo(element_type_defaults, { **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 | +| 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` @@ -527,18 +596,19 @@ project_element_defaults.belongsTo(element_type_defaults, { **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 | +| 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` @@ -552,17 +622,18 @@ project_element_defaults.belongsTo(element_type_defaults, { **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 | +| 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 @@ -575,19 +646,19 @@ project_element_defaults.belongsTo(element_type_defaults, { **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 | +| 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 | --- @@ -595,27 +666,32 @@ project_element_defaults.belongsTo(element_type_defaults, { **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 | +| 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(projects, { + as: 'project', + onDelete: 'CASCADE', +}); project_transition_settings.belongsTo(users, { as: 'createdBy' }); project_transition_settings.belongsTo(users, { as: 'updatedBy' }); ``` @@ -628,16 +704,16 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' }); **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 | +| 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 | --- @@ -645,18 +721,18 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' }); **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 | +| 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 | --- @@ -664,16 +740,16 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' }); **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 | +| 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: @@ -811,19 +887,17 @@ importHash: { ### 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 | +| 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 }, -] +indexes: [{ fields: ['projectId', 'environment', 'slug'], unique: true }]; ``` ### 6. JSON Fields @@ -831,8 +905,12 @@ indexes: [ Complex configurations stored as JSON: ```javascript -ui_schema_json: { type: DataTypes.JSON } // Parsed JSON column -settings_json: { type: DataTypes.TEXT } // Stringified JSON text +ui_schema_json: { + type: DataTypes.JSON; +} // Parsed JSON column +settings_json: { + type: DataTypes.TEXT; +} // Stringified JSON text ``` ### 7. Virtual Fields @@ -933,7 +1011,7 @@ 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' } } + where: { email: { [Op.like]: '%@example.com' } }, }); ``` diff --git a/backend/docs/modules/db-seeders.md b/backend/docs/modules/db-seeders.md index 2d2b042..7f7ee1a 100644 --- a/backend/docs/modules/db-seeders.md +++ b/backend/docs/modules/db-seeders.md @@ -30,6 +30,7 @@ backend/src/db/seeders/ ## Configuration ### NPM Scripts + ```bash # Run pending seeders npm run db:seed @@ -42,7 +43,9 @@ npm run db:reset ``` ### Server Startup + Seeders run automatically via `npm start`: + ```json { "scripts": { @@ -65,13 +68,14 @@ development and the compiled JavaScript file from `dist/` in production builds. **Users Created:** -| User | Email | Role Assignment | -|------|-------|-----------------| -| Admin | `config.admin_email` | Administrator | -| John | john@doe.com | Account Manager | -| Client | client@hello.com | Platform Owner | +| User | Email | Role Assignment | +| ------ | -------------------- | --------------- | +| Admin | `config.admin_email` | Administrator | +| John | john@doe.com | Account Manager | +| Client | client@hello.com | Platform Owner | **Key Features:** + - Uses bcrypt for password hashing with configured salt rounds - Hardcoded UUIDs for consistent user IDs - Reads credentials from `config.ts` (environment variables) @@ -112,14 +116,15 @@ data. **Data Created:** -| Data Type | Count | Description | -|-----------|-------|-------------| -| Roles | 7 | User role definitions | -| Permissions | 54 | CRUD permissions for 13 entities + special | -| Role-Permission Links | 200+ | M:N relationships | -| Join Table | 1 | `rolesPermissionsPermissions` table | +| Data Type | Count | Description | +| --------------------- | ----- | ------------------------------------------ | +| Roles | 7 | User role definitions | +| Permissions | 54 | CRUD permissions for 13 entities + special | +| Role-Permission Links | 200+ | M:N relationships | +| Join Table | 1 | `rolesPermissionsPermissions` table | **Key Features:** + - Uses stable named role and permission definitions, then reuses existing DB IDs when the same role/permission name is already present. - Inserts only missing roles, permissions, and role-permission links. This keeps @@ -128,19 +133,21 @@ data. by email after RBAC data exists. #### Roles + ```javascript const roles = [ - 'Administrator', // Full system access - 'PlatformOwner', // Full project/content access - 'AccountManager', // User and project management - 'TourDesigner', // Content creation and editing - 'ContentReviewer', // Read + limited update access - 'AnalyticsViewer', // Read-only access - 'Public', // Public/unauthenticated access + 'Administrator', // Full system access + 'PlatformOwner', // Full project/content access + 'AccountManager', // User and project management + 'TourDesigner', // Content creation and editing + 'ContentReviewer', // Read + limited update access + 'AnalyticsViewer', // Read-only access + 'Public', // Public/unauthenticated access ]; ``` #### Permission Generation Pattern + ```javascript // Generates CREATE, READ, UPDATE, DELETE permissions per entity function createPermissions(name) { @@ -153,13 +160,26 @@ function createPermissions(name) { } const entities = [ - 'users', 'roles', 'permissions', 'projects', 'project_memberships', - 'assets', 'asset_variants', 'presigned_url_requests', 'tour_pages', - 'project_audio_tracks', 'publish_events', 'pwa_caches', 'access_logs' + 'users', + 'roles', + 'permissions', + 'projects', + 'project_memberships', + 'assets', + 'asset_variants', + 'presigned_url_requests', + 'tour_pages', + 'project_audio_tracks', + 'publish_events', + 'pwa_caches', + 'access_logs', ]; // Creates 52 permissions (13 entities × 4 CRUD operations) -await queryInterface.bulkInsert('permissions', entities.flatMap(createPermissions)); +await queryInterface.bulkInsert( + 'permissions', + entities.flatMap(createPermissions), +); // Plus special permissions await queryInterface.bulkInsert('permissions', [ @@ -169,6 +189,7 @@ await queryInterface.bulkInsert('permissions', [ ``` #### ID Map Pattern + ```javascript // Consistent UUID generation using key-based map const idMap = new Map(); @@ -183,25 +204,26 @@ function getId(key) { } // Usage - same key always returns same UUID within seeder run -getId('Administrator') // Returns consistent UUID -getId('CREATE_USERS') // Returns consistent UUID +getId('Administrator'); // Returns consistent UUID +getId('CREATE_USERS'); // Returns consistent UUID ``` #### Permission Matrix -| Role | Users | Projects | Assets | Tour Pages | Access Logs | -|------|-------|----------|--------|------------|-------------| -| **Administrator** | CRUD | CRUD | CRUD | CRUD | CRUD | -| **PlatformOwner** | CRUD | CRUD | CRUD | CRUD | CRUD | -| **AccountManager** | RU | CRU | CRU | CRU | R | -| **TourDesigner** | R | RU | CRU | CRU | R | -| **ContentReviewer** | R | RU | RU | RU | R | -| **AnalyticsViewer** | R | R | R | R | R | -| **Public** | - | - | - | - | - | +| Role | Users | Projects | Assets | Tour Pages | Access Logs | +| ------------------- | ----- | -------- | ------ | ---------- | ----------- | +| **Administrator** | CRUD | CRUD | CRUD | CRUD | CRUD | +| **PlatformOwner** | CRUD | CRUD | CRUD | CRUD | CRUD | +| **AccountManager** | RU | CRU | CRU | CRU | R | +| **TourDesigner** | R | RU | CRU | CRU | R | +| **ContentReviewer** | R | RU | RU | RU | R | +| **AnalyticsViewer** | R | R | R | R | R | +| **Public** | - | - | - | - | - | **Legend:** C=Create, R=Read, U=Update, D=Delete #### Join Table Creation + ```javascript // Creates M:N relationship table directly in seeder await queryInterface.sequelize.query(` @@ -235,6 +257,7 @@ separate. Umzug records the seeder under its legacy `.js` name for storage compatibility only. **Opt-In Activation:** + ```bash # Enable sample data seeding export ENABLE_SAMPLE_DATA=true @@ -242,6 +265,7 @@ npm run db:seed ``` **Check in Code:** + ```typescript const sampleDataSeeder: SequelizeSeeder = { async up() { @@ -253,20 +277,21 @@ const sampleDataSeeder: SequelizeSeeder = { **Data Created:** -| Entity | Records | Description | -|--------|---------|-------------| -| Projects | 3 | Sample tour projects | -| Project Memberships | 3 | User-project associations | -| Assets | 3 | Images, videos, audio | -| Asset Variants | 3 | Thumbnail/preview variants | -| Presigned URL Requests | 3 | Upload/download requests | -| Tour Pages | 3 | Sample tour pages | -| Project Audio Tracks | 3 | Background audio | -| Publish Events | 3 | Deployment history | -| PWA Caches | 3 | Offline cache configs | -| Access Logs | 3 | Visitor tracking | +| Entity | Records | Description | +| ---------------------- | ------- | -------------------------- | +| Projects | 3 | Sample tour projects | +| Project Memberships | 3 | User-project associations | +| Assets | 3 | Images, videos, audio | +| Asset Variants | 3 | Thumbnail/preview variants | +| Presigned URL Requests | 3 | Upload/download requests | +| Tour Pages | 3 | Sample tour pages | +| Project Audio Tracks | 3 | Background audio | +| Publish Events | 3 | Deployment history | +| PWA Caches | 3 | Offline cache configs | +| Access Logs | 3 | Visitor tracking | #### Sample Projects + ```javascript const ProjectsData = [ { @@ -293,6 +318,7 @@ const ProjectsData = [ ``` #### Association Helper Pattern + ```javascript // Associates records after bulk creation using Sequelize model methods async function associateAssetWithProject() { @@ -314,6 +340,7 @@ async function associateAssetWithProject() { ## Seeder Patterns ### 1. bulkInsert Pattern + **Purpose:** Insert multiple records efficiently. ```javascript @@ -329,6 +356,7 @@ await queryInterface.bulkInsert('tableName', [ ``` ### 2. bulkDelete Pattern + **Purpose:** Remove seeded data during rollback. ```javascript @@ -340,6 +368,7 @@ async down(queryInterface, Sequelize) { ``` ### 3. Conditional Execution Pattern + **Purpose:** Enable/disable seeders based on environment. Seeder-only environment gates are intentionally allowed to read `process.env` @@ -354,13 +383,14 @@ records, or sample-data entities. ```javascript up: async () => { if (process.env.ENABLE_SAMPLE_DATA !== 'true') { - return; // Skip seeding + return; // Skip seeding } // ... proceed with seeding -} +}; ``` ### 4. ID Consistency Pattern + **Purpose:** Use deterministic IDs for reliable down() migrations. ```javascript @@ -381,6 +411,7 @@ function getId(key) { ``` ### 5. Model-Based Association Pattern + **Purpose:** Create relationships using Sequelize models after bulk insert. ```javascript @@ -391,6 +422,7 @@ await asset.setProject(project); ``` ### 6. Raw SQL Pattern + **Purpose:** Create structures not managed by Sequelize models. ```javascript @@ -403,7 +435,7 @@ await queryInterface.sequelize.query(` // Create indexes await queryInterface.sequelize.query( - 'CREATE INDEX IF NOT EXISTS "index_name" ON "tableName" ("columnName");' + 'CREATE INDEX IF NOT EXISTS "index_name" ON "tableName" ("columnName");', ); ``` @@ -448,16 +480,21 @@ npm run db:seed ## Best Practices ### 1. Use Consistent IDs + ```javascript // Good - allows rollback const ids = ['uuid-1', 'uuid-2']; -await queryInterface.bulkInsert('table', records.map((r, i) => ({ id: ids[i], ...r }))); +await queryInterface.bulkInsert( + 'table', + records.map((r, i) => ({ id: ids[i], ...r })), +); // Down migration can target specific IDs await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } }); ``` ### 2. Always Include Timestamps + ```javascript { field: 'value', @@ -467,6 +504,7 @@ await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } }); ``` ### 3. Handle Errors + ```javascript try { await queryInterface.bulkInsert('users', [...]); @@ -477,6 +515,7 @@ try { ``` ### 4. Environment-Aware Seeding + ```javascript // Production - only essential data // Development - include sample data @@ -486,6 +525,7 @@ if (process.env.ENABLE_SAMPLE_DATA !== 'true') { ``` ### 5. Idempotent Where Possible + ```javascript // Use IF NOT EXISTS for table/index creation await queryInterface.sequelize.query(` @@ -523,23 +563,27 @@ sample-data.ts ## Running Seeders ### Development Setup + ```bash cd backend npm run db:seed ``` ### With Sample Data + ```bash export ENABLE_SAMPLE_DATA=true npm run db:seed ``` ### Fresh Database + ```bash npm run db:reset # drop, create, migrate, seed ``` ### Undo Seeders + ```bash npm run db:seed:undo # Runs all down() methods in reverse order ``` @@ -548,22 +592,22 @@ npm run db:seed:undo # Runs all down() methods in reverse order ## Seeder Inventory -| # | Timestamp | Name | Records | Required | -|---|-----------|------|---------|----------| -| 1 | 20200430130759 | admin-user | 3 users | Yes | -| 2 | 20200430130760 | user-roles | 7 roles, 54 permissions, 200+ links | Yes | -| 3 | 20231127130745 | sample-data | 30+ sample records | No (opt-in) | +| # | Timestamp | Name | Records | Required | +| --- | -------------- | ----------- | ----------------------------------- | ----------- | +| 1 | 20200430130759 | admin-user | 3 users | Yes | +| 2 | 20200430130760 | user-roles | 7 roles, 54 permissions, 200+ links | Yes | +| 3 | 20231127130745 | sample-data | 30+ sample records | No (opt-in) | --- ## Environment Variables -| Variable | Purpose | Default | -|----------|---------|---------| -| `ENABLE_SAMPLE_DATA` | Enable sample data seeder | `false` | -| `ADMIN_EMAIL` | Admin user email | (from config) | -| `ADMIN_PASS` | Admin user password | (from config) | -| `USER_PASS` | Default user password | (from config) | +| Variable | Purpose | Default | +| -------------------- | ------------------------- | ------------- | +| `ENABLE_SAMPLE_DATA` | Enable sample data seeder | `false` | +| `ADMIN_EMAIL` | Admin user email | (from config) | +| `ADMIN_PASS` | Admin user password | (from config) | +| `USER_PASS` | Default user password | (from config) | --- diff --git a/backend/docs/modules/email.md b/backend/docs/modules/email.md index 417b328..7739e73 100644 --- a/backend/docs/modules/email.md +++ b/backend/docs/modules/email.md @@ -129,13 +129,13 @@ export default class EmailSender { **Key Methods:** -| Method | Type | Description | -|--------|------|-------------| -| `constructor(email)` | Instance | Accepts email template object | -| `send()` | Async | Sends email via Nodemailer | -| `isConfigured` | Static getter | Checks if SMTP credentials exist | -| `transportConfig` | Getter | Returns SMTP config | -| `from` | Getter | Returns sender address | +| Method | Type | Description | +| -------------------- | ------------- | -------------------------------- | +| `constructor(email)` | Instance | Accepts email template object | +| `send()` | Async | Sends email via Nodemailer | +| `isConfigured` | Static getter | Checks if SMTP credentials exist | +| `transportConfig` | Getter | Returns SMTP config | +| `from` | Getter | Returns sender address | --- @@ -167,7 +167,7 @@ export default class PasswordResetEmail implements EmailTemplate { get subject() { return getNotification( 'emails.passwordReset.subject', - getNotification('app.title') + getNotification('app.title'), ); // → "Reset your password for Tour Builder Platform" } @@ -179,10 +179,11 @@ export default class PasswordResetEmail implements EmailTemplate { .replace(/{resetUrl}/g, this.link) .replace(/{accountName}/g, this.to); } -}; +} ``` **Template Variables:** + - `{appTitle}` - Application name - `{resetUrl}` - Password reset link - `{accountName}` - User email address @@ -201,7 +202,7 @@ export default class EmailAddressVerificationEmail implements EmailTemplate { get subject() { return getNotification( 'emails.emailAddressVerification.subject', - getNotification('app.title') + getNotification('app.title'), ); // → "Verify your email for Tour Builder Platform" } @@ -213,10 +214,11 @@ export default class EmailAddressVerificationEmail implements EmailTemplate { .replace(/{signupUrl}/g, this.link) .replace(/{to}/g, this.to); } -}; +} ``` **Template Variables:** + - `{appTitle}` - Application name - `{signupUrl}` - Email verification link - `{to}` - User email address @@ -235,7 +237,7 @@ export default class InvitationEmail implements EmailTemplate { get subject() { return getNotification( 'emails.invitation.subject', - getNotification('app.title') + getNotification('app.title'), ); // → "You've been invited to Tour Builder Platform" } @@ -248,10 +250,11 @@ export default class InvitationEmail implements EmailTemplate { .replace(/{signupUrl}/g, signupUrl) .replace(/{to}/g, this.to); } -}; +} ``` **Template Variables:** + - `{appTitle}` - Application name - `{signupUrl}` - Account setup link with `&invitation=true` - `{to}` - User email address @@ -267,66 +270,66 @@ All HTML templates follow consistent styling: ```html - + - - + + - + ``` ### Template Comparison -| Template | Header Text | Call-to-Action | Button Style | -|----------|-------------|----------------|--------------| -| Password Reset | "Reset your password for {appTitle}" | Link | Text link | -| Email Verification | "Verify your email for {appTitle}!" | Link | Text link | -| Invitation | "Welcome to {appTitle}!" | Button | Primary button | +| Template | Header Text | Call-to-Action | Button Style | +| ------------------ | ------------------------------------ | -------------- | -------------- | +| Password Reset | "Reset your password for {appTitle}" | Link | Text link | +| Email Verification | "Verify your email for {appTitle}!" | Link | Text link | +| Invitation | "Welcome to {appTitle}!" | Button | Primary button | --- @@ -351,11 +354,11 @@ email: { ### Environment Variables -| Variable | Required | Description | -|----------|----------|-------------| -| `EMAIL_USER` | Yes | SMTP username (AWS SES IAM user) | -| `EMAIL_PASS` | Yes | SMTP password (AWS SES IAM credentials) | -| `EMAIL_TLS_REJECT_UNAUTHORIZED` | No | Set to `'false'` to skip TLS verification | +| Variable | Required | Description | +| ------------------------------- | -------- | ----------------------------------------- | +| `EMAIL_USER` | Yes | SMTP username (AWS SES IAM user) | +| `EMAIL_PASS` | Yes | SMTP password (AWS SES IAM credentials) | +| `EMAIL_TLS_REJECT_UNAUTHORIZED` | No | Set to `'false'` to skip TLS verification | ### AWS SES Configuration @@ -397,9 +400,10 @@ class Auth { const token = await UsersDBApi.generatePasswordResetToken(email); const link = `${host}/password-reset?token=${token}`; - const emailObj = type === 'invitation' - ? new InvitationEmail({ to: email, host: link }) - : new PasswordResetEmail({ to: email, link }); + const emailObj = + type === 'invitation' + ? new InvitationEmail({ to: email, host: link }) + : new PasswordResetEmail({ to: email, link }); return new EmailSender(emailObj).send(); } @@ -445,10 +449,14 @@ router.get('/email-configured', (req, res) => { }); // Resend verification email (authenticated) -router.put('/send-email-address-verification-email', jwtAuth, async (req, res) => { - await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email); - res.status(200).send(true); -}); +router.put( + '/send-email-address-verification-email', + jwtAuth, + async (req, res) => { + await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email); + res.status(200).send(true); + }, +); // Request password reset (public) router.put('/send-password-reset-email', async (req, res) => { @@ -542,10 +550,10 @@ static async markEmailVerified(id, options) { ### Token Properties -| Token Type | Field | Expiry Field | TTL | -|------------|-------|--------------|-----| +| Token Type | Field | Expiry Field | TTL | +| ------------------ | ------------------------ | --------------------------------- | -------- | | Email Verification | `emailVerificationToken` | `emailVerificationTokenExpiresAt` | 24 hours | -| Password Reset | `passwordResetToken` | `passwordResetTokenExpiresAt` | 24 hours | +| Password Reset | `passwordResetToken` | `passwordResetTokenExpiresAt` | 24 hours | --- @@ -670,6 +678,7 @@ static async markEmailVerified(id, options) { ### Email Configured Mode When `EMAIL_USER` and `EMAIL_PASS` are set: + - Email verification required before login - Password reset emails sent on request - User invitations include email @@ -677,6 +686,7 @@ When `EMAIL_USER` and `EMAIL_PASS` are set: ### Email Not Configured Mode When credentials are missing: + - `EmailSender.isConfigured` returns `false` - Users auto-verified on signin: `user.emailVerified = true` - Password reset/invitation silently skipped @@ -746,13 +756,13 @@ emails: { ### Email-Related Errors -| Error Code | Message | When Thrown | -|------------|---------|-------------| -| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | Token generation fails | -| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | Invalid/expired verification token | -| `auth.passwordReset.error` | "Email not recognized" | Password reset token generation fails | -| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | Invalid/expired reset token | -| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | Login without email verification | +| Error Code | Message | When Thrown | +| ------------------------------------------------- | --------------------------------------------------- | ------------------------------------- | +| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | Token generation fails | +| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | Invalid/expired verification token | +| `auth.passwordReset.error` | "Email not recognized" | Password reset token generation fails | +| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | Invalid/expired reset token | +| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | Login without email verification | ### Error Flow @@ -827,7 +837,7 @@ describe('EmailSender', () => { expect.objectContaining({ to: 'test@example.com', subject: expect.stringContaining('Reset your password'), - }) + }), ); }); }); @@ -843,6 +853,7 @@ EMAIL_PASS= ``` Result: + - `EmailSender.isConfigured` returns `false` - Users auto-verified on login - No emails sent @@ -859,19 +870,21 @@ Result: - - - - -