added documentation

This commit is contained in:
Dmitri 2026-07-03 16:11:24 +02:00
parent 9e6e0e0dbe
commit 9f111d6226
70 changed files with 60714 additions and 1 deletions

1
.gitignore vendored
View File

@ -11,7 +11,6 @@ frontend/public/sw.js
frontend/next-env.d.ts
package-lock.json
!backend/package-lock.json
AGENTS.md
.codex/
!frontend/package-lock.json

447
AGENTS.md Normal file
View File

@ -0,0 +1,447 @@
# AGENTS.md
This file provides guidance to CODEX when working with code in this repository.
**MAIN RULE:** DON'T MADE UP ANYTHING!!! IF YOU NOT SURE - DOUBLECHECK IT VIA PROJECT DOCUMENTATION, TOOL DOCUMENTATION, APPROPRIATE MCP, WEB SEARCH OR JUST ASK FOR ME TO CLARIFY.
## Project Overview
Tour Builder Platform - a web application for building and managing interactive virtual tours. Built with:
- **Frontend**: Next.js 15 with React 19, TypeScript, Redux Toolkit, Tailwind CSS
- **Backend**: Node.js/Express with Sequelize ORM
- **Database**: PostgreSQL
## System Requirements
### Required Software
- **Node.js** 24.x for backend runtime and TypeScript migration work
- **PostgreSQL** 14+
### FFmpeg
FFmpeg is bundled with the backend via `ffmpeg-static` and `ffprobe-static` npm packages. No manual installation is required.
**How it works:**
- Pre-compiled binaries are downloaded during `npm install`
- `fluent-ffmpeg` is configured to use bundled binaries in `backend/src/services/videoProcessing.js`
- Works across all platforms (Linux, macOS, Windows) and Docker environments
**Supported use case:**
- Reversed video generation for back navigation transitions
## Important Rules
- **Never change global app configs casually** - Do not modify `.env` or database configuration without verifying the target environment. These configs are shared across environments and incorrect changes can break production.
- **Centralize environment variables** - Do not read new app/runtime environment variables directly from services, routes, components, hooks, or feature modules. Backend app env vars must be added to `backend/src/utils/env-validation.ts`, typed in `backend/src/types/env.ts` / `backend/src/types/config.ts`, exposed through `backend/src/config.ts`, and consumed via `config`. Frontend public env vars must be centralized in `frontend/src/config.ts`. Direct `process.env` access is acceptable only in bootstrap/config entrypoints (`load-env`, logger bootstrap, DB/Umzug config, Next config), scripts, migrations, seeders, and tests.
- **Backend async handlers** must be wrapped with `wrapAsync` helper for error propagation.
- **Use Passport JWT** for protected routes: `passport.authenticate('jwt', { session: false })`. Secure routes must read the authenticated user through `getCurrentUser(req)` from `backend/src/utils/request-context.ts`; if absent, return `ForbiddenError`.
- **Access model** - Keep the current permission model: effective permissions are `app_role.permissions + user.custom_permissions`. `Administrator`, `Platform Owner`, and `Account Manager` are platform-wide internal roles. Other internal users currently have all-project scope by default unless a future explicit override system is implemented. `Public` users must not have admin API permissions and can only access public production pages plus explicitly granted private production presentations.
- **Public role hardening** - Do not grant RBAC permissions or custom permissions to `Public` users. Do not represent private production presentation grants as `READ_PROJECTS`, `READ_TOUR_PAGES`, or other admin permissions; use `production_presentation_access`.
- **Centralize access decisions** - New authorization logic must go through an `AccessPolicy`-style helper/service rather than ad hoc checks in routes/components. Keep admin API permissions separate from runtime presentation access.
## Mandatory Rules For New Code
These rules are required for new code and for touched code when practical.
### Backend Boundaries
- **Routes/controllers**: only authenticate, read runtime context, validate request input, call a service, and map the response. Do not put business logic in routes.
- **Services/domain**: own business logic, transactions, permission decisions, and orchestration.
- **DB API/repository**: only data access, filters, includes, pagination, and persistence mapping.
- **Policy**: all role/permission/runtime access decisions belong in policy/helper services, not scattered across routes.
- **Validation**: all new external `body`, `query`, and `params` inputs must be validated before service calls. `Joi` is already available in backend and should be preferred unless there is a clear reason to use another validator.
- **ID handling**: route params are canonical. For `PUT/PATCH/DELETE /:id`, use `req.params.id`; reject mismatched body ids.
- **Query safety**: new list/autocomplete endpoints must define max `limit`, default pagination, allowed sort fields, and allowed sort directions.
- **Service contracts**: new service/DB API methods should use object/options signatures, e.g. `Service.update({ id, data, currentUser, transaction, runtimeContext })` and `DBApi.findAll(filter, { currentUser, transaction, runtimeContext })`.
- **Logging**: use the project logger in runtime backend code (`services`, `routes`, `middlewares`, `db/api`, `utils`, app/bootstrap/config). Do not add new `console.*` calls outside migrations, seeders, scripts, or explicit debug-only CLI tooling.
### Backend Typing
- New backend pure helpers, validators, and policy modules should use TypeScript or JSDoc/checkJs-compatible types.
- Define shared shapes for `currentUser`, `runtimeContext`, service options, and validation results when touching related code.
- Do not add global `Express.Request` augmentation for project request fields. Use `backend/src/utils/request-context.ts` helpers for request-scoped `currentUser`, `runtimeContext`, logging, runtime-public flags, and permission overrides.
- Keep active backend source in strict TypeScript/ESM.
### Frontend State And API
- **Redux is for client/app state only**: auth/session UI, theme/style, layout/sidebar, constructor UI state, and app preferences.
- **TanStack Query is for server state**: API reads, entity lists/details, mutations, invalidation, and background refetch.
- Do not create new entity CRUD Redux slices or new Redux thunks for server reads/mutations.
- Centralize API access through query hooks or a shared API client. Avoid direct `axios` calls inside feature UI components unless there is a clear existing local pattern.
- New feature-specific code should live near the feature/domain it belongs to. Do not put feature logic into generic `components`, `hooks`, or `lib` folders when a feature-local module is clearer.
### Frontend TypeScript And React
- Do not add new `any` without a specific reason. Prefer existing domain types or add a narrow local type.
- Avoid new `eslint-disable`, `@ts-ignore`, and hook dependency suppressions. If unavoidable, add a short reason.
- New hooks must follow React hook rules and should be structured so `react-hooks/exhaustive-deps` can be enabled.
- Do not add new client usage of `jsonwebtoken`; use `jwt-decode` or `/auth/me` for client-side identity.
### Database And Migrations
- Do not add indexes speculatively. Add indexes after checking query patterns or explaining the expected query.
- Migrations must be reversible or include an explicit rollback/backup plan.
- Do not drop columns/tables in the same change that stops using them unless explicitly requested and production data safety is covered.
- Do not normalize `ui_schema_json` into new tables unless there is a concrete current need; prefer validation/extraction helpers first.
- Do not rewrite, rename, or reformat already applied backend migration files. Add new migrations only for new schema changes.
## Disabled Features
The following features are implemented but currently disabled with `false &&` conditions:
- **Navigation blocking while preloading** - Navigation buttons can be disabled until neighbor page backgrounds are preloaded. Currently disabled to allow instant navigation response. To re-enable, remove `false &&` in:
- `frontend/src/components/RuntimePresentation.tsx` (isForwardNavDisabled, handleElementClick)
- `frontend/src/pages/constructor.tsx` (handleElementClick, isNavDisabled calculation)
## Documentation Workflow
- **Before each task**: Research relevant documentation in `documentation/` to understand existing implementations, patterns, and conventions.
- **After each task**: Update affected documentation to reflect changes (API changes, new fields, modified workflows).
- **After implementing new features/modules**: Create new documentation file in `documentation/` following the existing format, and add reference to the Feature Documentation table in this file.
## Quick Start
```bash
# Terminal 1 - Backend (port 3000)
cd backend && npm run start-dev
# Terminal 2 - Frontend (port 3001)
cd frontend && npm run dev
```
## Common Commands
### Backend (run from `backend/` directory, runs on port 3000 in the default dev_stage flow)
```bash
npm install # Install dependencies
npm run start-dev # Start server (loads .env, migrates, seeds, watches)
npm run lint # ESLint check
npm run typecheck # Strict TypeScript check for migrated backend scope
npm run test # Unit tests (Node test runner)
npm run test:integration # Integration tests; DB tests skip when Postgres is unavailable
npm run test:e2e # E2E HTTP tests with a local listener
npm run test:all # Unit, integration, and e2e suites
npm run build # Compile migrated TypeScript files
npm run db:migrate # Run migrations only
npm run db:migrate:undo # Undo last migration
npm run db:seed # Seed database only
npm run db:reset # Drop, create, migrate, and seed
```
**Creating new migrations:** Add new migration files deliberately under `backend/src/db/migrations/` only when a schema change is required. Keep them reversible and preserve already applied migration files unchanged.
**Note:** Backend `.env` is loaded centrally by `backend/src/load-env.ts` for app and DB entrypoints. If `NODE_ENV` is absent, it defaults to `dev_stage`, matching the standard VM backend flow and using the `.env` DB settings. Do not add `NODE_ENV=production` to local startup unless a task explicitly requires the production config.
The backend defaults to port `3000` in `dev_stage` and `8080` otherwise; set `PORT` explicitly when a task needs a different port.
### Frontend (run from `frontend/` directory)
```bash
npm install # Install dependencies
npm run dev # Start dev server with Turbopack (port 3001)
npm run typecheck # TypeScript check without production build
npm run verify # Typecheck, lint, and production build
npm run build # Production build
npm run lint # ESLint check (.ts, .tsx files)
npm run format # Format code with Prettier
```
### Standard VM Environment
The standard VM port split is:
- **Frontend PM2 app `frontend-dev`**: Next.js production server on port `3001`
- **Backend PM2 app `backend-dev`**: `npm run start` with `NODE_ENV=dev_stage` on port `3000`
- **Apache**: public entrypoint and reverse proxy on port `80`
Direct VM backend health checks should target `http://127.0.0.1:3000/api/...`. A protected endpoint returning `401 Unauthorized` without JWT means the backend is reachable.
### Docker (run from `docker/` directory)
```bash
chmod +x start-backend.sh && chmod +x wait-for-it.sh # First time setup
docker-compose up # Start all services
rm -rf data && docker-compose up # Start with fresh database
```
## Architecture
### Backend Structure (`backend/src/`)
- **routes/**: Express route handlers (RESTful endpoints)
- **db/api/**: Database access layer (CRUD operations per model)
- **db/models/**: Sequelize model definitions
- **db/migrations/**: Database migrations
- **db/seeders/**: Seed data
- **auth/**: Passport.js authentication (JWT, Google OAuth, Microsoft OAuth)
- **services/**: Business logic services (emails, notifications, publishing, file storage)
- **factories/**: Router and service generation (`router.factory.js`, `service.factory.js`)
- **middlewares/**: Permission checking, runtime context handling
- **helpers/**: Utility functions (e.g., `wrapAsync` for async error handling)
### Frontend Structure (`frontend/src/`)
- **pages/**: Next.js pages (each entity has `-list`, `-edit`, `-new`, `-view`, `-table` variants)
- **components/**: React components (PascalCase naming)
- **stores/**: Redux Toolkit slices (per entity + auth, main, style slices)
- **layouts/**: Page layouts (Authenticated, Guest)
- **hooks/**: Global custom hooks (`usePreloadOrchestrator`, `useNeighborGraph`, etc.)
- **lib/**: Utility libraries (`elementStyles`, `imagePreDecode`, `mediaDuration`, `parseJson`)
- **types/**: TypeScript type definitions (`constructor.ts`, `runtime.ts`, `preload.ts`)
- **helpers/**: Utility functions
- **styles/**: Global CSS with Tailwind (`_theme.css` uses `@apply`)
### Key Domain Models
Projects, Tour Pages, Assets, Asset Variants, Permissions, Roles, Users, Publish Events, PWA Caches, Element Type Defaults, Project Element Defaults
**Note:** Page elements, navigation links, and transitions are stored directly in `tour_pages.ui_schema_json`.
### Element Defaults Hierarchy
The system has a two-level defaults cascade for UI elements:
1. **element_type_defaults** - Global platform-wide defaults (11 predefined types)
2. **project_element_defaults** - Project-specific overrides (auto-snapshotted from global on project creation)
When creating new elements, defaults cascade: global → project. Instance-specific settings are stored in `tour_pages.ui_schema_json`.
### Special Pages
- **`constructor.tsx`**: Tour builder/editor with drag-drop, element positioning, page management
- **`runtime.tsx`**: Tour playback with transitions, preloading, navigation
- **`p/[slug].tsx`**: Public tour pages with PWA offline support
### State Management
- **Redux Toolkit is the default only for client/app state** (auth/session UI, style/theme, layout/sidebar, constructor UI state, app preferences)
- **TanStack Query is the default for server state** (API reads, entity lists/details, mutations, invalidation, background refetch)
- Do not create new entity CRUD Redux slices in `stores/[entity]/[entity]Slice.ts`.
- Core slices: `authSlice.ts` (auth), `mainSlice.ts` (UI), `styleSlice.ts` (theming), `constructorSlice.ts` (editor)
- Use `useAppSelector` and `useAppDispatch` hooks from `stores/hooks.ts`
- **Local hooks acceptable** for ephemeral, component-scoped state (e.g., `usePageNavigation` for session-scoped page history)
- See [stores-module.md](frontend/docs/stores-module.md#state-management-guidelines) for decision tree
## Code Conventions
### Backend
- ES6+ with arrow functions, const/let
- Document endpoints with Swagger JSDoc comments
- Lowercase filenames (e.g., `auth.js`, `projects.js`)
### Frontend
- Functional components with TypeScript
- PascalCase for components and types, camelCase for variables/functions
- Custom hooks prefixed with `use` (e.g., `useAuth`)
- Tailwind CSS with theme customization in `_theme.css`
### Styling
- Theme customization uses `@apply` directive in `_theme.css`
- **Sidebar styles**: Target `#asideMenu` (defined in `AsideMenuLayer.tsx`) for sidebar overrides
- Use highly specific selectors when overriding Tailwind utilities to avoid conflicts
- Themed blocks (`.theme-pink`, `.theme-green`) standardize UI appearance - ensure custom overrides integrate cleanly
### Error Handling
- Backend: Use centralized `commonErrorHandler` middleware for uniform error responses
- Frontend: Use React error boundaries for runtime errors
## API Patterns
Routes follow pattern: `/api/[entity]/` with standard CRUD operations
- `POST /api/auth/signin/local` - Login
- `GET /api/auth/me` - Current user (requires JWT)
- Self-registration is disabled. New users are created by Administrator, Platform Owner, or Account Manager through the Users flow and receive an invitation/setup link.
All entity routes support: list, findOne, create, update, destroy operations with pagination and filtering.
## Backend Factory Patterns
### Router Factory (`factories/router.factory.js`)
Generates standard CRUD routes for entities:
```javascript
module.exports = createEntityRouter('assets', AssetsService, AssetsDBApi, {
permissionEntity: 'assets',
});
```
### Service Factory (`factories/service.factory.js`)
Generates service classes with transaction handling:
```javascript
module.exports = createEntityService('assets', AssetsDBApi);
```
### Base DB API (`db/api/base.api.js`)
All entity APIs extend `GenericDBApi` and override:
- `MODEL` - Sequelize model reference
- `SEARCHABLE_FIELDS` - Fields for text search
- `RANGE_FIELDS` - Fields for range filtering
- `ENUM_FIELDS` - Fields for exact match filtering
- `getFieldMapping(data)` - Transform input data before save
## File Storage
Controlled by `FILE_STORAGE_PROVIDER` env var or auto-detected from credentials:
- `s3` - AWS S3 (requires `S3_BUCKET`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`)
- `gcloud` - Google Cloud Storage
- `local` - Local filesystem (default)
## Publishing Workflow
Three-tier **content environment** model (distinct from `NODE_ENV` server environment):
- **Dev** (constructor editing) → **Stage** (preview) → **Production** (public)
**Route-based environment access:**
- `/p/[slug]` - Production presentation
- `/p/[slug]/stage` - Stage presentation
- `/constructor?projectId=` - Dev editing (always dev environment)
Key endpoints:
- `POST /api/publish/save-to-stage` - Dev → Stage (body: `{ projectId }`)
- `POST /api/publish` - Stage → Production (body: `{ projectId }`)
**Environment isolation:** Pages have `environment` field (`dev`, `stage`, `production`). The `X-Runtime-Environment` header (set by frontend) determines which content environment to query. Both frontend (`RuntimePresentation.tsx`) and backend (`runtime-context.js`) filter by this field.
**Server Environment vs Content Environment:**
- `NODE_ENV` controls database config (production/dev_stage/development)
- `tour_pages.environment` controls content visibility (dev/stage/production)
### Private Production Presentations
Production presentations are public by default at `/p/[slug]`. Each project can switch production runtime visibility through `projects.production_presentation_visibility` (`public` default, `private` optional).
- Platform staff users with any RBAC permission can visit every private production presentation
- Customer users with `Public` role can visit only private production presentations granted in `production_presentation_access`
- `Administrator`, `Platform Owner`, and `Account Manager` can create users
- When creating or editing a `Public` user, the form shows a selector for private production presentations and saves DB access grants
- Do not use config files or env vars for customer allowlists
- Do not grant customer viewer users broad permissions such as `READ_PROJECTS` or `READ_TOUR_PAGES`; any RBAC permission makes the user platform staff for private presentation access
See [private-production-presentations.md](documentation/private-production-presentations.md).
## PWA & Offline Support
- **Service Worker**: Generated by Serwist from `frontend/src/sw.ts``public/sw.js`
- **Offline Caching**: PWA_Caches model tracks cached assets per project
- **Runtime Mode**: Middleware distinguishes public vs authenticated access for offline tours
## Asset Preloading Architecture
Runtime presentations use direct S3 downloads via presigned URLs for instant page navigation:
```
1. Request presigned URLs (max 50 per batch, 1-hour expiry)
POST /api/file/presign { urls: ["assets/img.jpg", ...] }
2. Download directly from S3 → Store in Cache API (< 5MB) or IndexedDB ( 5MB)
3. Create blob URL → Decode image → Store in readyBlobUrlsRef
4. Instant lookup during navigation (O(1))
const blobUrl = preloadOrchestrator.getReadyBlobUrl(originalUrl);
```
**Preload Priority:** Transition videos (+150) > Images (+100) > Audio (+50) > Video (+30)
**Storage Layers:**
| Layer | Size Limit | Purpose |
|-------|------------|---------|
| Cache API | < 5MB | Fast asset storage |
| IndexedDB (Dexie) | ≥ 5MB | Large assets, offline data |
| Blob URLs | Memory | Pre-decoded for instant display |
## Frontend Patterns
### MUI X Data Grid v7
Use new `valueGetter` signature:
```typescript
// For value transformation
valueGetter: (value) => value?.id ?? value
// For row access
valueGetter: (_value, row) => new Date(row.created_at)
```
### Custom Hooks
Hooks are located in three places:
- **`src/hooks/`**: Global hooks (`usePreloadOrchestrator`, `usePageNavigationState`, `useTransitionPlayback`, `usePageNavigation`)
- **`src/hooks/video/`**: Video playback primitives (8 composable hooks for video playback scenarios)
- **Component directories**: Domain-specific hooks (`components/Assets/useAssetUploader.ts`)
Key runtime hooks:
| Hook | Purpose |
|------|---------|
| `usePreloadOrchestrator` | Stream-first asset preloading (current page + transitions only) |
| `usePageNavigationState` | Unified navigation state machine (replaces 6+ hooks) |
| `useTransitionPlayback` | Video transition playback coordination |
| `usePageNavigation` | Page history tracking with browser-like back behavior |
| `useCanvasScale` | Responsive canvas scaling with letterbox mode |
| `useNetworkAware` | Network condition monitoring for adaptive transitions |
| `useBackgroundDimensionSuggestion` | Detect background media dimensions for canvas size suggestions |
**Video Hooks (`src/hooks/video/`):**
| Hook | Purpose |
|------|---------|
| `useVideoBlobUrl` | Resolve video URLs to blob URLs from preload cache |
| `useVideoPlaybackCore` | Core playback logic combining multiple primitives |
| `useVideoPlayer` | Complete UI video player hook |
### Redux Entity Pattern
Use this pattern only when maintaining an existing Redux entity flow; new server-state code should use TanStack Query hooks instead.
```typescript
import { fetch, deleteItem } from '../../stores/assets/assetsSlice';
const dispatch = useAppDispatch();
dispatch(fetch({ query: '?limit=100' }));
```
## Feature Documentation
Detailed feature documentation is available in `documentation/`:
| Document | Description |
|----------|-------------|
| [project-architecture.md](documentation/project-architecture.md) | Overall project structure and architecture |
| [api-reference.md](documentation/api-reference.md) | API endpoints reference |
| [authentication-system.md](documentation/authentication-system.md) | JWT, OAuth (Google, Microsoft) authentication |
| [rbac-system.md](documentation/rbac-system.md) | Role-based access control system |
| [user-management.md](documentation/user-management.md) | User CRUD, roles assignment, account management |
| [project-memberships.md](documentation/project-memberships.md) | Team collaboration, per-project access control |
| [asset-upload-variants.md](documentation/asset-upload-variants.md) | Asset upload pipeline and variant generation |
| [ui-elements.md](documentation/ui-elements.md) | UI Elements stored in `ui_schema_json` (buttons, hotspots, galleries, tooltips, media players) |
| [page-transitions.md](documentation/page-transitions.md) | Video transitions stored on navigation elements |
| [project-transition-settings.md](documentation/project-transition-settings.md) | Environment-aware CSS transition settings (fade, duration, easing) |
| [video-playback.md](documentation/video-playback.md) | Video player implementation, iOS autoplay compatibility |
| [assets-preloading.md](documentation/assets-preloading.md) | Asset preloading and caching strategy |
| [publishing-workflow.md](documentation/publishing-workflow.md) | Dev → Stage → Production publishing |
| [private-production-presentations.md](documentation/private-production-presentations.md) | Private production presentation allowlist, viewer users, and runtime access flow |
| [offline-pwa-mode.md](documentation/offline-pwa-mode.md) | PWA offline capabilities and caching |
| [email-notification-service.md](documentation/email-notification-service.md) | Nodemailer/SES integration, verification emails, invitations |
| [search-system.md](documentation/search-system.md) | Global full-text search, permission-based filtering |
| [deployment-vm.md](documentation/deployment-vm.md) | Standard VM deployment topology, PM2 recovery, ports, OOM/ffmpeg diagnostics |
| [custom-domains-apache.md](documentation/custom-domains-apache.md) | Customer-owned presentation domains via Apache, DNS A records, Certbot, and host/path routing |
| [page-links-navigation.md](documentation/page-links-navigation.md) | Page navigation using `targetPageSlug` in elements |
| [access-logs-audit-trail.md](documentation/access-logs-audit-trail.md) | Access logging, audit trail, activity tracking |
| [db-cleanup-audit.md](documentation/db-cleanup-audit.md) | Non-destructive DB cleanup audit, orphan checks, legacy schema checks, and soft-delete retention policy |
| [global-ui-controls.md](documentation/global-ui-controls.md) | Configurable fullscreen, sound, and offline system controls with global/project/page cascade |
| [backend/database-schema.md](backend/docs/database-schema.md) | Complete database schema - all models, fields, relationships, indexes, constraints |
| [backend/backend-architecture.md](backend/docs/backend-architecture.md) | Backend architecture - layers, design patterns, middleware, factories, file storage |
| [backend/api-endpoints.md](backend/docs/api-endpoints.md) | Complete API reference - all endpoints, request/response formats, authentication, rate limits |
| [backend/modules/core.md](backend/docs/modules/core.md) | Core module - index.js (entry), config.ts (configuration), helpers.js/utilities |
| [backend/modules/auth.md](backend/docs/modules/auth.md) | Auth module - Passport.js strategies (JWT, Google, Microsoft), login, invitation setup, password reset, email verification |
| [backend/modules/middleware.md](backend/docs/modules/middleware.md) | Middleware module - rate limiting, permissions, runtime context, public access control, file uploads |
| [backend/modules/routes.md](backend/docs/modules/routes.md) | Routes module - 22 route files, factory pattern, CRUD endpoints, Swagger docs, auth/file/search/sql routes |
| [backend/modules/services.md](backend/docs/modules/services.md) | Services module - business logic layer, 34 service files, factory pattern, file storage (S3/GCloud/Local), email, notifications, publishing |
| [backend/modules/email.md](backend/docs/modules/email.md) | Email module - Nodemailer/AWS SES, transactional emails, HTML templates, token management, email verification, password reset, invitations |
| [backend/modules/notifications.md](backend/docs/modules/notifications.md) | Notifications module - error classes (ValidationError, ForbiddenError), i18n message catalog, helper functions |
| [backend/modules/factories.md](backend/docs/modules/factories.md) | Factories module - createEntityRouter and createEntityService functions, code generation patterns for CRUD operations, boilerplate elimination |
| [backend/modules/db-models.md](backend/docs/modules/db-models.md) | DB Models module - 16 Sequelize models, entity definitions, associations, validations, lifecycle hooks, soft delete patterns |
| [backend/modules/db-api.md](backend/docs/modules/db-api.md) | DB API module - GenericDBApi base class, 18 entity APIs, declarative configuration, query filtering, runtime context helpers |
| [backend/modules/db-migrations.md](backend/docs/modules/db-migrations.md) | DB Migrations module - Umzug runner, migration safety rules, schema evolution, data backfill |
| [backend/modules/db-seeders.md](backend/docs/modules/db-seeders.md) | DB Seeders module - Umzug seeders, RBAC setup (7 roles, 54 permissions), sample data opt-in |
| [backend/modules/db-config.md](backend/docs/modules/db-config.md) | DB Config module - typed DB config, environment validation (Joi), Umzug commands, sync/reset scripts |
| [backend/modules/utilities.md](backend/docs/modules/utilities.md) | Utilities module - error classes, Pino logging, env validation, request helpers (wrapAsync, commonErrorHandler), DB utils, i18n messages |
| [backend/testing.md](backend/docs/testing.md) | Backend test strategy - unit, integration, and e2e coverage, helpers, commands, and environment notes |
| [frontend/hooks-reference.md](frontend/docs/hooks-reference.md) | React hooks reference (useFormSync, useEntityTable, useOfflineMode, etc.) |
| [frontend/video-hooks-module.md](frontend/docs/video-hooks-module.md) | Video playback primitive hooks (8 composable hooks for video scenarios) |
| [frontend/constructor-page-editor.md](frontend/docs/constructor-page-editor.md) | Constructor page editor - visual tour builder with drag-drop elements |
| [frontend/runtime-presentation.md](frontend/docs/runtime-presentation.md) | Runtime presentation viewer - full-screen tour playback with transitions |
| [frontend/navigation-smooth-transitions.md](frontend/docs/navigation-smooth-transitions.md) | Navigation & smooth transitions - page switching, transition video playback, last frame preservation, online/offline modes |
| [frontend/ui-adaptivity-system.md](frontend/docs/ui-adaptivity-system.md) | UI Adaptivity System - canvas units (--cu), responsive scaling, letterbox mode, element styling |
| [frontend/ui-element-preloading-analysis.md](frontend/docs/ui-element-preloading-analysis.md) | UI element processing & neighbor preloading - deep analysis of asset extraction, preload flow, offline caching |
| [frontend/asset-upload-preloading-pwa-analysis.md](frontend/docs/asset-upload-preloading-pwa-analysis.md) | Deep analysis of asset upload, preload orchestrator, PWA/offline mode, storage layers, network awareness |
| [frontend/frontend-architecture.md](frontend/docs/frontend-architecture.md) | Frontend architecture - 386 files, 14 modules, factories, hooks, Redux, PWA/offline, design patterns |
| [frontend/pages-module.md](frontend/docs/pages-module.md) | Pages module - 99 pages, _app.tsx entry, entity CRUD pattern, constructor, runtime presentations, layouts |
| [frontend/components-module.md](frontend/docs/components-module.md) | Components module - 183 files, entity tables, factories, constructor, element settings, runtime, PWA/offline |
| [frontend/hooks-module.md](frontend/docs/hooks-module.md) | Hooks module - 52 custom hooks, runtime/preloading, PWA/offline, constructor, tables/forms, UI utilities |
| [frontend/stores-module.md](frontend/docs/stores-module.md) | Stores module - Redux Toolkit, 17 slices (4 core + 13 entity), createEntitySlice factory, typed hooks |
| [frontend/lib-module.md](frontend/docs/lib-module.md) | Lib module - 19 utility files, asset URL management, element defaults/styles/effects, offline storage (Cache API + IndexedDB), download queue |
| [frontend/types-module.md](frontend/docs/types-module.md) | Types module - 18 TypeScript definition files, entity types, constructor/runtime types, API/Redux types, permissions enum, offline/preload types |
| [frontend/factories-module.md](frontend/docs/factories-module.md) | Factories module - 5 factory files (~1,285 LOC), createListPage, createFormPage, createTableComponent, configBuilderFactory, createEntitySlice for 91% boilerplate reduction |
| [frontend/schemas-module.md](frontend/docs/schemas-module.md) | Schemas module - 6 Zod validation schema files (~257 LOC), form validation for User, Asset, Project, TourPage, Role entities with type inference |
| [frontend/helpers-module.md](frontend/docs/helpers-module.md) | Helpers module - 6 utility files (~304 LOC), dataFormatter (entity display), hasPermission (RBAC), notifyStateHandler (Redux), text formatters, file saver |
| [frontend/layouts-module.md](frontend/docs/layouts-module.md) | Layouts module - 2 layout files (~262 LOC), LayoutAuthenticated (JWT auth, permissions, UI chrome), LayoutGuest (public pages), getLayout pattern |
| [frontend/config-module.md](frontend/docs/config-module.md) | Config module - 12 config files (~523 LOC), Next.js/Tailwind/TypeScript build config, runtime settings (config.ts), menu configs, offline/preload configs, ESLint rules |
| [frontend/context-module.md](frontend/docs/context-module.md) | Context module - DownloadContext (~256 LOC) for PWA download progress state, DownloadEventBus integration, useDownloadContext/useDownloadContextOptional hooks |
| [frontend/interfaces-module.md](frontend/docs/interfaces-module.md) | Interfaces module - ~15 type definition files (~2,200+ LOC), entity types, API contracts, Redux state shapes, permissions enum, constructor/runtime/offline/preload interfaces |

View File

@ -100,6 +100,15 @@ After seeding, use the credentials configured in backend environment/config valu
└── README.md # Docker documentation
```
## Documentation
- [General feature documentation](documentation/project-architecture.md) - platform architecture and cross-cutting features
- [API reference](documentation/api-reference.md) - public API overview and workflow-level endpoint notes
- [Backend documentation](backend/docs/backend-architecture.md) - backend architecture, modules, database, and tests
- [Backend API endpoints](backend/docs/api-endpoints.md) - backend endpoint reference
- [Frontend documentation](frontend/docs/frontend-architecture.md) - frontend architecture, pages, components, hooks, and runtime flows
- [VM deployment](documentation/deployment-vm.md) - standard VM topology, ports, PM2, Apache, and diagnostics
## Key Workflows
### Tour Creation

View File

@ -53,6 +53,15 @@ npm run check:public-access
non-Public private production presentation grants. Review its output before
running `npm run fix:public-access`.
## Documentation
- [Backend architecture](docs/backend-architecture.md) - service boundaries, app bootstrap, middleware, and factories
- [API endpoints](docs/api-endpoints.md) - backend REST API reference
- [Database schema](docs/database-schema.md) - models, relationships, indexes, and constraints
- [Testing strategy](docs/testing.md) - unit, integration, and e2e test notes
- [Backend modules](docs/modules/core.md) - module-level documentation entry point
- [VM deployment](../documentation/deployment-vm.md) - standard VM ports, PM2, Apache, and backend health checks
## Environment Variables
Create a `.env` file in the backend directory:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,681 @@
# Backend Architecture Documentation
This document provides a comprehensive analysis of the Tour Builder Platform backend architecture, including design patterns, layers, and implementation details.
## Overview
- **Runtime**: Node.js 24 LTS
- **Framework**: Express.js 4.x
- **ORM**: Sequelize 6.x with PostgreSQL
- **Authentication**: Passport.js (JWT, Google OAuth, Microsoft OAuth)
- **Documentation**: Swagger/OpenAPI 3.0
- **Logging**: Pino (structured JSON logging)
- **TypeScript/ESM**: the backend package is `"type": "module"` and active
backend source is strict TypeScript ESM.
- **File Storage**: AWS S3 / Local filesystem (Strategy Pattern, provider-based)
---
## Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────────────┐
│ Express Application │
│ (src/index.ts) │
└──────────────────────────────────────────────────────────────────────────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌────────────────────────┐ ┌────────────────┐ ┌────────────────────────┐
│ Middleware │ │ Rate Limiter │ │ Request Logger │
│ • runtimeContext │ │ • auth (10/15m)│ │ • Pino structured │
│ • runtimePublic │ │ • api (100/1m) │ │ • Request ID tracking │
│ • checkPermissions │ │ • upload (10/m)│ │ • Duration metrics │
│ • passport JWT │ │ • download(200)│ └────────────────────────┘
└────────────────────────┘ │ • search (30/m)│
│ • AI (20/1m) │
└────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ Routes Layer │
│ (src/routes/*.ts) │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Factory-generated routes: router.factory.js │ │
│ │ • Standard CRUD: POST /, PUT /:id, DELETE /:id, GET /, GET /:id │ │
│ │ • Extra: GET /count, GET /autocomplete, POST /deleteByIds │ │
│ │ • CSV export: GET /?filetype=csv │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Custom routes: auth, file, publish, search, sql, runtime │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ Service Layer │
│ (src/services/*.js) │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Factory-generated services: service.factory.js │ │
│ │ • Transaction-wrapped CRUD operations │ │
│ │ • Delegates to DB API layer │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Custom services: │ │
│ │ • auth.js - Authentication logic (signin, signup, password) │ │
│ │ • publish.ts - Publishing workflow service (dev→stage→production)│ │
│ │ • file.ts - File storage operations │ │
│ │ • email/index.js - Email sending via Nodemailer/SES │ │
│ │ • search.ts - Full-text search route │ │
│ │ • pwa_manifest.js - PWA offline manifest generation │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ Database API Layer │
│ (src/db/api/*.js) │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ GenericDBApi (base.api.js) - Template Method Pattern │ │
│ │ • Configurable: MODEL, SEARCHABLE_FIELDS, RANGE_FIELDS, etc. │ │
│ │ • CRUD: create, update, remove, deleteByIds │ │
│ │ • Query: findBy, findAll, findAllAutocomplete │ │
│ │ • Data Transform: getFieldMapping(), JSON_FIELDS, FIELD_DEFAULTS │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Entity APIs extend GenericDBApi: │ │
│ │ UsersDBApi, ProjectsDBApi, AssetsDBApi, TourPagesDBApi, etc. │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ Sequelize Models │
│ (src/db/models/*.ts) │
│ Entity models + file model, loaded dynamically via loader.ts │
└──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ PostgreSQL │
└──────────────────────────────────────────────────────────────────────────┘
```
---
## Directory Structure
```
backend/src/
├── index.ts # Application entry point
├── config.ts # Environment configuration
├── helpers.ts # Utility functions (wrapAsync, JWT, UUID validation)
├── types/ # Reusable strict TypeScript contracts for migrated code
├── auth/
│ └── auth.ts # Passport strategies (JWT, Google, Microsoft)
├── middlewares/
│ ├── check-permissions.ts # RBAC permission checking
│ ├── runtime-context.ts # Runtime environment context (dev/stage/production)
│ ├── runtime-public.ts # Public runtime access control & field sanitization
│ ├── rateLimiter.ts # Rate limiting (auth, API, upload, download)
│ └── upload.ts # File upload handling (multer)
├── routes/
│ ├── auth.ts # Authentication routes (custom)
│ ├── file.ts # File upload/download routes (custom)
│ ├── publish.ts # Publishing workflow routes (custom)
│ ├── search.ts # Full-text search routes (custom)
│ ├── runtime-context.ts # Runtime context detection (custom)
│ └── [entity].ts # Entity CRUD routes
├── services/
│ ├── auth.ts # Authentication service
│ ├── publish.ts # Publishing workflow (dev→stage→production)
│ ├── file.ts # Unified file storage service
│ ├── search.ts # Full-text search service
│ ├── file/ # File storage providers (S3, Local)
│ │ ├── index.ts # Module exports & provider factory
│ │ ├── BaseStorageProvider.ts # Abstract base class
│ │ ├── S3StorageProvider.ts # AWS S3 implementation
│ │ ├── LocalStorageProvider.ts # Local filesystem implementation
│ │ └── UploadSessionManager.ts # Chunked upload session management
│ ├── email/
│ │ ├── index.ts # Email sender (Nodemailer/SES)
│ │ └── list/ # Email templates
│ ├── notifications/
│ │ ├── helpers.ts # Notification helpers
│ │ └── errors/ # Error classes (ValidationError, ForbiddenError)
│ └── [entity].ts # Entity services
├── factories/
│ ├── router.factory.ts # Route generator (createEntityRouter)
│ └── service.factory.ts # Service generator (createEntityService)
├── db/
│ ├── db-config.ts # Database configuration (env-based)
│ ├── umzug.ts # Migration and seeder runner
│ ├── models/
│ │ ├── index.ts # Model registry entrypoint
│ │ ├── loader.ts # Model loader
│ │ └── [entity].ts # Sequelize models
│ ├── api/
│ │ ├── base.api.ts # GenericDBApi base class
│ │ ├── runtime-context.ts # Runtime filtering helpers
│ │ └── [entity].ts # Entity DB APIs
│ ├── migrations/ # Applied migration history
│ └── seeders/ # Typed seed data files
└── utils/
├── index.ts # Utils barrel export
├── errors.ts # Error classes (AppError, NotFoundError, etc.)
├── logger.ts # Pino logger configuration
└── env-validation.ts # Environment variable validation
```
---
## Design Patterns
### 1. Factory Pattern
**Router Factory** (`factories/router.factory.js`)
Generates standardized CRUD routes for entities:
```javascript
const { createEntityRouter } = require('../factories/router.factory');
// Creates routes: POST /, PUT /:id, DELETE /:id, GET /, GET /:id, GET /count, GET /autocomplete
module.exports = createEntityRouter('assets', AssetsService, AssetsDBApi, {
permissionEntity: 'assets',
csvFields: ['id', 'name', 'asset_type', 'createdAt'],
});
```
**Service Factory** (`factories/service.factory.js`)
Generates transaction-wrapped service classes:
```javascript
const { createEntityService } = require('../factories/service.factory');
// Creates: create(), update(), remove(), deleteByIds(), bulkImport()
module.exports = createEntityService(AssetsDBApi, { entityName: 'Asset' });
```
### 2. Template Method Pattern
**GenericDBApi** (`db/api/base.api.js`)
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; }
// 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' }]; }
// Custom field transformation
static getFieldMapping(data) {
return {
name: data.name || null,
asset_type: data.asset_type || null,
type: data.type || 'general',
// ...
};
}
}
```
### 3. Strategy Pattern
**File Storage Providers** (`services/file/`)
Two concrete implementations with pluggable architecture:
```
BaseStorageProvider (abstract)
├── S3StorageProvider # AWS S3 implementation (with timeout/retry)
└── LocalStorageProvider # Local filesystem implementation
```
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`
- `deleteMany(keys)``void`
- `exists(key)``boolean`
- `list(prefix)``string[]`
- `getSignedUrl(key, expiresIn)``string`
### 4. Middleware Chain Pattern
Request flow through middleware stack:
```
Request → requestLogger → runtimeContextMiddleware → rateLimiter
→ passport.authenticate('jwt') → checkCrudPermissions
→ Route Handler → Service → DB API → Database
→ Response
```
---
## Layers in Detail
### Entry Point (`src/index.ts`)
Application bootstrap:
1. **Security**: Helmet, CORS configuration
2. **Logging**: Request logger middleware (Pino)
3. **Authentication**: Passport JWT initialization
4. **Rate Limiting**: Per-route rate limiters
5. **Body Parsing**: JSON (1mb limit), applied after file routes
6. **Runtime Context**: Environment detection middleware
7. **Route Mounting**: Entity routes with auth/permissions
8. **Error Handling**: Generic error handler
9. **Static Files**: Public directory serving
```javascript
// Key route mounting patterns
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
blockNonPublicRuntimeListEndpoints, // Block non-list endpoints
sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields
router
);
};
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes);
mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes);
```
### Routes Layer
**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 |
**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 |
### Service Layer
**Transaction Management**: Services wrap operations in transactions:
```javascript
static async create({ data, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
const ownsTransaction = !externalTransaction;
try {
const record = await DBApi.create({ data, currentUser, transaction, runtimeContext });
if (ownsTransaction) await transaction.commit();
return record;
} catch (error) {
if (ownsTransaction) await transaction.rollback();
throw error;
}
}
```
**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
### Database API Layer
**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` |
---
## Authentication & Authorization
### Authentication Flow
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ POST /signin │ │ Passport JWT │ │ Protected │
│ → JWT Token │────▶│ Middleware │────▶│ Route │
└─────────────────┘ └─────────────────┘ └─────────────────┘
req.currentUser = {
id, email, app_role,
custom_permissions
}
```
### Authorization (RBAC)
**Permission Check Flow** (`middlewares/check-permissions.ts`):
1. Self-access bypass (user accessing own resource)
2. Check custom permissions (user-specific)
3. Check role permissions (from app_role)
4. Fallback to Public role for unauthenticated
**Permission Naming Convention**:
- `CREATE_<ENTITY>` - Create records
- `READ_<ENTITY>` - Read records
- `UPDATE_<ENTITY>` - Modify records
- `DELETE_<ENTITY>` - Delete records
```javascript
// Auto-generated from HTTP method
const permissionName = `${METHOD_MAP[req.method]}_${name.toUpperCase()}`;
// POST /api/assets → CREATE_ASSETS
// GET /api/assets → READ_ASSETS
```
### Runtime Public Access
For production content accessible without authentication:
```javascript
const requireRuntimeReadOrAuth = (req, res, next) => {
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 jwtAuth(req, res, next); // Require JWT
};
```
---
## Rate Limiting
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 |
Headers returned:
- `X-RateLimit-Limit`: Maximum requests
- `X-RateLimit-Remaining`: Remaining requests
- `X-RateLimit-Reset`: Reset time (ISO timestamp)
- `Retry-After`: Seconds until reset (when limited)
---
## File Storage
**Storage Provider Selection**:
```javascript
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 |
**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
---
## Error Handling
**Error Classes** (`utils/errors.js`):
```javascript
class AppError extends Error {
constructor(message, statusCode = 500, details = null) {
super(message);
this.statusCode = statusCode;
this.details = details;
this.isOperational = true;
}
}
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`):
```javascript
// Wraps async route handlers to catch errors
static wrapAsync(fn) {
return function(req, res, next) {
fn(req, res, next).catch(next);
};
}
```
**Common Error Handler** (`helpers.ts`):
```javascript
static commonErrorHandler(error, req, res, _next) {
const statusCode = error.code || error.status;
if ([400, 401, 403, 404, 409, 422].includes(statusCode)) {
return res.status(statusCode).send(error.message);
}
console.error(error);
return res.status(500).send('Internal server error');
}
```
---
## Logging
**Pino Logger** (`utils/logger.js`):
Logger initialization is a bootstrap exception: it may read `process.env`
directly because importing `config.ts` would create an initialization cycle.
```javascript
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: isDevelopment
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
base: {
service: 'tour-builder-api',
env: process.env.NODE_ENV || 'development',
},
});
```
**Request Logging**:
```javascript
function requestLogger(req, res, next) {
const requestId = req.headers['x-request-id'] || crypto.randomUUID();
req.log = logger.child({ requestId });
req.requestId = requestId;
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');
});
}
```
---
## Configuration
**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` |
**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 |
---
## API Documentation
Swagger/OpenAPI documentation is available at `/api-docs`.
The served document is centralized in `backend/src/openapi/document.ts`. The
OpenAPI module defines shared schemas, common parameters, reusable responses,
and generated standard CRUD paths for every `createEntityRouter` resource. This
keeps the documented factory contract aligned with the route factory endpoints:
create, bulk import, update, delete, bulk delete, list, count, autocomplete,
and get by ID.
```javascript
const specs = createOpenApiDocument({
serverUrl: config.server.swaggerServerUrl,
});
```
When adding a new route, update `backend/src/openapi/document.ts` in the same
change. For new factory-backed entities, add the entity schema and `CrudResource`
entry; for custom routes, add an explicit path item.
---
## Health Check
```javascript
GET /api/health
{
"status": "ok", // or "degraded"
"timestamp": "2026-03-29T...",
"uptime": 12345.678,
"environment": "production",
"database": "connected" // or "disconnected"
}
```
---
## 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 |
| `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 |
---
## Best Practices Implemented
1. **Factory Patterns** - Reduce boilerplate for CRUD operations
2. **Template Method** - Configurable base class for DB operations
3. **Strategy Pattern** - Pluggable storage providers
4. **Middleware Chain** - Composable request processing
5. **Transaction Management** - Consistent rollback on errors
6. **Rate Limiting** - Protection against abuse
7. **Structured Logging** - JSON logs with request IDs
8. **Environment-Based Config** - Secure credential handling
9. **Soft Deletes** - Paranoid models for data recovery
10. **RBAC** - Fine-grained permission control

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,942 @@
# Backend Auth Module Documentation
## Overview
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 |
---
## Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────────┐
│ Frontend/Client │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────────┐ │
│ │ Login Form │ │ Signup Form │ │ OAuth Buttons │ │
│ │ (email/pass) │ │ (email/pass) │ │ (Google/Microsoft) │ │
│ └───────┬────────┘ └───────┬────────┘ └───────────┬────────────┘ │
└──────────┼───────────────────┼───────────────────────┼───────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ Routes Layer (routes/auth.ts) │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Rate Limiters: │ │
│ │ • authLimiter (10 req/15min) → /signin/local │ │
│ │ • passwordResetLimiter (5 req/hour) → /send-password-reset │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────────────┐ │
│ │ POST /signin │ │ PUT /reset │ │ GET /signin/google │ │
│ │ /local │ │ │ │ GET /signin/microsoft │ │
│ └───────┬───────┘ └───────┬───────┘ └───────────┬───────────┘ │
│ │ │ │ │
└──────────┼──────────────────┼──────────────────────┼─────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ Service Layer (services/auth.ts) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Auth Class (Static Methods) │ │
│ │ • signup(email, password, options, host) │ │
│ │ • signin(email, password) │ │
│ │ • verifyEmail(token) │ │
│ │ • passwordUpdate(currentPassword, newPassword, options) │ │
│ │ • passwordReset(token, password) │ │
│ │ • sendEmailAddressVerificationEmail(email, host) │ │
│ │ • sendPasswordResetEmail(email, type, host) │ │
│ │ • updateProfile(data, currentUser) │ │
│ └────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ Passport Layer (auth/auth.ts) │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ JWT Strategy │ │ Google Strategy │ │ Microsoft Strategy │ │
│ │ (API Auth) │ │ (OAuth 2.0) │ │ (OAuth 2.0) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────────┘ │
│ │ │ │
│ └──────────┬───────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ socialStrategy() │ │
│ │ • findOrCreate │ │
│ │ • Generate JWT │ │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Database Layer (db/api/users.js) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ UsersDBApi (Static Methods) │ │
│ │ • findBy({ email }) │ │
│ │ • createFromAuth(data) │ │
│ │ • updatePassword(id, password) │ │
│ │ • generateEmailVerificationToken(email) │ │
│ │ • generatePasswordResetToken(email) │ │
│ │ • findByEmailVerificationToken(token) │ │
│ │ • findByPasswordResetToken(token) │ │
│ │ • markEmailVerified(id) │ │
│ └────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
---
## Authentication Strategies
### 1. JWT Strategy (Primary)
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 });
if (user && user.disabled) {
return done(new Error(`User '${user.email}' is disabled`));
}
req.currentUser = user;
return done(null, user);
})
);
```
**Token Structure:**
```javascript
{
user: {
id: "uuid",
email: "user@example.com"
},
iat: 1234567890, // Issued at
exp: 1234589490 // Expires in 6 hours
}
```
**Usage:**
```javascript
// Protect route with JWT
router.get('/me', passport.authenticate('jwt', { session: false }), handler);
// Access user in handler
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);
})
);
```
**Environment Variables:**
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
**OAuth Scopes:** `profile`, `email`
### 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);
})
);
```
**Environment Variables:**
| 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`
### Social Strategy Helper
Common logic for OAuth providers:
```javascript
function socialStrategy(email, profile, provider, done) {
db.users.findOrCreate({ where: { email, provider } }).then(([user]) => {
const body = {
id: user.id,
email: user.email,
name: profile.displayName,
};
const token = helpers.jwtSign({ user: body });
return done(null, { token });
});
}
```
---
## File Details
### 1. services/auth.ts
Core authentication business logic.
#### Class: Auth
```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)
}
```
#### Method: signup(email, password, options, host)
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
├── User exists but disabled → Error: userDisabled
└── User exists without authenticationUid → Update password
2. If new user:
├── Hash password (bcrypt, 12 rounds)
├── Create user via UsersDBApi.createFromAuth()
└── Assign default "User" role
3. Send verification email (if EmailSender configured)
4. Return signed JWT token
```
**Returns:** JWT token string
#### Method: signin(email, password)
Authenticates user with email and password.
**Flow:**
```
1. Find user by email
└── Not found → Error: userNotFound
2. Check if disabled
└── Disabled → Error: userDisabled
3. Verify password exists
└── No password → Error: wrongPassword
4. Check email verification
└── Not verified (and email configured) → Error: userNotVerified
5. Compare password with bcrypt
└── Mismatch → Error: wrongPassword
6. Return signed JWT token
```
**Returns:** JWT token string
#### Method: verifyEmail(token, options)
Verifies user email address using token.
**Flow:**
```
1. Find user by email verification token
└── Not found or expired → Error: invalidToken
2. Mark email as verified
3. Return true
```
#### Method: passwordUpdate(currentPassword, newPassword, options)
Updates password for authenticated user.
**Flow:**
```
1. Verify currentUser exists
└── Not authenticated → ForbiddenError
2. Verify current password matches
└── Mismatch → Error: wrongPassword
3. Verify new password is different
└── Same → Error: samePassword
4. Hash new password and update
```
#### Method: passwordReset(token, password, options)
Resets password using reset token.
**Flow:**
```
1. Find user by password reset token
└── Not found or expired → Error: invalidToken
2. Hash new password
3. Update user password
```
---
### 2. routes/auth.ts (327 lines)
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 |
#### POST /api/auth/signin/local
Login with email and password.
**Request:**
```json
{
"email": "user@example.com",
"password": "securepassword123"
}
```
**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 |
#### Self-Registration
Self-registration is disabled. `POST /api/auth/signup` is not registered.
New users are created through the authenticated Users flow and receive an
invitation/setup link.
#### GET /api/auth/me
Get current authenticated user.
**Headers:**
```
Authorization: Bearer <JWT_TOKEN>
```
**Response (200):**
```json
{
"id": "uuid",
"email": "user@example.com",
"firstName": "John",
"lastName": "Doe",
"emailVerified": true,
"disabled": false,
"app_role": {
"id": "uuid",
"name": "Administrator",
"permissions": [...]
},
"custom_permissions": [],
"avatar": [...],
"createdAt": "2024-01-01T00:00:00.000Z"
}
```
**Note:** Password field is omitted from response.
#### PUT /api/auth/password-reset
Reset password using token from email.
**Request:**
```json
{
"token": "abc123...",
"password": "newSecurePassword456"
}
```
**Response (200):**
```json
{ "success": true }
```
#### PUT /api/auth/password-update
Change password for authenticated user.
**Headers:**
```
Authorization: Bearer <JWT_TOKEN>
```
**Request:**
```json
{
"currentPassword": "oldPassword123",
"newPassword": "newPassword456"
}
```
**Errors:**
| 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 <JWT_TOKEN>
```
**Request:**
```json
{
"profile": {
"firstName": "John",
"lastName": "Smith",
"phoneNumber": "+1234567890"
}
}
```
#### 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}`
---
### 3. helpers.js (32 lines)
JWT and utility functions.
#### Method: jwtSign(data)
Signs JWT token with application secret.
```javascript
static jwtSign(data) {
return jwt.sign(data, config.secret_key, { expiresIn: '6h' });
}
```
**Configuration:**
| 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 |
---
### 4. db/api/users.js (Authentication Methods)
User database operations related to authentication.
#### Method: createFromAuth(data)
Creates new user during signup.
```javascript
static async createFromAuth(data, options) {
const users = await db.users.create({
email: data.email,
firstName: data.firstName,
authenticationUid: data.authenticationUid,
password: data.password,
}, { transaction });
// Assign default "User" role
const app_role = await db.roles.findOne({
where: { name: config.roles?.user || 'User' },
});
await users.setApp_role(app_role?.id);
return users;
}
```
#### Method: generateEmailVerificationToken(email)
Generates secure token for email verification.
```javascript
static async generateEmailVerificationToken(email, options) {
const token = crypto.randomBytes(20).toString('hex');
const tokenExpiresAt = Date.now() + (24 * 60 * 60 * 1000); // 24 hours
await users.update({
emailVerificationToken: token,
emailVerificationTokenExpiresAt: tokenExpiresAt,
});
return token;
}
```
**Token Properties:**
| 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`
#### Method: findByEmailVerificationToken(token)
Finds user by valid (non-expired) verification token.
```javascript
static async findByEmailVerificationToken(token, options) {
return db.users.findOne({
where: {
emailVerificationToken: token,
emailVerificationTokenExpiresAt: {
[Op.gt]: Date.now(),
},
},
});
}
```
#### Method: markEmailVerified(id)
Marks user email as verified.
```javascript
static async markEmailVerified(id, options) {
const users = await db.users.findByPk(id);
await users.update({ emailVerified: true });
return true;
}
```
---
## Rate Limiting
Authentication endpoints have dedicated rate limiters defined in `middlewares/rateLimiter.js`.
### Auth Limiter (Login)
```javascript
const authLimiter = createRateLimiter({
keyPrefix: 'auth',
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many authentication attempts. Please try again later.',
skipFailedRequests: false, // Count ALL attempts
});
```
| Setting | Value |
|---------|-------|
| Window | 15 minutes |
| Max Requests | 10 |
| Applied To | `/signin/local` |
### Signup Limiter
Self-registration is disabled, so no signup limiter is registered.
### Password Reset Limiter
```javascript
const passwordResetLimiter = createRateLimiter({
keyPrefix: 'password-reset',
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: 'Too many password reset requests. Please try again later.',
});
```
| Setting | Value |
|---------|-------|
| Window | 1 hour |
| Max Requests | 5 |
| Applied To | `/send-password-reset-email` |
### Rate Limit Response
```json
{
"error": "Too Many Requests",
"message": "Too many authentication attempts. Please try again later.",
"retryAfter": 300
}
```
**Headers:**
```
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 2024-01-01T00:15:00.000Z
Retry-After: 300
```
---
## Password Security
### Hashing Configuration
```javascript
// config.ts
bcrypt: {
saltRounds: 12,
}
```
| 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
---
## Email Integration
Email functionality is conditional based on configuration.
### Email Configuration Check
```javascript
if (EmailSender.isConfigured) {
await this.sendEmailAddressVerificationEmail(user.email, host);
}
```
When email is NOT configured:
- Signup succeeds without verification email
- Users are auto-verified on signin
- Password reset emails not sent
### Verification Email
**Email Class:** `EmailAddressVerificationEmail`
**Link Format:**
```
{host}/verify-email?token={token}
```
### Password Reset Email
**Email Classes:**
- `PasswordResetEmail` - Standard reset
- `InvitationEmail` - New user invitation
**Link Format:**
```
{host}/password-reset?token={token}
```
---
## Configuration Reference
### 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 |
### config.ts Settings
```javascript
{
bcrypt: { saltRounds: 12 },
secret_key: env.SECRET_KEY,
providers: {
LOCAL: 'local',
GOOGLE: 'google',
MICROSOFT: 'microsoft',
},
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
microsoft: {
clientId: env.MS_CLIENT_ID,
clientSecret: env.MS_CLIENT_SECRET,
},
roles: {
admin: 'Administrator',
user: 'Analytics Viewer',
},
}
```
---
## Authentication Flows
### Local Login Flow
```
┌────────┐ ┌─────────┐ ┌─────────────┐ ┌──────────┐
│ Client │────▶│ Router │────▶│ AuthService │────▶│ UsersDB │
└────────┘ └─────────┘ └─────────────┘ └──────────┘
│ │ │ │
│ POST /signin │ │ │
│ {email,pass} │ │ │
│──────────────▶│ │ │
│ │ signin() │ │
│ │───────────────▶│ │
│ │ │ findBy({email}) │
│ │ │──────────────────▶│
│ │ │◀──────────────────│
│ │ │ │
│ │ │ bcrypt.compare() │
│ │ │ │
│ │ │ jwtSign() │
│ │◀───────────────│ │
│◀──────────────│ │ │
│ JWT Token │ │ │
```
### OAuth Login Flow
```
┌────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ Backend │ │ Provider │ │ UsersDB │
└────────┘ └─────────┘ └──────────┘ └──────────┘
│ │ │ │
│ GET /signin/ │ │ │
│ google │ │ │
│──────────────▶│ │ │
│ │ Redirect to │ │
│◀──────────────│ consent screen │ │
│──────────────────────────────▶│ │
│ │ │ │
│ │ User consents │ │
│◀──────────────────────────────│ │
│ │ │ │
│ GET /callback │ │ │
│ ?code=... │ │ │
│──────────────▶│ │ │
│ │ Exchange code │ │
│ │───────────────▶│ │
│ │ Profile data │ │
│ │◀───────────────│ │
│ │ │ │
│ │ findOrCreate() │ │
│ │───────────────────────────────▶│
│ │◀───────────────────────────────│
│ │ jwtSign() │ │
│ │ │ │
│ Redirect to │ │ │
│ /login?token= │ │ │
│◀──────────────│ │ │
```
### Email Verification Flow
```
┌────────┐ ┌─────────┐ ┌─────────────┐ ┌───────┐
│ Client │ │ Router │ │ AuthService │ │ Email │
└────────┘ └─────────┘ └─────────────┘ └───────┘
│ │ │ │
│ POST /signup │ │ │
│──────────────▶│ │ │
│ │ signup() │ │
│ │───────────────▶│ │
│ │ │ generateToken()│
│ │ │ sendEmail() │
│ │ │───────────────▶│
│ │◀───────────────│ │
│◀──────────────│ │ │
│ JWT Token │ │ │
│ │ │ │
│ User clicks email link │ │
│ │ │ │
│ PUT /verify- │ │ │
│ email │ │ │
│ {token} │ │ │
│──────────────▶│ │ │
│ │ verifyEmail() │ │
│ │───────────────▶│ │
│ │ │ markVerified() │
│ │◀───────────────│ │
│◀──────────────│ │ │
│ Success │ │ │
```
---
## 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 |
---
## Security Considerations
1. **Password Storage:** bcrypt with 12 salt rounds
2. **JWT Expiration:** 6 hours (balances security and UX)
3. **Token Generation:** crypto.randomBytes(20) - 160-bit entropy
4. **Token Expiry:** 24 hours for email/password reset tokens
5. **Rate Limiting:** Prevents brute force attacks
6. **Disabled User Check:** Checked on both JWT validation and login
7. **Session-less:** No server-side session storage (stateless JWT)
8. **HTTPS Required:** OAuth callbacks require HTTPS in production
---
## Testing
### Test Local Login
```bash
curl -X POST http://localhost:3000/api/auth/signin/local \
-H "Content-Type: application/json" \
-d '{"email": "admin@flatlogic.com", "password": "password"}'
```
### Test Get Current User
```bash
curl http://localhost:3000/api/auth/me \
-H "Authorization: Bearer <JWT_TOKEN>"
```
### User Creation
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 |
---
## Summary
The Auth module provides:
1. **JWT Authentication** - Stateless API authentication with 6-hour tokens
2. **Local Login** - Email/password authentication with bcrypt
3. **OAuth 2.0** - Google and Microsoft social login
4. **Email Verification** - Token-based email confirmation
5. **Password Reset** - Secure token-based password recovery
6. **Rate Limiting** - Protection against brute force attacks
7. **Profile Management** - User profile updates
8. **Role Assignment** - Default role on signup

View File

@ -0,0 +1,781 @@
# Core Module Documentation
The Core module provides the foundational components of the backend application: the entry point, configuration management, and utility functions.
## 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 |
---
## 1. Application Entry Point (`index.ts`)
### Purpose
The main entry point that bootstraps the Express application, configures middleware, mounts routes, and starts the HTTP server.
### Dependencies
```typescript
import bodyParser from 'body-parser';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import * as swaggerUI from 'swagger-ui-express';
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 { createOpenApiDocument } from './openapi/document.ts';
import {
exitAfterLogging,
logger,
registerProcessErrorHandlers,
requestLogger,
} from './utils/logger.ts';
```
> TypeScript migration note: `index.ts`, `config.ts`, `helpers.ts`, `middlewares/validate-request.ts`, `middlewares/runtime-context.ts`, `middlewares/runtime-public.ts`, `routes/runtime-context.ts`, `validators/request-schemas.ts`, `services/notifications/list.ts`, `services/notifications/helpers.ts`, `utils/logger.ts`, and `utils/env-validation.ts` are now migrated runtime TypeScript modules. New migrated backend TypeScript files use `backend/tsconfig.json` with `strict: true`, reusable named types from `src/types/`, and lint rules that reject `any`, non-null assertions, and unsafe type assertions.
> Environment bootstrap note: `src/load-env.ts` is loaded by app config and DB entrypoints so short backend scripts can see `backend/.env` without repeating `NODE_OPTIONS=-r dotenv/config`. If `NODE_ENV` is absent, it defaults to `dev_stage`, matching the standard VM backend flow.
### Application Bootstrap Sequence
```
┌─────────────────────────────────────────────────────────────────┐
│ APPLICATION BOOTSTRAP │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 1. EXPRESS INITIALIZATION │
│ • Create Express app │
│ • Enable trust proxy (for reverse proxies) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 2. SECURITY MIDDLEWARE │
│ • Helmet (CSP disabled, COEP disabled) │
│ • CORS (origin: true - allow all origins) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 3. AUTHENTICATION SETUP │
│ • Load Passport strategies (JWT, Google, Microsoft) │
│ • Create jwtAuth middleware │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 4. LOGGING │
│ • Register process-level error handlers │
│ • Apply requestLogger middleware (early for full coverage) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 5. FILE ROUTES (BEFORE BODY PARSER) │
│ • Mount file routes without JSON parsing │
│ • Apply rate limiters (download: 200/min, upload: 10/min) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 6. BODY PARSING │
│ • JSON parser (1MB limit) │
│ • URL-encoded parser (1MB limit) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 7. RUNTIME CONTEXT │
│ • Apply runtimeContextMiddleware │
│ • Detect environment from X-Runtime-Environment header │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 8. ROUTE MOUNTING │
│ • Public routes (health, auth, runtime-context) │
│ • Protected routes (JWT required) │
│ • Runtime public routes (production content without auth) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 9. STATIC FILES & ERROR HANDLING │
│ • Serve public directory if exists │
│ • Generic error handler │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 10. SERVER START │
│ • Listen on PORT (8080 or 3000 for dev_stage) │
│ • Log startup message │
└─────────────────────────────────────────────────────────────────┘
```
### Middleware Stack
```javascript
// Order matters - applied in this sequence:
1. swaggerUI.serve // API documentation at /api-docs
2. helmet() // Security headers
3. cors({ origin: true }) // Cross-origin requests
4. requestLogger // Request logging (Pino)
5. downloadLimiter // Rate limit for /api/file/download, /api/file/presign
6. uploadLimiter // Rate limit for /api/file/upload*
7. bodyParser.json() // JSON parsing (1MB limit)
8. bodyParser.urlencoded() // Form data parsing
9. runtimeContextMiddleware // Environment detection
10. passport.authenticate() // JWT authentication (per-route)
11. checkPermissions // RBAC (per-route)
12. errorHandler // Generic error handling
```
### Route Mounting
#### Public Routes (No Authentication)
```javascript
app.get('/api/health', ...) // Health check
app.use('/api/auth', authRoutes) // Authentication
app.use('/api/runtime-context', ...) // Runtime context
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)
```
#### Runtime Public Routes (Production Content Without Auth)
```javascript
// 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)
```
### Key Functions
#### `requireRuntimeReadOrAuth`
Middleware that allows public read access for production content:
```javascript
const requireRuntimeReadOrAuth = (req, res, next) => {
const headerEnvironment = req.runtimeContext?.headerEnvironment;
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
const hasAuthHeader = Boolean(req.headers.authorization);
// Only production is public. Stage requires authentication.
const isPublicEnvironment = headerEnvironment === 'production';
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
req.isRuntimePublicRequest = true;
return next(); // Allow without JWT
}
req.isRuntimePublicRequest = false;
return jwtAuth(req, res, next); // Require JWT
};
```
#### `mountRuntimeEntityRoute`
Helper to mount routes with runtime public access middleware stack:
```javascript
const mountRuntimeEntityRoute = (path, entityName, router) => {
app.use(
path,
requireRuntimeReadOrAuth, // JWT or public production
blockNonPublicRuntimeListEndpoints, // Block non-list for public
sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields
router,
);
};
```
#### `getBaseUrl`
Utility to extract base URL for Swagger:
```javascript
const getBaseUrl = (url) => {
if (!url) return '';
return url.endsWith('/api') ? url.slice(0, -4) : url;
};
```
### Health Check Endpoint
```javascript
GET /api/health
// Response (200 - healthy):
{
"status": "ok",
"timestamp": "2026-03-30T12:00:00.000Z",
"uptime": 12345.678,
"environment": "production",
"database": "connected"
}
// Response (503 - degraded):
{
"status": "degraded",
"timestamp": "2026-03-30T12:00:00.000Z",
"uptime": 12345.678,
"environment": "production",
"database": "disconnected",
"databaseError": "Connection refused"
}
```
### Swagger/OpenAPI Configuration
```javascript
const specs = createOpenApiDocument({
serverUrl: config.server.swaggerServerUrl,
});
app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(specs));
```
The canonical OpenAPI source is `backend/src/openapi/document.ts`. It defines
shared schemas, common responses and parameters, generated factory CRUD paths,
and explicit custom-route paths. Route-local JSDoc comments are not the source
of truth for Swagger UI.
### Error Handler
```javascript
app.use((err, req, res, _next) => {
if (!res.headersSent) {
const requestLog = getRequestLogger(req) ?? logger;
requestLog.error(
{ err, url: req.url, method: req.method },
'Express error middleware caught unhandled error',
);
res.status(safeStatusCode).json({
message:
safeStatusCode === 503
? 'Service temporarily unavailable'
: 'Internal server error',
});
}
});
```
Route-level `commonErrorHandler` also uses the request-scoped logger when
available and logs unexpected route failures as `Route handler failed`. Circuit
breaker rejections use status `503` instead of being collapsed to `500`.
### Server Configuration
```javascript
const PORT = config.server.port;
const server = app.listen(PORT, () => {
logger.info(
{ port: PORT, env: config.server.env },
'Server started',
);
});
server.on('error', (err) => {
logger.error(
{ err, port: PORT, env: config.server.env },
'Server failed to start',
);
exitAfterLogging();
});
```
Startup errors such as `EADDRINUSE` happen on the Node `Server`, outside the
Express request lifecycle, so the generic Express error middleware cannot catch
them. The server-level `error` listener logs the failure and exits with a
non-zero status for nodemon/PM2 supervision.
---
## 2. Configuration (`config.ts`)
### Purpose
Centralized configuration management with environment variable support and sensible defaults.
### Configuration Structure
```javascript
const config = {
// Google Cloud Storage
gcloud: {
bucket: 'fldemo-files',
hash: 'afeefb9d49f5b7977577876b99532ac7',
projectId: env.GC_PROJECT_ID,
clientEmail: env.GC_CLIENT_EMAIL,
privateKey: env.GC_PRIVATE_KEY,
},
fileStorage: {
provider: env.FILE_STORAGE_PROVIDER,
},
// AWS S3
s3: {
bucket: env.AWS_S3_BUCKET,
region: env.AWS_S3_REGION,
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
prefix: env.AWS_S3_PREFIX,
connectionTimeout: env.AWS_S3_CONNECTION_TIMEOUT,
requestTimeout: env.AWS_S3_REQUEST_TIMEOUT,
maxAttempts: env.AWS_S3_MAX_ATTEMPTS,
maxSockets: env.AWS_S3_MAX_SOCKETS,
keepAlive: env.AWS_S3_KEEP_ALIVE !== 'false',
presignExpirySeconds: env.AWS_S3_PRESIGN_EXPIRY,
},
resilience: {
ffmpeg: {
reverseTimeoutMs: env.FFMPEG_REVERSE_TIMEOUT_MS,
ffprobeTimeoutMs: env.FFPROBE_TIMEOUT_MS,
breaker: {
failureThreshold: env.FFMPEG_BREAKER_FAILURE_THRESHOLD,
cooldownMs: env.FFMPEG_BREAKER_COOLDOWN_MS,
successThreshold: env.FFMPEG_BREAKER_SUCCESS_THRESHOLD,
},
},
fileStorage: {
breaker: {
failureThreshold: env.FILE_STORAGE_BREAKER_FAILURE_THRESHOLD,
cooldownMs: env.FILE_STORAGE_BREAKER_COOLDOWN_MS,
successThreshold: env.FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD,
},
},
},
// Password hashing
bcrypt: {
saltRounds: 12,
},
// Default credentials
admin_pass: env.ADMIN_PASS,
user_pass: env.USER_PASS,
admin_email: env.ADMIN_EMAIL,
// Authentication providers
providers: {
LOCAL: 'local',
GOOGLE: 'google',
MICROSOFT: 'microsoft',
},
// JWT
secret_key: env.SECRET_KEY,
// Server URLs
remote: '',
port,
hostUI,
portUI,
// Swagger
swaggerUI,
swaggerPort,
// OAuth
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
microsoft: {
clientId: env.MS_CLIENT_ID,
clientSecret: env.MS_CLIENT_SECRET,
},
// File uploads
uploadDir: os.tmpdir(),
// Email (AWS SES)
email: {
from: 'Tour Builder Platform <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {
user: env.EMAIL_USER,
pass: env.EMAIL_PASS,
},
tls: {
rejectUnauthorized: env.EMAIL_TLS_REJECT_UNAUTHORIZED !== 'false',
},
},
// Default roles
roles: {
admin: 'Administrator',
user: 'Analytics Viewer',
},
server: {
env: env.NODE_ENV,
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 |
### Environment Validation
The configuration uses Joi schema validation via `utils/env-validation.js`:
```javascript
const Joi = require('joi');
const envSchema = Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'test', 'production', 'dev_stage')
.default('development'),
PORT: Joi.number().default(8080),
DB_HOST: Joi.string().default('localhost'),
DB_PORT: Joi.number().default(5432),
DB_NAME: Joi.string().default('db_tour_builder_platform'),
DB_USER: Joi.string().default('postgres'),
DB_PASS: Joi.string().allow('').default(''),
SECRET_KEY: Joi.string()
.min(16)
.default('88dbeaf8-e906-405e-9e41-c3baadeda5c6'),
// ... more validations
}).unknown(true);
function validateEnv() {
const { error, value } = envSchema.validate(process.env, {
abortEarly: false,
stripUnknown: false,
});
if (error) {
const messages = error.details.map((d) => ` - ${d.message}`);
logger.error({ errors: messages }, 'Environment validation failed');
if (process.env.NODE_ENV === 'production') {
process.exit(1); // Fatal in production
} else {
logger.warn('Continuing with default values in non-production mode');
}
}
return value;
}
```
---
## 3. Helpers (`helpers.js`)
### Purpose
Utility class providing common functions used across the application.
### Class Definition
```javascript
const jwt = require('jsonwebtoken');
const config = require('./config');
module.exports = class Helpers {
/**
* Wraps async route handlers to catch errors and pass to next()
* @param {Function} fn - Async function (req, res, next) => Promise
* @returns {Function} Wrapped function
*/
static wrapAsync(fn) {
return function (req, res, next) {
fn(req, res, next).catch(next);
};
}
/**
* Common error handler middleware
* @param {Error} error - Error object with code/status property
* @param {Request} req - Express request
* @param {Response} res - Express response
* @param {Function} _next - Next middleware (unused)
*/
static commonErrorHandler(error, req, res, _next) {
const statusCode = error.code || error.status;
// Known HTTP error codes - return error message
if ([400, 401, 403, 404, 409, 422, 503].includes(statusCode)) {
return res.status(statusCode).send(error.message);
}
// Unknown errors - log and return generic message
const requestLog = getRequestLogger(req) ?? logger;
requestLog.error({ err: error }, 'Route handler failed');
return res.status(500).send('Internal server error');
}
/**
* Sign JWT token with 6-hour expiration
* @param {Object} data - Payload to sign
* @returns {string} JWT token
*/
static jwtSign(data) {
return jwt.sign(data, config.secret_key, { expiresIn: '6h' });
}
/**
* Validate UUID v4 format
* @param {string} value - String to validate
* @returns {boolean} True if valid UUID v4
*/
static isUuidV4(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
);
}
};
```
### Usage Examples
#### wrapAsync
```javascript
// Without wrapAsync - need try/catch
router.get('/', async (req, res, next) => {
try {
const data = await Service.findAll();
res.json(data);
} catch (error) {
next(error);
}
});
// With wrapAsync - cleaner code
const wrapAsync = require('../helpers').wrapAsync;
router.get('/', wrapAsync(async (req, res) => {
const data = await Service.findAll();
res.json(data);
}));
```
#### commonErrorHandler
```javascript
const { commonErrorHandler } = require('../helpers');
// At end of route file
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"
// Unknown errors return 500
throw new Error('Database connection failed'); // → 500 "Internal server error"
```
#### jwtSign
```javascript
const { jwtSign } = require('./helpers');
// Create JWT token
const token = jwtSign({
user: {
id: user.id,
email: user.email,
},
});
// Token expires in 6 hours
// Token is signed with config.secret_key
```
#### isUuidV4
```javascript
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
```
---
## Module Dependencies
```
┌─────────────────────────────────────────────────────────────────┐
│ index.ts │
│ (Application Entry) │
└─────────────────────────────────────────────────────────────────┘
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ config.ts │ │ helpers.ts │ │ utils/logger │
│ (Configuration) │ │ (Utilities) │ │ (Logging) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ env-validation │ │ jsonwebtoken │
│ (Joi) │ │ (JWT lib) │
└─────────────────┘ └─────────────────┘
External Dependencies:
• express (web framework)
• cors (CORS middleware)
• helmet (security headers)
• passport (authentication)
• body-parser (request parsing)
• swagger-ui-express (API docs)
• jsonwebtoken (JWT signing)
• joi (schema validation)
• dotenv (environment loading)
• pino (logging)
```
---
## Best Practices Implemented
### 1. Environment Validation
- Joi schema validates all environment variables at startup
- Fails fast in production, warns in development
- Provides sensible defaults for optional variables
### 2. Security
- Helmet middleware for security headers
- CORS configured for cross-origin requests
- JWT authentication with 6-hour expiration
- Trust proxy enabled for reverse proxy support
### 3. Error Handling
- `wrapAsync` wrapper for async route handlers
- Centralized error handler with status code mapping
- Generic error handler logs and returns safe message
### 4. Middleware Ordering
- File routes mounted before body parser (binary uploads)
- Rate limiters applied before routes
- Authentication applied per-route (not globally)
- Error handler at the end of middleware stack
### 5. API Documentation
- Swagger/OpenAPI 3.0 generated from `backend/src/openapi/document.ts`
- Available at `/api-docs` endpoint
- Security scheme documented (Bearer JWT)
- `backend/tests/openapi-document.test.ts` checks key paths, factory CRUD
coverage, internal `$ref` resolution, and Swagger UI handler smoke behavior
### 6. Logging
- Pino structured logging
- Request logger for all routes
- Error logging with context (URL, method)
---
## Configuration Precedence
```
1. Environment Variables (process.env.*)
↓ (fallback)
2. .env file (loaded by dotenv)
↓ (fallback)
3. Joi schema defaults
↓ (fallback)
4. Hardcoded defaults in config.ts
```
---
## 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 |
**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
separately on port `3001`, and Apache proxies public traffic from port `80`.
Do not use `8080` as the VM backend health check unless the PM2 definition has
been changed. See [deployment-vm.md](../../../documentation/deployment-vm.md).

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,505 @@
# DB Config Module
## Overview
The DB Config module manages database connection settings, environment validation, and database utilities. It provides environment-aware configuration for PostgreSQL connections via Sequelize ORM.
**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
- `sync.ts` - Development database sync script
- `reset.ts` - Database reset script
**Related Files:**
- `backend/src/config.ts` - Application configuration
- `backend/src/utils/env-validation.ts` - Environment variable validation
---
## Architecture
```
backend/
├── src/
│ ├── config.ts # Application config
│ ├── utils/
│ │ └── env-validation.ts # Joi schema validation
│ └── db/
│ ├── db-config.ts # Typed database connection config
│ ├── umzug.ts # Typed migration/seed runner
│ ├── utils.ts # DB utilities
│ ├── sync.ts # Dev sync script (21 LOC)
│ ├── reset.ts # Reset script (18 LOC)
│ ├── models/
│ │ ├── loader.ts # Typed model loader + Sequelize init
│ │ └── index.ts # ESM model registry entrypoint
│ ├── migrations/ # Schema migrations
│ └── seeders/ # Data seeders
```
---
## Database Configuration
### db-config.ts
`db-config.ts` exports environment-specific database settings for Sequelize.
It uses the official Sequelize `Options` type plus project-specific
`DatabaseConfigMap`/`DatabaseEnvironmentConfig` reusable contracts from
`backend/src/types/db-config.ts`.
Database scripts use `backend/src/db/umzug.ts` directly.
`DB_PORT` is parsed to a number for the Sequelize `Options` contract. If the
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 |
| **Migration Storage** | SequelizeMeta | SequelizeMeta | SequelizeMeta |
| **Seeder Storage** | SequelizeData | SequelizeData | SequelizeData |
---
## Environment Variables
### 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 |
### Environment Validation
The `env-validation.ts` file uses Joi schema to validate all environment variables:
```javascript
const Joi = require('joi');
const envSchema = Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'test', 'production', 'dev_stage')
.default('development'),
PORT: Joi.number().default(8080),
DB_HOST: Joi.string().default('localhost'),
DB_PORT: Joi.number().default(5432),
DB_NAME: Joi.string().default('db_tour_builder_platform'),
DB_USER: Joi.string().default('postgres'),
DB_PASS: Joi.string().allow('').default(''),
SECRET_KEY: Joi.string()
.min(16)
.default('88dbeaf8-e906-405e-9e41-c3baadeda5c6'),
// ... more variables
}).unknown(true);
```
### 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 |
### 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
});
if (error) {
const messages = error.details.map((d) => ` - ${d.message}`);
logger.error({ errors: messages }, 'Environment validation failed');
if (process.env.NODE_ENV === 'production') {
process.exit(1); // Fatal in production
} else {
logger.warn('Continuing with default values in non-production mode');
}
}
return value;
}
```
---
## Umzug Configuration
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/` |
---
## Database Initialization
### Model Loader
`src/db/models/loader.ts` explicitly imports model factories and builds the
Sequelize registry. `src/db/models/index.ts` is the ESM entrypoint. The typed
service-specific overload facade lives in `src/types/db-models.ts`.
### Initialization Flow
```
Server Start
config.ts loads
├─ dotenv.config() ─── Load .env file
├─ validateEnv() ─── Validate with Joi schema
│ │
│ ├─ Production: Exit on error
│ └─ Development: Warn, use defaults
└─ Export config object
db/models/index.ts loads
├─ Read NODE_ENV
├─ Load db-config.ts[env]
├─ Create Sequelize instance
├─ Initialize explicitly imported model factories
├─ Call model.associate() for relationships
└─ Export db object { sequelize, Sequelize, models... }
```
---
## Database Utilities
### utils.ts
Database utility helpers are TypeScript/ESM source. Runtime DB access goes
through `src/db/models/index.ts`, whose typed facade is provided by
`src/types/db-models.ts`.
### Usage Examples
```typescript
import Utils from '../db/utils.ts';
// UUID validation
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
// Filter array to valid UUIDs only
const validIds = Utils.filterValidUuids(['uuid1', 'invalid', 'uuid2']);
// Case-insensitive search
const where = {
[Op.or]: [
Utils.ilike('users', 'firstName', searchTerm),
Utils.ilike('users', 'lastName', searchTerm),
Utils.ilike('users', 'email', searchTerm),
]
};
```
---
## Database Scripts
### sync.ts (Development Only)
Synchronizes models to database schema using Sequelize's `alter` mode.
```javascript
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.');
process.exit(1);
}
try {
console.log('Syncing database...');
await db.sequelize.sync({ alter: true });
console.log('Database synced successfully!');
process.exit(0);
} catch (error) {
console.error('Error syncing database:', error);
process.exit(1);
}
}
```
**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 |
### reset.ts
Forcefully resets database and runs seeders.
```javascript
db.sequelize
.sync({ force: true })
.then(() => {
execSync('sequelize db:seed:all');
console.log('OK');
process.exit();
})
.catch((error) => {
console.error(error);
process.exit(1);
});
```
**Usage:**
```bash
node src/db/reset.ts
```
**Warning:** This drops ALL tables and recreates them. Data loss!
---
## Application Configuration
### config.ts
Centralized application configuration with environment-aware settings.
```javascript
const env = validateEnv();
const config = {
gcloud: {
bucket: 'fldemo-files',
hash: 'afeefb9d49f5b7977577876b99532ac7',
projectId: env.GC_PROJECT_ID,
clientEmail: env.GC_CLIENT_EMAIL,
privateKey: env.GC_PRIVATE_KEY,
},
fileStorage: {
provider: env.FILE_STORAGE_PROVIDER,
},
s3: {
bucket: env.AWS_S3_BUCKET,
region: env.AWS_S3_REGION,
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
prefix: env.AWS_S3_PREFIX,
connectionTimeout: env.AWS_S3_CONNECTION_TIMEOUT,
requestTimeout: env.AWS_S3_REQUEST_TIMEOUT,
maxAttempts: env.AWS_S3_MAX_ATTEMPTS,
maxSockets: env.AWS_S3_MAX_SOCKETS,
keepAlive: env.AWS_S3_KEEP_ALIVE !== 'false',
presignExpirySeconds: env.AWS_S3_PRESIGN_EXPIRY,
},
resilience: {
ffmpeg: { reverseTimeoutMs, ffprobeTimeoutMs, breaker },
fileStorage: { breaker },
},
server: {
env: env.NODE_ENV,
port: serverPort,
swaggerServerUrl,
},
secret_key: env.SECRET_KEY,
admin_pass: env.ADMIN_PASS,
user_pass: env.USER_PASS,
admin_email: env.ADMIN_EMAIL,
// Roles
roles: {
admin: 'Administrator',
user: 'Analytics Viewer',
},
apiUrl: `${host}${port ? `:${port}` : ''}/api`,
swaggerUrl: `${swaggerUI}${swaggerPort}`,
uiUrl: `${hostUI}${portUI ? `:${portUI}` : ''}/#`,
backUrl: `${hostUI}${portUI ? `:${portUI}` : ''}`,
};
```
---
## Configuration Categories
### 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 |
### 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) |
### 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 |
### 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) |
---
## Running Commands
### Development
```bash
cd backend
npm run start-dev
```
`start-dev` runs the standard `start` pipeline (`db:migrate`, `db:seed`,
`watch`) with `LOG_PRETTY=true`. `src/load-env.ts` loads `backend/.env` before
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
```
In the standard VM PM2 setup, `NODE_ENV=dev_stage` makes the backend listen on
port `3000`; the frontend runs separately on `3001` and Apache proxies public
traffic from port `80`. See [`deployment-vm.md`](../../../documentation/deployment-vm.md).
Do not add `NODE_ENV=production` only to make local startup work. The current
flow loads `.env` through `src/load-env.ts` and defaults missing `NODE_ENV` to
`dev_stage`, matching the VM backend process.
---
## 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') {
process.exit(1);
}
```
### 3. Validate Environment Early
```javascript
// config.ts loads validation at import time
import { validateEnv } from './utils/env-validation.ts';
validateEnv(); // Called before app starts
```
### 4. Environment-Specific Logging
```javascript
// Production: logging disabled (performance)
// Development/dev_stage: SQL logs use structured Pino debug entries
logging: process.env.NODE_ENV === 'production'
? false
: (sql) => logger.debug({ sql }, 'Sequelize query'),
```
---
## Related Documentation
- [DB Models](./db-models.md) - Sequelize model definitions
- [DB API](./db-api.md) - Database access layer
- [DB Migrations](./db-migrations.md) - Schema evolution
- [DB Seeders](./db-seeders.md) - Initial data population
- [Core Module](./core.md) - Application entry point

View File

@ -0,0 +1,764 @@
# DB Migrations Module
## Overview
The DB Migrations module manages database schema evolution using a typed
**Umzug v3** runner. It provides version-controlled, reversible database
changes that run automatically on server startup.
**Location:** `backend/src/db/migrations/`
**Files:** 34+ migration files (as of June 2026)
**Migration safety policy:** Do not rewrite, rename, or reformat already
applied migration files. Production databases track migration names in
`SequelizeMeta`, and fresh databases must replay the same schema history.
---
## Architecture
```
backend/
├── package.json # Umzug-backed migration scripts
└── src/db/
├── db-config.ts # Database connection settings
├── umzug.ts # Typed Umzug runner
└── migrations/ # Migration files
├── package.json # CommonJS boundary for legacy migrations
├── 20260319000001-*.js # Foreign key constraints
├── 20260319000002-*.js # Column cleanup
├── 20260326000001-*.js # Table rename
├── 20260326000002-*.js # ENUM to TEXT conversion
├── 20260326000003-*.js # Create table
├── 20260326000004-*.js # Data backfill
├── 20260326000005-*.js # Environment fix
├── 20260326000006-*.js # Cross-environment copy
├── 20260326043002-*.js # NOT NULL enforcement
├── 20260326050442-*.js # Column removal
├── 20260326054410-*.js # Column removal
├── 20260326060000-*.js # JSON data transformation
├── 20260326060001-*.js # Table drop (page_elements)
├── 20260326060002-*.js # Table drop (page_links)
├── 20260326060003-*.js # Table drop (transitions)
├── 20260326171017-*.js # Missing data insertion
├── 20260327000001-*.js # Full data sync
├── 20260331024423-*.js # Column removal (theme/css)
├── 20260331054340-*.js # Duplicate cleanup
├── 20260331063424-*.js # Invalid data cleanup
├── 20260403000001-*.js # Background video settings
├── 20260409000001-*.js # Design dimensions (projects)
├── 20260409111309-*.js # Design dimensions (tour_pages)
├── 20260605000001-*.js # Background audio settings
├── 20260626000001-*.js # Private production presentation access
├── 20260626000002-*.js # Account manager user creation permission
├── 20260628000001-*.js # Global UI-control settings tables
└── 20260628000005-*.js # Existing-project UI-control snapshots
```
---
## Configuration
### Umzug Runner
`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 |
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
# Undo last migration
npm run db:migrate:undo
# Undo all migrations
npm run db:migrate:undo:all
# Check migration status
npm run db:migrate:status
# Full database reset (drop, create, migrate, seed)
npm run db:reset
# Seed data
npm run db:seed
```
---
## Migration File Structure
### Standard Template
```javascript
'use strict';
/**
* Migration: [Description]
*
* [Explanation of what this migration does and why]
*/
module.exports = {
async up(queryInterface, Sequelize) {
// Apply changes
},
async down(queryInterface, Sequelize) {
// Revert changes
},
};
```
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
Examples:
- 20260319000001-add-foreign-key-constraints.js
- 20260326060000-convert-targetpageid-to-slug.js
- 20260326060001-drop-page-elements-table.js
- 20260628000001-create-ui-control-settings.js
- 20260628000005-snapshot-existing-project-ui-controls.js
```
---
## Migration Patterns
### 1. Transaction Wrapper Pattern
**Purpose:** Ensure atomic operations - all changes succeed or all fail.
```javascript
module.exports = {
async up(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
// Multiple operations...
await queryInterface.addColumn('table', 'column', { ... }, { transaction });
await queryInterface.addIndex('table', ['column'], { transaction });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};
```
**Used in:** Foreign key constraints, ENUM conversions, data transformations
---
### 2. Idempotent Check Pattern
**Purpose:** Safely re-run migrations without errors.
```javascript
// Check if table/column/constraint exists before modifying
const [results] = await queryInterface.sequelize.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'tableName' AND column_name = 'columnName'`,
{ transaction }
);
if (results.length > 0) {
console.log('Column already exists, skipping');
return;
}
// Proceed with modification
await queryInterface.addColumn('tableName', 'columnName', { ... });
```
**Used in:** All migrations that add columns, constraints, or tables
---
### 3. Helper Function Pattern
**Purpose:** Reduce repetition for bulk operations.
```javascript
module.exports = {
async up(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
// Define reusable helper
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 }
);
if (results.length === 0) {
await queryInterface.addConstraint(tableName, {
fields: [columnName],
type: 'foreign key',
name: constraintName,
references,
onDelete,
onUpdate: 'CASCADE',
transaction,
});
console.log(`Added FK: ${constraintName}`);
}
};
// Use helper multiple times
await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');
await addForeignKey('tour_pages', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');
// ... more FKs
},
};
```
**Used in:** Foreign key constraints, column removal, index management
---
### 4. Safe Table Drop Pattern
**Purpose:** Prevent accidental data loss when dropping tables.
```javascript
module.exports = {
async up(queryInterface) {
// Verify table is empty before dropping
const [results] = await queryInterface.sequelize.query(
'SELECT COUNT(*) as count FROM table_name'
);
const count = parseInt(results[0].count, 10);
if (count > 0) {
throw new Error(
`Cannot drop table_name: it contains ${count} records. ` +
`Please migrate or delete them first.`
);
}
await queryInterface.dropTable('table_name');
console.log('Dropped table_name (was empty)');
},
async down(queryInterface, Sequelize) {
// Full table recreation with all columns, indexes, and constraints
await queryInterface.createTable('table_name', {
id: { type: Sequelize.UUID, ... },
// ... all columns
});
},
};
```
**Used in:** `drop-page-elements-table`, `drop-page-links-table`, `drop-transitions-table`
---
### 5. ENUM to TEXT Conversion Pattern
**Purpose:** Convert restrictive ENUMs to flexible TEXT while preserving data.
```javascript
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
// 1. Create temporary TEXT column
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 }
);
// 3. Drop old ENUM column
await queryInterface.removeColumn('table', 'column', { transaction });
// 4. Rename TEXT column
await queryInterface.renameColumn('table', 'column_text', 'column', { transaction });
// 5. Add NOT NULL constraint
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 }
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, Sequelize) {
// Recreate ENUM type
await queryInterface.sequelize.query(`
CREATE TYPE "enum_table_column" AS ENUM ('value1', 'value2', 'value3')
`);
// ... reverse the process
},
};
```
**Used in:** `convert-element-type-enum-to-text`
---
### 6. Data Backfill Pattern
**Purpose:** Populate new tables/columns with data from existing records.
```javascript
// Define default data
const DEFAULT_ELEMENT_TYPES = [
{ element_type: 'navigation_next', name: 'Forward Button', sort_order: 1, settings_json: {...} },
{ element_type: 'navigation_prev', name: 'Back Button', sort_order: 2, settings_json: {...} },
// ... more defaults
];
module.exports = {
async up(queryInterface, Sequelize) {
// Get existing records
const [projects] = await queryInterface.sequelize.query(
`SELECT id FROM projects WHERE "deletedAt" IS NULL`,
{ type: Sequelize.QueryTypes.SELECT }
);
// For each project, check and insert missing records
for (const project of projects) {
const [existing] = await queryInterface.sequelize.query(
`SELECT element_type FROM project_element_defaults
WHERE "projectId" = :projectId AND "deletedAt" IS NULL`,
{ replacements: { projectId: project.id }, type: Sequelize.QueryTypes.SELECT }
);
const existingTypes = new Set(existing.map(d => d.element_type));
for (const defaultType of DEFAULT_ELEMENT_TYPES) {
if (!existingTypes.has(defaultType.element_type)) {
await queryInterface.sequelize.query(`
INSERT INTO project_element_defaults (...)
VALUES (gen_random_uuid(), :element_type, :name, ...)
`, { replacements: { ... } });
}
}
}
},
async down(queryInterface) {
// Delete only records created by this migration
await queryInterface.sequelize.query(
`DELETE FROM project_element_defaults WHERE snapshot_version = 1`
);
},
};
```
**Used in:** `backfill-project-element-defaults`, `sync-all-element-type-defaults`
---
### 7. Cross-Environment Data Copy Pattern
**Purpose:** Copy content between environments (dev → stage → production).
```javascript
module.exports = {
async up(queryInterface, Sequelize) {
const projects = await queryInterface.sequelize.query(
`SELECT id FROM projects WHERE "deletedAt" IS NULL`,
{ 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'`
);
if (stageCheck?.count > 0) continue;
// Copy with INSERT...SELECT, generating new UUIDs
await queryInterface.sequelize.query(`
INSERT INTO tour_pages (id, slug, name, ..., environment, source_key, ...)
SELECT
gen_random_uuid(),
slug, name, ...,
'stage', -- New environment
id::text, -- Track source record for rollback
...
FROM tour_pages
WHERE "projectId" = '${project.id}' AND environment = 'dev'
`);
// Copy related records using source_key for ID mapping
await queryInterface.sequelize.query(`
INSERT INTO page_elements (id, ..., "pageId", ...)
SELECT
gen_random_uuid(), ...,
stage_page.id, -- Map to new page ID
...
FROM page_elements pe
INNER JOIN tour_pages dev_page ON pe."pageId" = dev_page.id
INNER JOIN tour_pages stage_page ON stage_page.source_key = dev_page.id::text
WHERE dev_page.environment = 'dev'
`);
}
},
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`
);
},
};
```
**Used in:** `copy-dev-to-stage`
---
### 8. JSON Field Transformation Pattern
**Purpose:** Transform data stored in JSON columns.
```javascript
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
// Get all records with JSON data
const [records] = await queryInterface.sequelize.query(
`SELECT id, "projectId", environment, slug, json_column
FROM table_name WHERE json_column IS NOT NULL`,
{ 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 }));
// Transform each record
for (const record of records) {
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 => {
if (element.targetPageId) {
const target = slugById.get(element.targetPageId);
if (target) {
element.targetPageSlug = target.slug;
delete element.targetPageId;
hasChanges = true;
}
}
});
}
if (hasChanges) {
await queryInterface.sequelize.query(
`UPDATE table_name SET json_column = :json WHERE id = :id`,
{
replacements: { json: JSON.stringify(jsonData), id: record.id },
transaction
}
);
}
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};
```
**Used in:** `convert-targetpageid-to-slug`
---
### 9. Constraint Enforcement Pattern
**Purpose:** Add NOT NULL constraints after fixing existing NULL values.
```javascript
module.exports = {
async up(queryInterface) {
// First, fix any NULL values
await queryInterface.sequelize.query(
`UPDATE table_name SET column = 'default' WHERE column IS NULL`
);
// Then add NOT NULL constraint with default
await queryInterface.sequelize.query(`
ALTER TABLE table_name
ALTER COLUMN column SET NOT NULL,
ALTER COLUMN column SET DEFAULT 'default'
`);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE table_name
ALTER COLUMN column DROP NOT NULL,
ALTER COLUMN column DROP DEFAULT
`);
},
};
```
**Used in:** `enforce-environment-not-null`
---
### 10. Safe Down Migration Pattern
**Purpose:** Handle cases where down migration isn't meaningful.
```javascript
module.exports = {
async up(queryInterface, Sequelize) {
// Add/update missing data
// ...
},
async down(_queryInterface, _Sequelize) {
// This migration only adds missing data, not destructive
console.log('No down migration needed - this migration only adds missing data.');
},
};
```
**Used in:** `sync-all-element-type-defaults`
---
## 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 |
### 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 |
### Table Renames
| 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 |
| `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 |
---
## 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` |
```javascript
// CASCADE - delete assets when project is deleted
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');
```
---
## Best Practices
### 1. Always Use Transactions
```javascript
const transaction = await queryInterface.sequelize.transaction();
try {
// ... operations
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
```
### 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')`
);
```
### 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) {
throw new Error(`Cannot drop table: contains ${count} records`);
}
```
### 5. Reversible Operations
```javascript
// Down migration should restore previous state
async down(queryInterface, Sequelize) {
// Recreate everything that was dropped
await queryInterface.createTable('table_name', { ... });
await queryInterface.addIndex('table_name', [...]);
}
```
### 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 } }
);
// Avoid - SQL injection risk
await queryInterface.sequelize.query(
`UPDATE table SET column = '${unsafeValue}' WHERE id = '${unsafeId}'`
);
```
---
## Running Migrations
### Development
```bash
cd backend
npm run db:migrate
```
### Server Startup
Migrations run automatically via `npm start`:
```json
{
"scripts": {
"start": "npm run db:migrate && npm run db:seed && npm run watch"
}
}
```
### Migration Status
```bash
npm run db:migrate:status
```
### Undo Migrations
```bash
# Undo last migration
npm run db:migrate:undo
# Undo all migrations (dangerous!)
npm run db:migrate:undo:all
```
### Create New Migration
Create new migration files manually under `backend/src/db/migrations/` only
when a schema change is required. Keep every migration reversible or document an
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 |
---
## Related Documentation
- [DB Models](./db-models.md) - Sequelize model definitions
- [DB API](./db-api.md) - Database access layer
- [Database Schema](../database-schema.md) - Complete schema reference

View File

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

View File

@ -0,0 +1,575 @@
# DB Seeders Module
## Overview
The DB Seeders module provides initial data population for the database using
the typed **Umzug v3** runner in `backend/src/db/umzug.ts`. Seeders create
essential system data (users, roles, permissions) and optional sample data for
development and testing.
**Location:** `backend/src/db/seeders/`
**Files:** 3 TypeScript ESM seeder entries. There are no JavaScript seeder
bridges after the backend ESM migration.
---
## Architecture
```
backend/src/db/seeders/
├── 20200430130759-admin-user.ts # Initial admin users
├── 20200430130760-user-roles.ts # RBAC roles + permissions
└── 20231127130745-sample-data.ts # Sample data, opt-in
```
**Execution Order:** Seeders run in timestamp order (oldest first).
---
## Configuration
### NPM Scripts
```bash
# Run pending seeders
npm run db:seed
# Undo all seeders
npm run db:seed:undo
# Full database reset (includes seeding)
npm run db:reset
```
### Server Startup
Seeders run automatically via `npm start`:
```json
{
"scripts": {
"start": "npm run db:migrate && npm run db:seed && npm run watch"
}
}
```
---
## Seeder Files
### 1. Admin User Seeder (`20200430130759-admin-user.ts`)
**Purpose:** Create initial system users.
The implementation uses reusable contracts from
`backend/src/types/db-seeders.ts`. Umzug executes the TypeScript source in
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 |
**Key Features:**
- Uses bcrypt for password hashing with configured salt rounds
- Hardcoded UUIDs for consistent user IDs
- Reads credentials from `config.ts` (environment variables)
- Idempotent on repeated `db:seed`: existing seed user IDs are selected first,
and only missing rows are inserted. This prevents duplicate-key failures when
`SequelizeData` lacks the seeder entry but users already exist.
```typescript
const existingRows =
await queryInterface.sequelize.query<AdminUserSeedExistingIdRow>(
'SELECT "id" FROM "users" WHERE "id" IN (:ids)',
{
replacements: { ids: seedUserIds },
type: QueryTypes.SELECT,
},
);
const existingIds = new Set(existingRows.map((row) => row.id));
const rowsToInsert = createAdminUserRows().filter(
(row) => !existingIds.has(row.id),
);
if (rowsToInsert.length > 0) {
await queryInterface.bulkInsert('users', rowsToInsert);
}
```
---
### 2. User Roles Seeder (`20200430130760-user-roles.ts`)
**Purpose:** Create the complete RBAC (Role-Based Access Control) system.
The implementation uses reusable contracts from
`backend/src/types/db-seeders.ts`. Umzug stores stable seeder execution names
in `SequelizeData` so repeated startup seeding does not rerun completed seed
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 |
**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
repeated `db:seed` safe.
- Keeps the existing startup behavior that assigns system users to seeded roles
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
];
```
#### Permission Generation Pattern
```javascript
// Generates CREATE, READ, UPDATE, DELETE permissions per entity
function createPermissions(name) {
return [
{ name: `CREATE_${name.toUpperCase()}` },
{ name: `READ_${name.toUpperCase()}` },
{ name: `UPDATE_${name.toUpperCase()}` },
{ name: `DELETE_${name.toUpperCase()}` },
];
}
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'
];
// Creates 52 permissions (13 entities × 4 CRUD operations)
await queryInterface.bulkInsert('permissions', entities.flatMap(createPermissions));
// Plus special permissions
await queryInterface.bulkInsert('permissions', [
{ name: 'READ_API_DOCS' },
{ name: 'CREATE_SEARCH' },
]);
```
#### ID Map Pattern
```javascript
// Consistent UUID generation using key-based map
const idMap = new Map();
function getId(key) {
if (idMap.has(key)) {
return idMap.get(key);
}
const id = uuid();
idMap.set(key, id);
return id;
}
// Usage - same key always returns same UUID within seeder run
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** | - | - | - | - | - |
**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(`
CREATE TABLE IF NOT EXISTS "rolesPermissionsPermissions" (
"createdAt" timestamp with time zone not null,
"updatedAt" timestamp with time zone not null,
"roles_permissionsId" uuid not null,
"permissionId" uuid not null,
primary key ("roles_permissionsId", "permissionId"),
constraint "rolesPermissionsPermissions_roles_permissions_fk"
foreign key ("roles_permissionsId") references "roles"("id")
on delete cascade on update cascade,
constraint "rolesPermissionsPermissions_permission_fk"
foreign key ("permissionId") references "permissions"("id")
on delete cascade on update cascade
);
`);
```
---
### 3. Sample Data Seeder (`20231127130745-sample-data.ts`)
**Purpose:** Create sample data for development and testing.
The implementation lives in `20231127130745-sample-data.ts`. Demo-only
Sequelize operations use reusable `SampleDataModel` and
`SampleDataAssociationRecord` contracts from
`backend/src/types/db-seeders.ts`; the production service model overloads stay
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
npm run db:seed
```
**Check in Code:**
```typescript
const sampleDataSeeder: SequelizeSeeder = {
async up() {
if (process.env.ENABLE_SAMPLE_DATA !== 'true') return;
// ... seed data
},
};
```
**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 |
#### Sample Projects
```javascript
const ProjectsData = [
{
name: 'Cardiff Arena Tour',
slug: 'cardiff-arena',
description: 'Interactive arena tour for visitors and event planners.',
logo_url: 'https://cdn.platform.com/cardiff/logo.png',
favicon_url: 'https://cdn.platform.com/cardiff/favicon.ico',
og_image_url: 'https://cdn.platform.com/cardiff/og.jpg',
},
{
name: 'Riverside Park Walkthrough',
slug: 'riverside-park',
description: 'Offline-ready guided walkthrough for the city park.',
// ...
},
{
name: 'Mall Central Experience',
slug: 'mall-central',
description: 'Retail complex presentation with navigation and galleries.',
// ...
},
];
```
#### Association Helper Pattern
```javascript
// Associates records after bulk creation using Sequelize model methods
async function associateAssetWithProject() {
const relatedProject = await Projects.findOne({
offset: Math.floor(Math.random() * (await Projects.count())),
});
const asset = await Assets.findOne({
order: [['id', 'ASC']],
offset: 0,
});
if (asset?.setProject) {
await asset.setProject(relatedProject);
}
}
```
---
## Seeder Patterns
### 1. bulkInsert Pattern
**Purpose:** Insert multiple records efficiently.
```javascript
await queryInterface.bulkInsert('tableName', [
{
id: uuid(),
field1: 'value1',
createdAt: new Date(),
updatedAt: new Date(),
},
// ... more records
]);
```
### 2. bulkDelete Pattern
**Purpose:** Remove seeded data during rollback.
```javascript
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('users', {
id: { [Sequelize.Op.in]: ids }
});
}
```
### 3. Conditional Execution Pattern
**Purpose:** Enable/disable seeders based on environment.
Seeder-only environment gates are intentionally allowed to read `process.env`
directly. Runtime app configuration must go through `backend/src/config.ts`,
but seeders and scripts are bootstrap/data-loading entrypoints and are listed
as exceptions in `AGENTS.md`.
The resilience/config hardening work does not require seeder changes because it
does not introduce database schema, RBAC permissions, seed users, roles, default
records, or sample-data entities.
```javascript
up: async () => {
if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
return; // Skip seeding
}
// ... proceed with seeding
}
```
### 4. ID Consistency Pattern
**Purpose:** Use deterministic IDs for reliable down() migrations.
```javascript
// Hardcoded IDs for rollback capability
const ids = [
'193bf4b5-9f07-4bd5-9a43-e7e41f3e96af',
'af5a87be-8f9c-4630-902a-37a60b7005ba',
];
// OR: Map-based consistent generation
const idMap = new Map();
function getId(key) {
if (!idMap.has(key)) {
idMap.set(key, uuid());
}
return idMap.get(key);
}
```
### 5. Model-Based Association Pattern
**Purpose:** Create relationships using Sequelize models after bulk insert.
```javascript
// Use model methods for associations (more readable)
const project = await Projects.findOne({ where: { slug: 'test' } });
const asset = await Assets.findOne({ order: [['id', 'ASC']] });
await asset.setProject(project);
```
### 6. Raw SQL Pattern
**Purpose:** Create structures not managed by Sequelize models.
```javascript
// Create join table directly via SQL
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS "rolesPermissionsPermissions" (
...
);
`);
// Create indexes
await queryInterface.sequelize.query(
'CREATE INDEX IF NOT EXISTS "index_name" ON "tableName" ("columnName");'
);
```
---
## Execution Flow
```
npm run db:seed
┌────────────────────────────────────┐
│ 20200430130759-admin-user.ts │
│ ├─ Typed ESM source │
│ ├─ Hash passwords │
│ └─ Insert missing seed users │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 20200430130760-user-roles.ts │
│ ├─ Typed ESM source │
│ ├─ Insert missing roles │
│ ├─ Insert missing permissions │
│ ├─ Create join table │
│ ├─ Insert missing RBAC links │
│ └─ Update user app_roleId │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 20231127130745-sample-data.ts │
│ ├─ Check ENABLE_SAMPLE_DATA │
│ ├─ Skip if not enabled │
│ ├─ Insert sample records │
│ └─ Create associations │
└────────────────────────────────────┘
```
---
## 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 })));
// Down migration can target specific IDs
await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } });
```
### 2. Always Include Timestamps
```javascript
{
field: 'value',
createdAt: new Date(),
updatedAt: new Date(),
}
```
### 3. Handle Errors
```javascript
try {
await queryInterface.bulkInsert('users', [...]);
} catch (error) {
console.error('Error during bulkInsert:', error);
throw error;
}
```
### 4. Environment-Aware Seeding
```javascript
// Production - only essential data
// Development - include sample data
if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
return;
}
```
### 5. Idempotent Where Possible
```javascript
// Use IF NOT EXISTS for table/index creation
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS "tableName" (...)
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS "indexName" ON "tableName" (...)
`);
```
---
## Data Dependencies
```
admin-user.ts
│ Creates missing users with stable IDs
user-roles.ts
│ Creates/reuses roles and permissions, then references user emails to assign roles
│ UPDATE users SET app_roleId = '...' WHERE email = '...'
sample-data.ts
│ Uses Sequelize models to find existing users/projects
│ Creates associations via model methods
```
---
## 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
```
---
## 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) |
---
## 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) |
---
## Related Documentation
- [DB Migrations](./db-migrations.md) - Schema evolution
- [DB Models](./db-models.md) - Sequelize model definitions
- [RBAC System](../../../documentation/rbac-system.md) - Role-based access control details
- [Authentication](./auth.md) - User authentication

View File

@ -0,0 +1,961 @@
# Backend Email Module
## Overview
The Email module provides transactional email functionality for user authentication flows including email verification, password reset, and user invitations. It uses **Nodemailer** with **AWS SES** (Simple Email Service) as the SMTP transport.
**Location:** `backend/src/services/email/`
**Total Files:** 7
---
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ Auth Service │
│ (services/auth.ts) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ signup() │ │ signin() │ │ sendPasswordReset() │ │
│ └────────┬────────┘ └─────────────────┘ └──────────┬──────────┘ │
│ │ │ │
│ │ sends verification │ sends reset │
│ ▼ ▼ │
└───────────┼───────────────────────────────────────────┼─────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Email Module │
│ (services/email/) │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ EmailSender │ │
│ │ (index.js) │ │
│ │ • Nodemailer transport │ │
│ │ • AWS SES configuration │ │
│ │ • send() method │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │PasswordReset │ │ AddressVerif. │ │ Invitation │ │
│ │ Email │ │ Email │ │ Email │ │
│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ passwordReset │ │ addressVerif. │ │ invitation │ │
│ │ Email.html │ │ Email.html │ │ Template.html │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ AWS SES (SMTP) │
│ email-smtp.us-east-1.amazonaws.com:587 │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Directory Structure
```
services/email/
├── index.js # EmailSender class (45 LOC)
├── list/ # Email template classes
│ ├── passwordReset.ts # Password reset email
│ ├── addressVerification.ts # Email verification email
│ └── invitation.ts # User invitation email
└── htmlTemplates/ # HTML email templates
├── passwordReset/
│ └── passwordResetEmail.html (52 LOC)
├── addressVerification/
│ └── emailAddressVerification.html (52 LOC)
└── invitation/
└── invitationTemplate.html (55 LOC)
```
---
## Core Components
### EmailSender Class (`index.ts`)
The main email sending service using Nodemailer.
```typescript
import nodemailer from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import config from '../../config.ts';
import type { EmailSendResult, EmailTemplate } from '../../types/index.js';
export default class EmailSender {
constructor(private readonly email: EmailTemplate) {}
async send(): Promise<EmailSendResult> {
const htmlContent = await this.email.html();
const transporter = nodemailer.createTransport(this.transportConfig);
const mailOptions: SMTPTransport.MailOptions = {
from: this.from,
to: this.email.to,
subject: this.email.subject,
html: htmlContent,
headers: {
'X-SES-CONFIGURATION-SET': 'flatlogic-app',
},
};
return transporter.sendMail(mailOptions);
}
static get isConfigured(): boolean {
return Boolean(config.email.auth.pass && config.email.auth.user);
}
get transportConfig(): SMTPTransport.Options {
return config.email;
}
get from(): string {
return config.email.from;
}
}
```
**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 |
---
## Email Templates
### Template Interface
All email templates implement the same interface:
```typescript
interface EmailTemplate {
to: string;
subject: string;
html(): Promise<string> | string;
}
```
### 1. Password Reset Email (`list/passwordReset.ts`)
Sent when user requests password reset.
```typescript
export default class PasswordResetEmail implements EmailTemplate {
constructor({ to, link }: LinkEmailTemplateOptions) {
this.to = to;
this.link = link;
}
get subject() {
return getNotification(
'emails.passwordReset.subject',
getNotification('app.title')
);
// → "Reset your password for Tour Builder Platform"
}
async html() {
const template = await fs.readFile(templatePath, 'utf8');
return template
.replace(/{appTitle}/g, appTitle)
.replace(/{resetUrl}/g, this.link)
.replace(/{accountName}/g, this.to);
}
};
```
**Template Variables:**
- `{appTitle}` - Application name
- `{resetUrl}` - Password reset link
- `{accountName}` - User email address
### 2. Email Verification (`list/addressVerification.ts`)
Sent after user registration.
```typescript
export default class EmailAddressVerificationEmail implements EmailTemplate {
constructor({ to, link }: LinkEmailTemplateOptions) {
this.to = to;
this.link = link;
}
get subject() {
return getNotification(
'emails.emailAddressVerification.subject',
getNotification('app.title')
);
// → "Verify your email for Tour Builder Platform"
}
async html() {
const template = await fs.readFile(templatePath, 'utf8');
return template
.replace(/{appTitle}/g, appTitle)
.replace(/{signupUrl}/g, this.link)
.replace(/{to}/g, this.to);
}
};
```
**Template Variables:**
- `{appTitle}` - Application name
- `{signupUrl}` - Email verification link
- `{to}` - User email address
### 3. User Invitation (`list/invitation.ts`)
Sent when admin invites new user.
```typescript
export default class InvitationEmail implements EmailTemplate {
constructor({ to, host }: InvitationEmailTemplateOptions) {
this.to = to;
this.host = host;
}
get subject() {
return getNotification(
'emails.invitation.subject',
getNotification('app.title')
);
// → "You've been invited to Tour Builder Platform"
}
async html() {
const template = await fs.readFile(templatePath, 'utf8');
const signupUrl = `${this.host}&invitation=true`;
return template
.replace(/{appTitle}/g, appTitle)
.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
---
## HTML Email Templates
### Template Structure
All HTML templates follow consistent styling:
```html
<!DOCTYPE html>
<html>
<head>
<style>
.email-container {
max-width: 600px;
margin: auto;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.email-header {
background-color: #3498db; /* Primary blue */
color: #fff;
padding: 16px;
text-align: center;
}
.email-body {
padding: 16px;
}
.email-footer {
padding: 16px;
background-color: #f7fafc;
text-align: center;
color: #4a5568;
font-size: 14px;
}
.link-primary {
color: #3498db;
text-decoration: none;
}
.btn-primary {
background-color: #3498db;
color: #fff !important;
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
display: inline-block;
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">...</div>
<div class="email-body">...</div>
<div class="email-footer">
Thanks,<br/>
The {appTitle} Team
</div>
</div>
</body>
</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 |
---
## Configuration
### SMTP Settings (`config.ts`)
```javascript
email: {
from: 'Tour Builder Platform <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {
user: process.env.EMAIL_USER || '',
pass: process.env.EMAIL_PASS,
},
tls: {
rejectUnauthorized: process.env.EMAIL_TLS_REJECT_UNAUTHORIZED !== 'false',
},
}
```
### 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 |
### AWS SES Configuration
The system uses AWS SES in the `us-east-1` region:
```
Host: email-smtp.us-east-1.amazonaws.com
Port: 587 (STARTTLS)
Authentication: SMTP credentials from IAM
Configuration Set: flatlogic-app
```
---
## Integration Points
### Auth Service Integration
The Auth service (`services/auth.ts`) is the primary consumer:
```typescript
import EmailAddressVerificationEmail from './email/list/addressVerification.ts';
import InvitationEmail from './email/list/invitation.ts';
const PasswordResetEmail = require('./email/list/passwordReset.ts').default;
const EmailSender = require('./email/index.ts').default;
class Auth {
// Called during signup
static async sendEmailAddressVerificationEmail(email, host) {
const token = await UsersDBApi.generateEmailVerificationToken(email);
const link = `${host}/verify-email?token=${token}`;
const emailObj = new EmailAddressVerificationEmail({ to: email, link });
return new EmailSender(emailObj).send();
}
// Called for password reset or invitation
static async sendPasswordResetEmail(email, type = 'register', host) {
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 });
return new EmailSender(emailObj).send();
}
}
```
### Users Service Integration
User invitations are sent when creating users:
```javascript
// services/users.ts
static async create({ data, currentUser, sendInvitationEmails = true, host }) {
// ... create user ...
if (sendInvitationEmails) {
AuthService.sendPasswordResetEmail(email, 'invitation', host);
}
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
// ... import users from CSV ...
if (!sendInvitationEmails) {
emailsToInvite.forEach((email) => {
AuthService.sendPasswordResetEmail(email, 'invitation', host);
});
}
}
```
### Auth Routes Integration
Auth routes expose email functionality:
```javascript
// routes/auth.js
// Check if email is configured (public)
router.get('/email-configured', (req, res) => {
const payload = EmailSender.isConfigured;
res.status(200).send(payload);
});
// 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);
});
// Request password reset (public)
router.put('/send-password-reset-email', async (req, res) => {
const host = getRequestHost(req);
await AuthService.sendPasswordResetEmail(req.body.email, 'register', host);
res.status(200).send(true);
});
```
---
## Token Management
### Token Generation
Tokens are generated in `db/api/users.js`:
```javascript
static async _generateToken(keyNames, email, options) {
const users = await db.users.findOne({
where: { email: email.toLowerCase() },
transaction,
});
const token = crypto.randomBytes(20).toString('hex');
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const tokenExpiresAt = Date.now() + TOKEN_EXPIRY_MS;
if (users) {
await users.update({
[keyNames[0]]: token, // emailVerificationToken or passwordResetToken
[keyNames[1]]: tokenExpiresAt, // ...ExpiresAt
updatedById: currentUser.id,
}, { transaction });
}
return token;
}
static async generateEmailVerificationToken(email, options) {
return this._generateToken(
['emailVerificationToken', 'emailVerificationTokenExpiresAt'],
email, options
);
}
static async generatePasswordResetToken(email, options) {
return this._generateToken(
['passwordResetToken', 'passwordResetTokenExpiresAt'],
email, options
);
}
```
### Token Validation
```javascript
static async findByPasswordResetToken(token, options) {
return db.users.findOne({
where: {
passwordResetToken: token,
passwordResetTokenExpiresAt: {
[db.Sequelize.Op.gt]: Date.now(), // Not expired
},
},
transaction,
});
}
static async findByEmailVerificationToken(token, options) {
return db.users.findOne({
where: {
emailVerificationToken: token,
emailVerificationTokenExpiresAt: {
[db.Sequelize.Op.gt]: Date.now(),
},
},
transaction,
});
}
static async markEmailVerified(id, options) {
const user = await db.users.findByPk(id, { transaction });
await user.update({
emailVerified: true,
emailVerificationToken: null,
emailVerificationTokenExpiresAt: null,
}, { transaction });
}
```
### Token Properties
| Token Type | Field | Expiry Field | TTL |
|------------|-------|--------------|-----|
| Email Verification | `emailVerificationToken` | `emailVerificationTokenExpiresAt` | 24 hours |
| Password Reset | `passwordResetToken` | `passwordResetTokenExpiresAt` | 24 hours |
---
## Email Flows
### 1. User Registration Flow
```
┌────────────┐ ┌──────────────┐ ┌─────────────┐
│ User │────▶│ POST /auth │────▶│ Auth.signup │
│ Signs Up │ │ /signup │ │ │
└────────────┘ └──────────────┘ └──────┬──────┘
┌──────────────────────────────────────────┐
│ if (EmailSender.isConfigured) { │
│ await sendEmailAddressVerification(); │
│ } │
└───────────────────┬──────────────────────┘
┌──────────────────┼──────────────────┐
▼ ▼
┌────────────────┐ ┌────────────────┐
│ Generate Token │ │ Skip │
│ (24hr expiry) │ │ (not configured)│
└───────┬────────┘ └────────────────┘
┌────────────────┐
│ Send Email │
│ (verification) │
└───────┬────────┘
┌────────────────┐
│ User clicks │
│ /verify-email │
│ ?token=xxx │
└───────┬────────┘
┌────────────────┐
│ Auth.verifyEmail│
│ markEmailVerified│
└────────────────┘
```
### 2. Password Reset Flow
```
┌────────────┐ ┌────────────────────┐ ┌───────────────────────┐
│ User │────▶│ PUT /auth/send- │────▶│ Auth.sendPassword │
│ Forgot Pwd │ │ password-reset-email│ │ ResetEmail() │
└────────────┘ └────────────────────┘ └───────────┬───────────┘
┌───────────────────────┐
│ generatePasswordReset │
│ Token (24hr) │
└───────────┬───────────┘
┌───────────────────────┐
│ Send Email │
│ (PasswordResetEmail) │
└───────────┬───────────┘
┌───────────────────────┐
│ User clicks │
│ /password-reset │
│ ?token=xxx │
└───────────┬───────────┘
┌───────────────────────┐
│ PUT /auth/password- │
│ reset │
│ { token, password } │
└───────────────────────┘
```
### 3. User Invitation Flow
```
┌────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Admin │────▶│ POST /users │────▶│ UsersService │
│ Creates │ │ { email } │ │ .create() │
│ User │ └──────────────┘ └────────┬────────┘
└────────────┘ │
┌─────────────────────────┐
│ AuthService.sendPassword│
│ ResetEmail(email, │
│ 'invitation', host) │
└────────────┬────────────┘
┌─────────────────────────┐
│ InvitationEmail │
│ (Welcome to {appTitle}!)│
└────────────┬────────────┘
┌─────────────────────────┐
│ User clicks link │
│ /password-reset?token= │
│ xxx&invitation=true │
└────────────┬────────────┘
┌─────────────────────────┐
│ Sets password │
│ (account activated) │
└─────────────────────────┘
```
---
## Email Behavior Modes
### 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
### 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
- Frontend adapts UI accordingly
```javascript
// Auth service adapts behavior
static async signin(email, password) {
// ...
if (!EmailSender.isConfigured) {
user.emailVerified = true; // Auto-verify when email disabled
}
if (!user.emailVerified) {
throw new ValidationError('auth.userNotVerified');
}
// ...
}
```
---
## Notification Catalog
Email subjects use the notification system (`services/notifications/list.ts`):
```javascript
emails: {
invitation: {
subject: "You've been invited to {0}",
body: `
<p>Hello,</p>
<p>You've been invited to {0} set password for your {1} account.</p>
<p><a href='{2}'>{2}</a></p>
<p>Thanks,</p>
<p>Your {0} team</p>
`,
},
emailAddressVerification: {
subject: "Verify your email for {0}",
body: `
<p>Hello,</p>
<p>Follow this link to verify your email address.</p>
<p><a href='{0}'>{0}</a></p>
<p>If you didn't ask to verify this address, you can ignore this email.</p>
<p>Thanks,</p>
<p>Your {1} team</p>
`,
},
passwordReset: {
subject: "Reset your password for {0}",
body: `
<p>Hello,</p>
<p>Follow this link to reset your {0} password for your {1} account.</p>
<p><a href='{2}'>{2}</a></p>
<p>If you didn't ask to reset your password, you can ignore this email.</p>
<p>Thanks,</p>
<p>Your {0} team</p>
`,
},
}
```
---
## Error Handling
### 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 Flow
```javascript
static async sendEmailAddressVerificationEmail(email, host) {
let link;
try {
const token = await UsersDBApi.generateEmailVerificationToken(email);
link = `${host}/verify-email?token=${token}`;
} catch (error) {
console.error(error);
throw new ValidationError('auth.emailAddressVerificationEmail.error');
}
// Create and send email
const emailObj = new EmailAddressVerificationEmail({ to: email, link });
return new EmailSender(emailObj).send();
}
```
---
## Testing
### Unit Testing Email Templates
```javascript
describe('PasswordResetEmail', () => {
it('should generate correct subject', () => {
const email = new PasswordResetEmail({
to: 'user@example.com',
link: 'https://app.com/reset?token=abc',
});
expect(email.subject).toBe('Reset your password for Tour Builder Platform');
});
it('should render HTML with placeholders', async () => {
const email = new PasswordResetEmail({
to: 'user@example.com',
link: 'https://app.com/reset?token=abc',
});
const html = await email.html();
expect(html).toContain('Tour Builder Platform');
expect(html).toContain('https://app.com/reset?token=abc');
expect(html).toContain('user@example.com');
});
});
```
### Integration Testing Email Sending
```javascript
describe('EmailSender', () => {
it('should validate required fields', async () => {
const sender = new EmailSender({});
await expect(sender.send()).rejects.toThrow('email.to is required');
});
it('should send email via nodemailer', async () => {
const mockTransport = { sendMail: jest.fn().mockResolvedValue({}) };
jest.spyOn(nodemailer, 'createTransport').mockReturnValue(mockTransport);
const email = new PasswordResetEmail({
to: 'test@example.com',
link: 'https://example.com',
});
const sender = new EmailSender(email);
await sender.send();
expect(mockTransport.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'test@example.com',
subject: expect.stringContaining('Reset your password'),
})
);
});
});
```
### Testing Without SMTP
Set credentials to empty for local development:
```bash
EMAIL_USER=
EMAIL_PASS=
```
Result:
- `EmailSender.isConfigured` returns `false`
- Users auto-verified on login
- No emails sent
---
## Customization
### Adding New Email Template
1. **Create HTML template:**
```html
<!-- services/email/htmlTemplates/welcome/welcomeEmail.html -->
<!DOCTYPE html>
<html>
<head>
<style>/* Same styles as other templates */</style>
</head>
<body>
<div class="email-container">
<div class="email-header">Welcome to {appTitle}!</div>
<div class="email-body">
<p>Hello {userName},</p>
<p>Your account has been activated.</p>
</div>
<div class="email-footer">Thanks,<br/>The {appTitle} Team</div>
</div>
</body>
</html>
```
2. **Create email class:**
```javascript
// services/email/list/welcome.ts
import { promises as fs } from 'fs';
import path from 'path';
import { getNotification } from '../../notifications/helpers.ts';
import type { EmailTemplate } from '../../../types/index.js';
interface WelcomeEmailOptions {
to: string;
userName: string;
}
export default class WelcomeEmail implements EmailTemplate {
to: string;
private readonly userName: string;
constructor({ to, userName }: WelcomeEmailOptions) {
this.to = to;
this.userName = userName;
}
get subject(): string {
return getNotification('emails.welcome.subject', getNotification('app.title'));
}
async html(): Promise<string> {
const templatePath = path.resolve(
process.cwd(),
'src/services/email/htmlTemplates/welcome/welcomeEmail.html',
);
const template = await fs.readFile(templatePath, 'utf8');
const appTitle = getNotification('app.title');
return template
.replace(/{appTitle}/g, appTitle)
.replace(/{userName}/g, this.userName);
}
};
```
3. **Add notification entry:**
```typescript
// services/notifications/list.ts
emails: {
// ... existing ...
welcome: {
subject: "Welcome to {0}!",
body: "...",
},
}
```
4. **Use in service:**
```typescript
import WelcomeEmail from './email/list/welcome.ts';
const email = new WelcomeEmail({ to: 'user@example.com', userName: 'John' });
await new EmailSender(email).send();
```
---
## Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| `nodemailer` | ^6.x | SMTP transport |
| `assert` | built-in | Input validation |
| `fs.promises` | built-in | Template file reading |
| `path` | built-in | Template path resolution |
---
## Related Documentation
- [Auth Module](./auth.md) - Authentication service integration
- [Services Module](./services.md) - Service layer overview
- [Notifications Module](./notifications.md) - Error messages and i18n
- [User Management](../../../documentation/user-management.md) - User invitation flow

View File

@ -0,0 +1,846 @@
# Backend Factories Module
## Overview
The Factories module provides code generation patterns that eliminate boilerplate for standard CRUD operations across the backend. Two factory functions generate consistent, standardized router and service classes for all entity types.
**Location:** `backend/src/factories/`
**Files:**
| File | Purpose | LOC |
|------|---------|-----|
| `router.factory.ts` | Generates Express routers with CRUD endpoints | 429 |
| `service.factory.ts` | Generates service classes with transaction handling | 350 |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Factory Pattern Flow │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Route File │ │ Service File │ │ DB API File │
│ (3-5 lines) │ │ (3-5 lines) │ │ (extends base) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ createEntity │ │ createEntity │ │ GenericDBApi │
│ Router() │ │ Service() │ │ (base.api.ts) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└──────────────────────┼──────────────────────┘
┌───────────────────────┐
│ Sequelize Model │
│ (db/models/*.js) │
└───────────────────────┘
Boilerplate Reduction:
- Without factories: ~300-500 lines per entity
- With factories: ~10-20 lines per entity (97% reduction)
```
---
## Factory Files
### router.factory.ts
**Purpose:** Generates Express routers with standardized CRUD endpoints, permission checking, CSV export, and error handling.
**Location:** `backend/src/factories/router.factory.ts`
#### Function Signature
```javascript
function createEntityRouter(entityName, Service, DBApi, options = {})
```
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `entityName` | `string` | Entity name for routes and permissions |
| `Service` | `class` | Service class with CRUD methods |
| `DBApi` | `class` | Database API class extending GenericDBApi |
| `options` | `object` | Configuration options |
#### Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `permissionEntity` | `string` | `entityName` | Override permission entity name |
| `csvFields` | `string[]` | `DBApi.CSV_FIELDS` | Fields to include in CSV export |
| `customRoutes` | `function` | `null` | Callback to add custom routes |
#### Generated Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/` | Create new record |
| `POST` | `/bulk-import` | Bulk import from CSV |
| `PUT` | `/:id` | Update record by ID |
| `DELETE` | `/:id` | Delete record by ID |
| `POST` | `/deleteByIds` | Delete multiple records |
| `GET` | `/` | List all records (with filters, pagination) |
| `GET` | `/count` | Get record count |
| `GET` | `/autocomplete` | Get autocomplete suggestions |
| `GET` | `/:id` | Get single record by ID |
#### Implementation
```javascript
const express = require('express');
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
const { checkCrudPermissions } = require('../middlewares/check-permissions');
const { parse } = require('json2csv');
function createEntityRouter(entityName, Service, DBApi, options = {}) {
const router = express.Router();
// Apply CRUD permission middleware for all routes
const permissionEntity = options.permissionEntity || entityName;
router.use(checkCrudPermissions(permissionEntity));
// POST / - Create
router.post('/', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
const payload = await Service.create({
data: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
sendInvitationEmails: true,
host: link.host,
});
res.status(200).send(payload);
}));
// POST /bulk-import - Bulk CSV import
router.post('/bulk-import', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await Service.bulkImport(req, res, true, link.host);
res.status(200).send(true);
}));
// PUT /:id - Update
router.put('/:id', wrapAsync(async (req, res) => {
assertRouteIdMatchesBody(req);
await Service.update({
id: req.params.id,
data: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(true);
}));
// DELETE /:id - Delete single
router.delete('/:id', wrapAsync(async (req, res) => {
await Service.remove({
id: req.params.id,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(true);
}));
// POST /deleteByIds - Delete multiple
router.post('/deleteByIds', wrapAsync(async (req, res) => {
await Service.deleteByIds({
ids: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(true);
}));
// GET / - List all with optional CSV export
router.get('/', wrapAsync(async (req, res) => {
const filetype = req.query.filetype;
const currentUser = req.currentUser;
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi, {
csv: filetype === 'csv',
}), { currentUser, runtimeContext });
if (filetype === 'csv') {
const fields = options.csvFields || DBApi.CSV_FIELDS || ['id', 'createdAt'];
const opts = { fields };
try {
const csv = parse(payload.rows, opts);
res.status(200).attachment('export.csv').send(csv);
} catch (err) {
logger.error({ err, entityName }, 'CSV export error');
res.status(500).send('CSV export error');
}
} else {
res.status(200).send(payload);
}
}));
// GET /count - Count only
router.get('/count', wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi), { countOnly: true, currentUser, runtimeContext });
res.status(200).send(payload);
}));
// GET /autocomplete - Autocomplete search
router.get('/autocomplete', wrapAsync(async (req, res) => {
const payload = await DBApi.findAllAutocomplete({
query: req.query.query,
limit,
offset: req.query.offset,
});
res.status(200).send(payload);
}));
// GET /:id - Find by ID
router.get('/:id', wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send(`Invalid ${entityName} id`);
}
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findBy({ id: req.params.id }, { runtimeContext });
res.status(200).send(payload);
}));
// Custom routes hook
if (options.customRoutes) {
options.customRoutes(router, Service, DBApi);
}
// Error handler
router.use('/', commonErrorHandler);
return router;
}
module.exports = { createEntityRouter, isUuidV4 };
```
Generic CRUD query safety:
- `PUT /:id` uses `req.params.id` as the canonical id and rejects mismatched
body ids.
- List and count queries default to `limit=50`, max `limit=1000`, and sanitize
sort direction to `ASC` or `DESC`.
- Sort fields are accepted only when present in the model's `rawAttributes` or
`DBApi.SORTABLE_FIELDS`.
- CSV export uses the same auth path as list and is capped at `limit=1000`.
- Autocomplete defaults to `limit=20` and is capped at `limit=50`.
#### Exports
| Export | Type | Description |
|--------|------|-------------|
| `createEntityRouter` | `function` | Factory function |
| `isUuidV4` | `function` | UUID validation helper |
---
### service.factory.ts
**Purpose:** Generates service classes with standardized CRUD operations wrapped in database transactions.
**Location:** `backend/src/factories/service.factory.ts`
#### Function Signature
```javascript
function createEntityService(DBApi, options = {})
```
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `DBApi` | `class` | Database API class extending GenericDBApi |
| `options` | `object` | Configuration options |
#### Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `entityName` | `string` | `'Entity'` | Name used in error messages |
#### Generated Methods
| Method | Description |
|--------|-------------|
| `create({ data, currentUser, transaction, runtimeContext })` | Create record with transaction |
| `bulkImport(req, res)` | Bulk import from CSV with transaction |
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record with transaction |
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Delete multiple with transaction |
| `remove({ id, currentUser, transaction, runtimeContext })` | Delete single with transaction |
#### Implementation
```javascript
const db = require('../db/models');
const processFile = require('../middlewares/upload');
const ValidationError = require('../services/notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
function createEntityService(DBApi, options = {}) {
const entityName = options.entityName || 'Entity';
return class GenericService {
static async create({ data, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
const ownsTransaction = !externalTransaction;
try {
const record = await DBApi.create({ data, currentUser, transaction, runtimeContext });
if (ownsTransaction) await transaction.commit();
return record;
} catch (error) {
if (ownsTransaction) await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', () => resolve())
.on('error', (error) => reject(error));
});
await DBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update({ id, data, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
const ownsTransaction = !externalTransaction;
try {
const record = await DBApi.findBy({ id }, { transaction, runtimeContext });
if (!record) {
throw new ValidationError(`${entityName}NotFound`);
}
const updated = await DBApi.update({ id, data, currentUser, transaction, runtimeContext });
if (ownsTransaction) await transaction.commit();
return updated;
} catch (error) {
if (ownsTransaction) await transaction.rollback();
throw error;
}
}
static async deleteByIds({ ids, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
const ownsTransaction = !externalTransaction;
try {
await DBApi.deleteByIds({ ids, currentUser, transaction, runtimeContext });
if (ownsTransaction) await transaction.commit();
} catch (error) {
if (ownsTransaction) await transaction.rollback();
throw error;
}
}
static async remove({ id, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
const ownsTransaction = !externalTransaction;
try {
await DBApi.remove({ id, currentUser, transaction, runtimeContext });
if (ownsTransaction) await transaction.commit();
} catch (error) {
if (ownsTransaction) await transaction.rollback();
throw error;
}
}
};
}
module.exports = { createEntityService };
```
#### Transaction Pattern
All service methods follow the same transaction pattern:
```javascript
static async methodName(params) {
const transaction = await db.sequelize.transaction();
try {
// ... database operations with { transaction }
await transaction.commit();
return result;
} catch (error) {
await transaction.rollback();
throw error;
}
}
```
---
## Supporting Components
### helpers.js
**Location:** `backend/src/helpers.js`
Helper utilities used by the router factory:
```javascript
module.exports = class Helpers {
// Wrap async route handlers to propagate errors
static wrapAsync(fn) {
return function (req, res, next) {
fn(req, res, next).catch(next);
};
}
// Centralized error response handler
static commonErrorHandler(error, req, res, _next) {
const statusCode = error.code || error.status;
if ([400, 401, 403, 404, 409, 422].includes(statusCode)) {
return res.status(statusCode).send(error.message);
}
console.error(error);
return res.status(500).send('Internal server error');
}
// JWT token signing
static jwtSign(data) {
return jwt.sign(data, config.secret_key, { expiresIn: '6h' });
}
// UUID v4 validation
static isUuidV4(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
};
```
### check-permissions.ts
**Location:** `backend/src/middlewares/check-permissions.ts`
Permission checking middleware used by router factory:
```javascript
// HTTP method to permission action mapping
const METHOD_MAP = {
POST: 'CREATE',
GET: 'READ',
PUT: 'UPDATE',
PATCH: 'UPDATE',
DELETE: 'DELETE',
};
// Entities accessible publicly in runtime mode
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PROJECTS',
'TOUR_PAGES',
'PAGE_ELEMENTS',
'PAGE_LINKS',
'TRANSITIONS',
'PROJECT_AUDIO_TRACKS',
]);
// Generate permission name from HTTP method and entity
function checkCrudPermissions(name) {
return (req, res, next) => {
// Skip auth for public runtime read requests
const isRuntimePublicRead =
req.isRuntimePublicRequest === true &&
req.method === 'GET' &&
RUNTIME_PUBLIC_READ_ENTITIES.has(name.toUpperCase());
if (isRuntimePublicRead) {
return next();
}
// Build permission name: e.g., 'READ_ASSETS', 'CREATE_USERS'
const permissionName = `${METHOD_MAP[req.method]}_${name.toUpperCase()}`;
return checkPermissions(permissionName)(req, res, next);
};
}
```
---
## GenericDBApi (Base Class)
**Location:** `backend/src/db/api/base.api.ts`
The DB API base class that all entity APIs extend. Provides declarative configuration for CRUD operations.
### Static Getters (Override in Subclasses)
| Getter | Type | Description |
|--------|------|-------------|
| `MODEL` | `Model` | Sequelize model reference (required) |
| `TABLE_NAME` | `string` | Database table name |
| `SEARCHABLE_FIELDS` | `string[]` | Fields for text search (ILIKE) |
| `RANGE_FIELDS` | `string[]` | Fields for range filtering |
| `ENUM_FIELDS` | `string[]` | Fields for exact match filtering |
| `RELATION_FILTERS` | `object[]` | Related entity filters |
| `CSV_FIELDS` | `string[]` | Fields for CSV export |
| `AUTOCOMPLETE_FIELD` | `string` | Field for autocomplete |
| `ASSOCIATIONS` | `object[]` | Related entity setters |
| `FIND_BY_INCLUDES` | `object[]` | Includes for findBy |
| `FIND_ALL_INCLUDES` | `object[]` | Includes for findAll |
| `JSON_FIELDS` | `string[]` | Fields to auto-stringify |
| `FIELD_TRANSFORMERS` | `object` | Custom field transformers |
| `FIELD_DEFAULTS` | `object` | Default values for fields |
### Methods
| Method | Description |
|--------|-------------|
| `getFieldMapping(data)` | Transform input data for database |
| `create(data, options)` | Create record |
| `bulkImport(data, options)` | Bulk create records |
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record |
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple |
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single |
| `findBy(where, options)` | Find single by criteria |
| `findAll(filter, options)` | Find all with pagination/filters |
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
| `toCSV(rows)` | Convert to CSV string |
---
## Usage Examples
### Basic Entity (Minimal Configuration)
**Route (assets.ts):**
```typescript
import AssetsDBApi from '../db/api/assets.ts';
import { createEntityRouter } from '../factories/router.factory.ts';
import AssetsService from '../services/assets.ts';
// 1 line: generates 9 CRUD endpoints
export default createEntityRouter('assets', AssetsService, AssetsDBApi);
```
**Service (assets.ts):**
```typescript
import AssetsDBApi from '../db/api/assets.ts';
import { createEntityService } from '../factories/service.factory.ts';
// 1 line: generates service class with 5 transaction-wrapped methods
export default createEntityService(AssetsDBApi, {
entityName: 'assets',
});
```
**DB API (assets.js):**
```javascript
const GenericDBApi = require('./base.api');
const db = require('../models');
class AssetsDBApi extends GenericDBApi {
static get MODEL() {
return db.assets;
}
static get SEARCHABLE_FIELDS() {
return ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum'];
}
static get RANGE_FIELDS() {
return ['size_mb', 'width_px', 'height_px', 'duration_sec'];
}
static get ENUM_FIELDS() {
return ['asset_type', 'type', 'is_public'];
}
static get ASSOCIATIONS() {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static getFieldMapping(data) {
return {
name: data.name || null,
asset_type: data.asset_type || null,
type: data.type || 'general',
cdn_url: data.cdn_url || null,
// ... other fields
};
}
}
module.exports = AssetsDBApi;
```
### Entity with Custom Routes
**Route (project_element_defaults.ts):**
```typescript
import Service from '../services/project_element_defaults.ts';
import DBApi from '../db/api/project_element_defaults.ts';
import { createEntityRouter } from '../factories/router.factory.ts';
import { wrapAsync } from '../helpers.ts';
// Create base router
const baseRouter = createEntityRouter(
'project_element_defaults',
Service,
DBApi,
{ permissionEntity: 'page_elements' } // Override permission entity
);
// Add custom endpoint
baseRouter.post('/:id/reset', wrapAsync(async (req, res) => {
const payload = await Service.resetToGlobal(req.params.id, {
currentUser: req.currentUser,
});
res.status(200).json(payload);
}));
// Add another custom endpoint
baseRouter.get('/:id/diff', wrapAsync(async (req, res) => {
const payload = await Service.getDiffFromGlobal(req.params.id);
res.status(200).json(payload);
}));
export default baseRouter;
```
### Service with Extended Methods
**Service (project_element_defaults.ts):**
```typescript
import DBApi from '../db/api/project_element_defaults.ts';
import { createEntityService } from '../factories/service.factory.ts';
// Create base service class
const BaseService = createEntityService(DBApi, {
entityName: 'project_element_defaults',
});
// Extend with custom methods
export default class Project_element_defaultsService extends BaseService {
static resetToGlobal(id, options = {}) {
return DBApi.resetToGlobal(id, options);
}
static getDiffFromGlobal(id) {
return DBApi.getDiffFromGlobal(id);
}
static snapshotGlobalDefaults(projectId, options = {}) {
return DBApi.snapshotGlobalDefaults(projectId, options);
}
}
```
### Using customRoutes Callback
Alternative approach using the options callback:
```javascript
module.exports = createEntityRouter('entities', Service, DBApi, {
customRoutes: (router, Service, DBApi) => {
router.post('/:id/custom-action', wrapAsync(async (req, res) => {
const result = await Service.customAction(req.params.id);
res.status(200).json(result);
}));
router.get('/stats', wrapAsync(async (req, res) => {
const stats = await DBApi.getStatistics();
res.status(200).json(stats);
}));
}
});
```
---
## Entity Usage Summary
### Entities Using Router Factory (13)
| Entity | Permission Override | Custom Routes |
|--------|--------------------|--------------|
| `access_logs` | - | No |
| `asset_variants` | - | No |
| `assets` | - | No |
| `element_type_defaults` | - | No |
| `permissions` | - | No |
| `presigned_url_requests` | - | No |
| `project_audio_tracks` | - | No |
| `project_element_defaults` | `page_elements` | Yes (reset, diff) |
| `project_memberships` | - | No |
| `publish_events` | - | No |
| `pwa_caches` | - | No |
| `roles` | - | No |
| `tour_pages` | - | No |
### Entities Using Service Factory (11)
| Entity | Custom Methods |
|--------|---------------|
| `access_logs` | No |
| `asset_variants` | No |
| `assets` | No |
| `element_type_defaults` | No |
| `permissions` | No |
| `presigned_url_requests` | No |
| `pwa_caches` | No |
| `publish_events` | No |
| `tour_pages` | No |
| `project_element_defaults` | Yes (resetToGlobal, getDiffFromGlobal, snapshotGlobalDefaults) |
| `project_memberships` | No |
### Entities NOT Using Factories
Some entities have custom implementations due to specialized requirements:
| Entity | Reason |
|--------|--------|
| `users` | Complex auth, password hashing, token management |
| `projects` | Publishing workflow, complex business logic |
| `auth` | Authentication flows (login, OAuth, password reset) |
| `file` | File upload/download, S3/GCloud/Local storage |
| `search` | Full-text search across multiple entities |
| `publish` | Multi-step publishing workflow |
---
## Generated Endpoints Flow
```
HTTP Request
┌─────────────────────────────────┐
│ JWT Authentication │ (from index.js)
│ passport.authenticate('jwt') │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ checkCrudPermissions() │ (from router.factory.ts)
│ - Maps HTTP method to action │
│ - Builds permission name │
│ - Checks user role permissions │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ Route Handler │ (from router.factory.ts)
│ - wrapAsync() for error catch │
│ - Calls Service method │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ Service Method │ (from service.factory.ts)
│ - Starts transaction │
│ - Calls DBApi method │
│ - Commits or rollbacks │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ DBApi Method │ (from base.api.ts + entity)
│ - getFieldMapping() transform │
│ - Sequelize model operation │
│ - Returns result │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ commonErrorHandler() │ (from helpers.js)
│ - Formats error response │
│ - Appropriate HTTP status │
└─────────────────────────────────┘
HTTP Response
```
---
## Design Patterns
### Factory Pattern
Both `createEntityRouter` and `createEntityService` implement the Factory pattern, creating objects (router, service class) without specifying their exact classes.
### Template Method Pattern
`GenericDBApi.getFieldMapping()` uses the Template Method pattern - the base class defines the algorithm skeleton, while subclasses can override specific steps via static getters (`JSON_FIELDS`, `FIELD_TRANSFORMERS`, `FIELD_DEFAULTS`).
### Strategy Pattern
The permission checking system uses Strategy pattern - different entities can have different permission strategies by overriding `permissionEntity` option.
### Decorator Pattern
The router factory decorates Express routers with permission middleware and error handling.
---
## Best Practices
### When to Use Factories
Use factories when:
- Entity requires standard CRUD operations
- No complex business logic beyond data transformation
- Permissions follow standard READ/CREATE/UPDATE/DELETE pattern
- No special authentication requirements
### When NOT to Use Factories
Don't use factories when:
- Complex multi-step workflows (use custom service)
- Special authentication (OAuth flows, password reset)
- External API integration (file storage, AI)
- Cross-entity transactions
- Custom endpoint patterns
### Extending Factory-Generated Code
1. **Add custom routes:** Extend the base router after factory call
2. **Add custom service methods:** Extend the generated class
3. **Override permissions:** Use `permissionEntity` option
4. **Customize data transformation:** Override `getFieldMapping()` in DBApi
---
## Related Documentation
- [Services Module](./services.md) - Business logic layer
- [Routes Module](./routes.md) - All route files
- [Middleware Module](./middleware.md) - Permission checking
- [Database Schema](../database-schema.md) - Model definitions

View File

@ -0,0 +1,842 @@
# Backend Middleware Module Documentation
## Overview
The Middleware module provides cross-cutting concerns for the Express application including rate limiting, permission checking, runtime context management, file uploads, and public access control.
**Files:**
| File | Lines | Purpose |
|------|-------|---------|
| `src/middlewares/rateLimiter.js` | 268 | Configurable rate limiting with in-memory store |
| `src/middlewares/check-permissions.ts` | RBAC permission checking through AccessPolicy |
| `src/middlewares/runtime-context.ts` | 34 | Runtime environment context from headers |
| `src/middlewares/runtime-public.ts` | 200 | Public runtime access control and response sanitization |
| `src/middlewares/upload.ts` | 34 | Multer-based file upload handling |
---
## Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────────┐
│ Incoming Request │
└──────────────────────────────────┬───────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Express Middleware Stack │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 1. helmet() - Security headers │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 2. cors() - Cross-origin resource sharing │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 3. requestLogger - Pino HTTP logging │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 4. Rate Limiters (route-specific) │ │
│ │ • downloadLimiter → /api/file/download, /api/file/presign │ │
│ │ • uploadLimiter → /api/file/upload │ │
│ │ • searchLimiter → /api/search │ │
│ │ • authLimiter → /api/auth/signin │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 5. bodyParser.json() - JSON body parsing (after file routes) │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 6. runtimeContextMiddleware - Environment context │ │
│ │ Reads: X-Runtime-Environment, X-Runtime-Project-Slug │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 7. JWT Authentication (route-specific) │ │
│ │ passport.authenticate('jwt', { session: false }) │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 8. Runtime Public Middleware (route-specific) │ │
│ │ • blockNonPublicRuntimeListEndpoints │ │
│ │ • sanitizePublicRuntimeListResponse │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 9. checkCrudPermissions / checkPermissions (route-specific) │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 10. Route Handler │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 11. Error Handler (commonErrorHandler) │ │
│ └────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
---
## File Details
### 1. rateLimiter.js (268 lines)
In-memory rate limiting middleware with configurable windows and limits.
#### Storage Architecture
```javascript
// In-memory store (Map)
const rateLimitStore = new Map();
// Entry structure
{
count: number, // Request count in window
expiresAt: number, // Window expiration timestamp
resetTime: string // ISO timestamp for headers
}
// Automatic cleanup every 5 minutes
setInterval(() => {
for (const [key, entry] of rateLimitStore.entries()) {
if (entry.expiresAt <= now) {
rateLimitStore.delete(key);
}
}
}, 5 * 60 * 1000);
```
#### Factory: createRateLimiter(options)
Creates a configurable rate limiter middleware.
**Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `keyPrefix` | string | `'rate-limit'` | Prefix for rate limit keys |
| `windowMs` | number | `900000` (15min) | Time window in milliseconds |
| `max` | number | `100` | Maximum requests per window |
| `message` | string | `'Too many requests...'` | Error message on limit |
| `skipFailedRequests` | boolean | `false` | Don't count 4xx/5xx responses |
| `keyGenerator` | function | `null` | Custom key generator `(req) => string` |
| `skip` | function | `null` | Skip rate limiting `(req) => boolean` |
**Returns:** Express middleware function
**Response Headers:**
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 2024-01-01T00:15:00.000Z
Retry-After: 300 (only when limit exceeded)
```
**Rate Limit Exceeded Response (429):**
```json
{
"error": "Too Many Requests",
"message": "Too many requests. Please try again later.",
"retryAfter": 300
}
```
#### Factory: createAuthenticatedRateLimiter(options)
Creates rate limiter that uses IP + User ID as key.
```javascript
const createAuthenticatedRateLimiter = (options = {}) => {
return createRateLimiter({
...options,
keyGenerator: (req) => {
const userId = req.currentUser?.id || 'anonymous';
const ip = req.ip || 'unknown';
return `${ip}:${userId}`;
},
});
};
```
#### Pre-configured Limiters
| Limiter | Key Prefix | Window | Max | Skip Failed | Use Case |
|---------|------------|--------|-----|-------------|----------|
| `authLimiter` | `auth` | 15 min | 10 | No | Login attempts |
| `passwordResetLimiter` | `password-reset` | 1 hour | 5 | No | Password reset |
| `apiLimiter` | `api` | 1 min | 100 | Yes | General API |
| `uploadLimiter` | `upload` | 1 min | 10 | No | File uploads |
| `downloadLimiter` | `download` | 1 min | 200 | Yes | File downloads |
| `searchLimiter` | `search` | 1 min | 30 | No | Search queries |
#### Route Mapping
```javascript
// index.js
app.use('/api/file/download', downloadLimiter);
app.use('/api/file/presign', downloadLimiter);
app.use('/api/file/upload', uploadLimiter);
app.use('/api/file/upload-sessions', uploadLimiter);
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes);
// routes/auth.js
router.post('/signin/local', signinLimiter, handler);
router.post('/send-password-reset-email', passwordResetLimiter, handler);
```
#### Development Mode
Rate limiting is skipped for localhost in development:
```javascript
if (
config.server.env === 'development' &&
(req.ip === '127.0.0.1' || req.ip === '::1')
) {
return next(); // Skip rate limiting
}
```
---
### 2. check-permissions.ts (194 lines)
Role-based access control (RBAC) middleware. Permission decisions are delegated
to `src/services/access-policy.ts` so role/custom permission resolution and
Public-user hardening stay centralized.
#### Public Role Caching
```javascript
let publicRoleCache = null;
// Fetched on module load (startup)
async function fetchAndCachePublicRole() {
publicRoleCache = await RolesDBApi.findBy({ name: 'Public' });
}
// Called immediately when module is imported
fetchAndCachePublicRole();
```
#### Function: checkPermissions(permission)
Creates middleware that checks if user has specific permission.
**Permission Check Flow:**
```
1. AccessPolicy.hasPermission(user, permission)
├── Public users are always denied admin API permissions
└── Internal users use app_role.permissions + custom_permissions
2. Public role fallback
└── Unauthenticated/no-role requests use cached Public role, but Public role permissions are ignored
3. Role lacks permission → 403 Forbidden
```
Self-access bypass is not part of `checkPermissions`. It is explicitly limited
to `GET`, `PUT`, and `PATCH` on the authenticated user's own `/api/users/:id`
route in `checkCrudPermissions`.
**Usage:**
```javascript
const { checkPermissions } = require('./middlewares/check-permissions');
// Check specific permission
router.get('/admin', checkPermissions('ADMIN_ACCESS'), handler);
// Check entity permission
router.get('/users', checkPermissions('READ_USERS'), handler);
```
**Error Response (403):**
```json
{
"message": "Forbidden"
}
```
#### Function: checkCrudPermissions(name)
Creates middleware that maps HTTP method to CRUD permission.
**Method Mapping:**
| HTTP Method | Permission Prefix |
|-------------|-------------------|
| `POST` | `CREATE_` |
| `GET` | `READ_` |
| `PUT` | `UPDATE_` |
| `PATCH` | `UPDATE_` |
| `DELETE` | `DELETE_` |
**Permission Name Format:** `{METHOD}_{ENTITY}`
Examples:
- `GET /api/users``READ_USERS`
- `POST /api/projects``CREATE_PROJECTS`
- `DELETE /api/assets/123``DELETE_ASSETS`
Routes can set `req.permissionNameOverride` before `checkCrudPermissions` when
the HTTP verb does not describe the domain operation. The middleware uses the
override as the exact permission name and otherwise falls back to
`{METHOD}_{ENTITY}`. For example, environment-level resets for project runtime
settings use `DELETE` to remove an override row, but the user-facing operation
is "use inherited defaults", so those routes require `UPDATE_PAGE_ELEMENTS`
rather than `DELETE_PAGE_ELEMENTS`.
**Usage:**
```javascript
const { checkCrudPermissions } = require('./middlewares/check-permissions');
// In router factory
router.get('/', checkCrudPermissions('users'), listHandler);
router.post('/', checkCrudPermissions('users'), createHandler);
router.delete('/:id', checkCrudPermissions('users'), deleteHandler);
// For reset/update semantics implemented as DELETE
router.use((req, _res, next) => {
if (req.method === 'DELETE' && req.path.startsWith('/project/')) {
req.permissionNameOverride = 'UPDATE_PAGE_ELEMENTS';
}
next();
});
router.use(checkCrudPermissions('page_elements'));
```
#### Runtime Public Read Bypass
Certain entities allow public read access in production runtime:
```javascript
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PROJECTS',
'TOUR_PAGES',
'PAGE_ELEMENTS',
'PAGE_LINKS',
'TRANSITIONS',
'PROJECT_AUDIO_TRACKS',
'GLOBAL_TRANSITION_DEFAULTS',
'PROJECT_TRANSITION_SETTINGS',
]);
// Bypass permission check for public runtime reads
const isRuntimePublicRead =
req.isRuntimePublicRequest === true &&
req.method === 'GET' &&
RUNTIME_PUBLIC_READ_ENTITIES.has(name.toUpperCase());
if (isRuntimePublicRead) {
return next(); // Skip permission check
}
```
**⚠️ Middleware Ordering Requirement:**
For public read bypass to work, the middleware that sets `req.isRuntimePublicRequest = true` **MUST run before** `checkCrudPermissions`. Common mistake:
```javascript
// ❌ WRONG - allowPublicRead runs AFTER checkCrudPermissions
router.use(checkCrudPermissions('entity'));
router.get('/', allowPublicRead, handler); // Too late!
// ✅ CORRECT - allowPublicRead runs BEFORE checkCrudPermissions
router.use(allowPublicRead);
router.use(checkCrudPermissions('entity'));
router.get('/', handler);
```
When using `router.use()`, middleware is applied to ALL routes before route-specific middleware runs.
---
### 3. runtime-context.ts (34 lines)
Middleware that extracts runtime environment context from request headers.
#### Function: runtimeContextMiddleware
Reads environment and project slug from headers for route-based access.
**Headers:**
| Header | Values | Description |
|--------|--------|-------------|
| `X-Runtime-Environment` | `production`, `stage`, `dev` | Content environment |
| `X-Runtime-Project-Slug` | string | Project identifier |
**Context Object:**
```javascript
req.runtimeContext = {
mode: 'admin', // Default mode
projectSlug: null, // Extracted from path or header
headerEnvironment: 'production', // From X-Runtime-Environment
headerProjectSlug: 'my-tour' // From X-Runtime-Project-Slug
};
```
**Usage in Routes:**
```javascript
// index.js
app.use(runtimeContextMiddleware);
// Access in handlers
const env = req.runtimeContext?.headerEnvironment;
if (env === 'production') {
// Filter for production content
}
```
**Route-Based Environment Access:**
| Route | Environment | Access |
|-------|-------------|--------|
| `/p/[slug]` | `production` | Public (no auth) |
| `/p/[slug]/stage` | `stage` | Authenticated only |
| `/constructor?projectId=` | `dev` | Authenticated only |
---
### 4. runtime-public.ts (200 lines)
Middleware for controlling public runtime access and sanitizing responses.
#### Allowed Fields (Whitelist)
Only these fields are returned for public runtime requests:
```javascript
const PUBLIC_RUNTIME_ENTITY_FIELDS = {
projects: [
'id', 'name', 'slug', 'description', 'logo_url', 'favicon_url', 'og_image_url',
],
tour_pages: [
'id', 'projectId', 'environment', 'source_key', 'name', 'slug',
'sort_order', 'background_image_url', 'background_video_url',
'background_audio_url', 'background_loop', 'requires_auth', 'ui_schema_json',
],
project_audio_tracks: [
'id', 'projectId', 'environment', 'source_key', 'name', 'slug',
'url', 'loop', 'volume', 'sort_order', 'is_enabled',
],
};
```
#### Function: blockNonPublicRuntimeListEndpoints
Restricts public runtime requests to list endpoints only.
```javascript
const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
if (!isPublicRuntimeReadRequest(req)) {
return next(); // Not a public request, continue
}
// Only allow root path (list endpoint)
if (req.path !== '/') {
return res.status(404).send({ message: 'Not found' });
}
// Block CSV exports
if (req.query.filetype === 'csv') {
return res.status(404).send({ message: 'Not found' });
}
return next();
};
```
**Blocked:**
- Individual record access: `GET /api/projects/123` → 404
- CSV exports: `GET /api/projects?filetype=csv` → 404
**Allowed:**
- List endpoints: `GET /api/projects/` → Continue
#### Function: sanitizePublicRuntimeListResponse(entityName)
Filters response data to only include whitelisted fields.
```javascript
const sanitizePublicRuntimeListResponse = (entityName) => {
const fields = PUBLIC_RUNTIME_ENTITY_FIELDS[entityName] || [];
return (req, res, next) => {
// Intercept res.send()
const originalSend = res.send.bind(res);
res.send = (body) => {
if (Array.isArray(body.rows)) {
const sanitizedRows = body.rows.map((row) => pickFields(row, fields));
return originalSend({ ...body, rows: sanitizedRows });
}
return originalSend(body);
};
return next();
};
};
```
**Before Sanitization:**
```json
{
"rows": [{
"id": "123",
"name": "My Tour",
"slug": "my-tour",
"createdAt": "2024-01-01",
"createdById": "user-456",
"internalNotes": "sensitive data"
}]
}
```
**After Sanitization:**
```json
{
"rows": [{
"id": "123",
"name": "My Tour",
"slug": "my-tour"
}]
}
```
#### Usage in index.js
```javascript
const mountRuntimeEntityRoute = (path, entityName, router) => {
app.use(
path,
requireRuntimeReadOrAuth, // JWT or public access
blockNonPublicRuntimeListEndpoints, // Block non-list endpoints
sanitizePublicRuntimeListResponse(entityName), // Filter fields
router,
);
};
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes);
mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes);
mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', ...);
```
---
### 5. upload.ts (34 lines)
Simple Multer-based file upload middleware.
```javascript
const util = require('util');
const Multer = require('multer');
let processFile = Multer({
storage: Multer.memoryStorage(),
}).single('file');
let processFileMiddleware = util.promisify(processFile);
module.exports = processFileMiddleware;
```
**Configuration:**
| Setting | Value |
|---------|-------|
| Storage | Memory (Buffer) |
| Field Name | `file` |
| Max Files | 1 (single) |
**Usage:**
```javascript
const upload = require('./middlewares/upload');
router.post('/upload', async (req, res) => {
await upload(req, res);
// req.file contains:
// - buffer: File data
// - originalname: Original filename
// - mimetype: MIME type
// - size: File size in bytes
});
```
**Note:** This middleware is primarily used for legacy uploads. The main file upload system uses chunked uploads without this middleware.
---
## Middleware Composition Patterns
### Pattern 1: Route-Level Rate Limiting
```javascript
// Apply limiter before route handler
app.use('/api/file/upload', uploadLimiter);
app.use('/api/file', fileRoutes);
```
### Pattern 2: Inline Middleware Chain
```javascript
// Multiple middlewares in route definition
router.post(
'/signin/local',
signinLimiter, // Rate limit
wrapAsync(async (req, res) => { ... }),
);
```
### Pattern 3: JWT + Feature Middleware
```javascript
// JWT auth + rate limit + routes
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes);
```
### Pattern 4: Conditional Auth (Runtime)
```javascript
const requireRuntimeReadOrAuth = (req, res, next) => {
const headerEnvironment = req.runtimeContext?.headerEnvironment;
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
const hasAuthHeader = Boolean(req.headers.authorization);
const isPublicEnvironment = headerEnvironment === 'production';
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
req.isRuntimePublicRequest = true;
return next(); // Allow without auth
}
req.isRuntimePublicRequest = false;
return jwtAuth(req, res, next); // Require auth
};
```
### Pattern 5: Response Interception
```javascript
// Intercept and modify response before sending
const sanitizeResponse = (req, res, next) => {
const originalSend = res.send.bind(res);
res.send = (body) => {
const modified = transformBody(body);
return originalSend(modified);
};
return next();
};
```
---
## Request Flow Examples
### Example 1: Authenticated API Request
```
GET /api/users
Authorization: Bearer <jwt>
1. helmet() → Security headers
2. cors() → CORS headers
3. requestLogger → Log request
4. bodyParser.json() → Parse body
5. runtimeContextMiddleware → Set req.runtimeContext
6. jwtAuth → Validate JWT, set req.currentUser
7. checkCrudPermissions('users') → Check READ_USERS permission
8. Route handler → Return users
```
### Example 2: Public Runtime Request
```
GET /api/projects
X-Runtime-Environment: production
1. helmet() → Security headers
2. cors() → CORS headers
3. requestLogger → Log request
4. bodyParser.json() → Parse body
5. runtimeContextMiddleware → Set req.runtimeContext.headerEnvironment = 'production'
6. requireRuntimeReadOrAuth → Set req.isRuntimePublicRequest = true, skip JWT
7. blockNonPublicRuntimeListEndpoints → Allow (path is '/')
8. sanitizePublicRuntimeListResponse('projects') → Filter response fields
9. checkCrudPermissions('projects') → Skip (isRuntimePublicRequest)
10. Route handler → Return sanitized projects
```
### Example 3: Rate Limited Upload
```
POST /api/file/upload
Authorization: Bearer <jwt>
Content-Type: multipart/form-data
1. uploadLimiter → Check rate limit (10/min)
├── Under limit → Continue
└── Over limit → 429 Too Many Requests
2. fileRoutes handles request (own body parsing)
```
---
## Error Handling
### Rate Limit Errors
```javascript
// 429 Too Many Requests
{
"error": "Too Many Requests",
"message": "Too many requests. Please try again later.",
"retryAfter": 300
}
```
### Permission Errors
```javascript
// 403 Forbidden (via ValidationError)
{
"message": "Role 'User' denied access to 'DELETE_USERS'."
}
```
### Public Access Errors
```javascript
// 404 Not Found (blocked endpoint)
{
"message": "Not found"
}
```
---
## Configuration
### Environment Variables
| Variable | Affects | Description |
|----------|---------|-------------|
| `NODE_ENV` | Rate limiting | Skip localhost in development |
### Constants
```javascript
// rateLimiter.js
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
// check-permissions.ts
const METHOD_MAP = {
POST: 'CREATE',
GET: 'READ',
PUT: 'UPDATE',
PATCH: 'UPDATE',
DELETE: 'DELETE',
};
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PROJECTS', 'TOUR_PAGES', 'PAGE_ELEMENTS',
'PAGE_LINKS', 'TRANSITIONS', 'PROJECT_AUDIO_TRACKS',
]);
// runtime-public.ts
const PUBLIC_RUNTIME_ALLOWED_PATH = '/';
```
---
## Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| `multer` | ^1.4.5 | Multipart form data parsing |
| `util` | built-in | Promisify multer |
**Internal Dependencies:**
- `../utils/logger` - Pino logger for rate limit logging
- `../services/notifications/errors/validation` - ValidationError class
- `../db/api/roles` - RolesDBApi for Public role
---
## Security Considerations
1. **Rate Limiting:** Prevents brute force and DoS attacks
2. **Permission Checking:** RBAC with role hierarchy
3. **Public Role Fallback:** Unauthenticated users get minimal permissions
4. **Response Sanitization:** Prevents data leakage in public runtime
5. **Self-Access Bypass:** Users can always access their own resources
6. **Memory Store:** Not suitable for horizontal scaling (use Redis)
---
## Testing
### Test Rate Limiting
```bash
# Should succeed (under limit)
for i in {1..10}; do
curl -X POST http://localhost:3000/api/auth/signin/local \
-H "Content-Type: application/json" \
-d '{"email": "test@test.com", "password": "wrong"}'
done
# Should return 429 (over limit)
curl -X POST http://localhost:3000/api/auth/signin/local \
-H "Content-Type: application/json" \
-d '{"email": "test@test.com", "password": "wrong"}'
```
### Test Public Runtime Access
```bash
# Should return sanitized projects
curl http://localhost:3000/api/projects \
-H "X-Runtime-Environment: production"
# Should return 404 (individual record blocked)
curl http://localhost:3000/api/projects/123 \
-H "X-Runtime-Environment: production"
```
### Test Permission Check
```bash
# Should return 403 if user lacks permission
curl http://localhost:3000/api/users \
-H "Authorization: Bearer <limited_user_jwt>"
```
---
## Summary
The Middleware module provides:
1. **rateLimiter.js** - 8 pre-configured rate limiters with in-memory store
2. **check-permissions.ts** - RBAC through AccessPolicy with user-route-only self access
3. **runtime-context.ts** - Runtime environment context from headers
4. **runtime-public.ts** - Public access control and response sanitization
5. **upload.ts** - Simple Multer-based file upload
**Key Features:**
- Configurable rate limiting per endpoint type
- Role-based permission checking with method-to-CRUD mapping
- Public runtime access for production presentations
- Response field filtering for public access
- Memory-based storage (scales vertically, needs Redis for horizontal)

View File

@ -0,0 +1,801 @@
# Backend Notifications Module
## Overview
The Notifications module provides a centralized **error handling system** and **internationalization (i18n) catalog** for the backend. It includes custom error classes with HTTP status codes and a message resolution system that supports parameter substitution.
**Location:** `backend/src/services/notifications/`
**Total Files:** 4
---
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ Services Layer │
│ (auth.js, users.js, projects.js, roles.js, search.js, etc.) │
│ │
│ throw new ValidationError('auth.userNotFound'); │
│ throw new ForbiddenError('auth.forbidden'); │
└───────────────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Notifications Module │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Error Classes │ │
│ │ (errors/) │ │
│ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │
│ │ │ ValidationError │ │ ForbiddenError │ │ │
│ │ │ (HTTP 400) │ │ (HTTP 403) │ │ │
│ │ └──────────┬──────────┘ └──────────┬──────────┘ │ │
│ │ │ │ │ │
│ │ └────────────┬───────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ helpers.js │ │ │
│ │ │ • getNotification() │ │ │
│ │ │ • isNotification() │ │ │
│ │ └────────────┬────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ list.js │ │ │
│ │ │ (Message Catalog) │ │ │
│ │ │ • auth.* │ │ │
│ │ │ • iam.* │ │ │
│ │ │ • importer.* │ │ │
│ │ │ • errors.* │ │ │
│ │ │ • emails.* │ │ │
│ │ └─────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Error Handler Middleware │
│ (Generic error handling in index.js) │
│ │
│ app.use((err, req, res, _next) => { │
│ res.status(err.code || 500).json({ message: err.message }); │
│ }); │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Directory Structure
```
services/notifications/
├── helpers.js # Message resolution functions (31 LOC)
├── list.js # Message catalog / i18n strings (101 LOC)
└── errors/
├── validation.js # ValidationError class - 400 (17 LOC)
└── forbidden.js # ForbiddenError class - 403 (17 LOC)
```
---
## Core Components
### Message Catalog (`list.js`)
Centralized storage for all user-facing messages and email content.
```javascript
const errors = {
app: {
title: 'Tour Builder Platform',
},
auth: {
userDisabled: 'Your account is disabled',
forbidden: 'Forbidden',
unauthorized: 'Unauthorized',
userNotFound: "Sorry, we don't recognize your credentials",
wrongPassword: "Sorry, we don't recognize your credentials",
weakPassword: 'This password is too weak',
emailAlreadyInUse: 'Email is already in use',
invalidEmail: 'Please provide a valid email',
passwordReset: {
invalidToken: 'Password reset link is invalid or has expired',
error: 'Email not recognized',
},
passwordUpdate: {
samePassword: "You can't use the same password. Please create new password",
},
userNotVerified: 'Sorry, your email has not been verified yet',
emailAddressVerificationEmail: {
invalidToken: 'Email verification link is invalid or has expired',
error: 'Email not recognized',
},
},
iam: {
errors: {
userAlreadyExists: 'User with this email already exists',
userNotFound: 'User not found',
disablingHimself: "You can't disable yourself",
revokingOwnPermission: "You can't revoke your own owner permission",
deletingHimself: "You can't delete yourself",
emailRequired: 'Email is required',
slugAlreadyExists: 'This slug is already in use by another project',
searchQueryRequired: 'Search query is required',
},
},
importer: {
errors: {
invalidFileEmpty: 'The file is empty',
invalidFileExcel: 'Only excel (.xlsx) files are allowed',
invalidFileUpload: 'Invalid file. Make sure you are using the last version of the template.',
importHashRequired: 'Import hash is required',
importHashExistent: 'Data has already been imported',
userEmailMissing: 'Some items in the CSV do not have an email',
},
},
errors: {
forbidden: {
message: 'Forbidden',
},
validation: {
message: 'An error occurred',
},
searchQueryRequired: {
message: 'Search query is required',
},
},
emails: {
invitation: {
subject: "You've been invited to {0}",
body: '...',
},
emailAddressVerification: {
subject: 'Verify your email for {0}',
body: '...',
},
passwordReset: {
subject: 'Reset your password for {0}',
body: '...',
},
},
};
module.exports = errors;
```
### Message Categories
| Category | Purpose | Example Key |
|----------|---------|-------------|
| `app` | Application metadata | `app.title` |
| `auth` | Authentication errors | `auth.userNotFound` |
| `iam` | Identity/access management | `iam.errors.userAlreadyExists` |
| `importer` | CSV/file import errors | `importer.errors.invalidFileEmpty` |
| `errors` | Generic error messages | `errors.validation.message` |
| `emails` | Email subjects/bodies | `emails.invitation.subject` |
---
### Helper Functions (`helpers.js`)
Functions for resolving and formatting notification messages.
```javascript
const _get = require('lodash/get');
const errors = require('./list');
/**
* Format message with positional arguments
* @param {string} message - Message with {0}, {1}, etc. placeholders
* @param {Array} args - Arguments to substitute
* @returns {string} Formatted message
*/
function format(message, args) {
if (!message) {
return null;
}
return message.replace(/{(\d+)}/g, function (match, number) {
return typeof args[number] != 'undefined' ? args[number] : match;
});
}
/**
* Check if a key exists in the notification catalog
* @param {string} key - Dot-notation path (e.g., 'auth.userNotFound')
* @returns {boolean}
*/
const isNotification = (key) => {
const message = _get(errors, key);
return !!message;
};
/**
* Get notification message by key with optional parameter substitution
* @param {string} key - Dot-notation path
* @param {...any} args - Values to substitute for {0}, {1}, etc.
* @returns {string} Resolved message or original key if not found
*/
const getNotification = (key, ...args) => {
const message = _get(errors, key);
if (!message) {
return key; // Return raw key as fallback
}
return format(message, args);
};
exports.getNotification = getNotification;
exports.isNotification = isNotification;
```
**Usage Examples:**
```javascript
const { getNotification, isNotification } = require('./notifications/helpers');
// Simple lookup
getNotification('auth.userNotFound');
// → "Sorry, we don't recognize your credentials"
// With parameter substitution
getNotification('emails.invitation.subject', 'Tour Builder Platform');
// → "You've been invited to Tour Builder Platform"
// Check if key exists
isNotification('auth.userNotFound'); // → true
isNotification('custom.message'); // → false
// Unknown key returns the key itself
getNotification('unknown.key');
// → "unknown.key"
```
---
### Error Classes
#### ValidationError (`errors/validation.js`)
HTTP 400 Bad Request error for validation failures.
```javascript
const { getNotification, isNotification } = require('../helpers');
module.exports = class ValidationError extends Error {
constructor(messageCode) {
let message;
// Try to resolve from notification catalog
if (messageCode && isNotification(messageCode)) {
message = getNotification(messageCode);
}
// Fallback to generic validation message
message = message || getNotification('errors.validation.message');
super(message);
this.code = 400;
}
};
```
**Properties:**
- `message` - Human-readable error message
- `code` - HTTP status code (400)
**Usage:**
```javascript
const ValidationError = require('./notifications/errors/validation');
// With catalog key
throw new ValidationError('auth.userNotFound');
// → Error: "Sorry, we don't recognize your credentials" (code: 400)
// With unknown key (uses fallback)
throw new ValidationError('unknown.error');
// → Error: "An error occurred" (code: 400)
// Without argument
throw new ValidationError();
// → Error: "An error occurred" (code: 400)
```
#### ForbiddenError (`errors/forbidden.js`)
HTTP 403 Forbidden error for authorization failures.
```javascript
const { getNotification, isNotification } = require('../helpers');
module.exports = class ForbiddenError extends Error {
constructor(messageCode) {
let message;
// Try to resolve from notification catalog
if (messageCode && isNotification(messageCode)) {
message = getNotification(messageCode);
}
// Fallback to generic forbidden message
message = message || getNotification('errors.forbidden.message');
super(message);
this.code = 403;
}
};
```
**Properties:**
- `message` - Human-readable error message
- `code` - HTTP status code (403)
**Usage:**
```javascript
const ForbiddenError = require('./notifications/errors/forbidden');
// With catalog key
throw new ForbiddenError('auth.forbidden');
// → Error: "Forbidden" (code: 403)
// Without argument
throw new ForbiddenError();
// → Error: "Forbidden" (code: 403)
```
---
## Complete Message Reference
### Authentication Messages (`auth.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `auth.userDisabled` | "Your account is disabled" | signin |
| `auth.forbidden` | "Forbidden" | authorization failures |
| `auth.unauthorized` | "Unauthorized" | missing authentication |
| `auth.userNotFound` | "Sorry, we don't recognize your credentials" | signin |
| `auth.wrongPassword` | "Sorry, we don't recognize your credentials" | signin, password update |
| `auth.weakPassword` | "This password is too weak" | signup, password reset |
| `auth.emailAlreadyInUse` | "Email is already in use" | signup |
| `auth.invalidEmail` | "Please provide a valid email" | signup |
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | signin |
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | password reset |
| `auth.passwordReset.error` | "Email not recognized" | password reset request |
| `auth.passwordUpdate.samePassword` | "You can't use the same password..." | password update |
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | email verification |
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | email verification |
### IAM Messages (`iam.errors.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `iam.errors.userAlreadyExists` | "User with this email already exists" | user creation |
| `iam.errors.userNotFound` | "User not found" | user update/delete |
| `iam.errors.disablingHimself` | "You can't disable yourself" | user disable |
| `iam.errors.revokingOwnPermission` | "You can't revoke your own owner permission" | permission revoke |
| `iam.errors.deletingHimself` | "You can't delete yourself" | user delete |
| `iam.errors.emailRequired` | "Email is required" | user creation |
| `iam.errors.slugAlreadyExists` | "This slug is already in use by another project" | project create/update |
| `iam.errors.searchQueryRequired` | "Search query is required" | search |
### Importer Messages (`importer.errors.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `importer.errors.invalidFileEmpty` | "The file is empty" | CSV import |
| `importer.errors.invalidFileExcel` | "Only excel (.xlsx) files are allowed" | file import |
| `importer.errors.invalidFileUpload` | "Invalid file..." | file import |
| `importer.errors.importHashRequired` | "Import hash is required" | bulk import |
| `importer.errors.importHashExistent` | "Data has already been imported" | duplicate import |
| `importer.errors.userEmailMissing` | "Some items in the CSV do not have an email" | user CSV import |
### Generic Messages (`errors.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `errors.forbidden.message` | "Forbidden" | ForbiddenError default |
| `errors.validation.message` | "An error occurred" | ValidationError default |
| `errors.searchQueryRequired.message` | "Search query is required" | search validation |
### Email Messages (`emails.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `emails.invitation.subject` | "You've been invited to {0}" | user invitation |
| `emails.emailAddressVerification.subject` | "Verify your email for {0}" | email verification |
| `emails.passwordReset.subject` | "Reset your password for {0}" | password reset |
---
## Integration Points
### Services Using Notifications
| Service | Errors Used | Common Keys |
|---------|-------------|-------------|
| `auth.js` | ValidationError, ForbiddenError | auth.*, iam.* |
| `users.ts` | ValidationError | iam.errors.* |
| `projects.ts` | ValidationError | projectsNotFound |
| `roles.ts` | ValidationError | rolesNotFound, Public role permission validation |
| `search.js` | ValidationError | auth.unauthorized, auth.forbidden |
| `project_audio_tracks.ts` | ValidationError | project_audio_tracksNotFound |
### Email Templates Using Notifications
| Template | Helper Usage |
|----------|--------------|
| `passwordReset.js` | `getNotification('emails.passwordReset.subject')` |
| `addressVerification.js` | `getNotification('emails.emailAddressVerification.subject')` |
| `invitation.js` | `getNotification('emails.invitation.subject')` |
### Middleware Using Notifications
| Middleware | Error Used | Purpose |
|------------|------------|---------|
| `check-permissions.ts` | ValidationError | Permission denied responses |
### Service Factory Using Notifications
```javascript
// factories/service.factory.js
const ValidationError = require('../services/notifications/errors/validation');
// Used in update() when entity not found
if (!record) {
throw new ValidationError(`${entityName}NotFound`);
}
```
---
## Error Flow
### Error Creation and Propagation
```
┌─────────────────────────────────────────────────────────────────────┐
│ Service Layer │
│ │
│ const ValidationError = require('./notifications/errors/validation');│
│ │
│ if (!user) { │
│ throw new ValidationError('auth.userNotFound'); │
│ } │
│ │ │
└─────────────────┼───────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Notifications Module │
│ │
│ 1. isNotification('auth.userNotFound') → true │
│ 2. getNotification('auth.userNotFound') │
│ → lodash.get(errors, 'auth.userNotFound') │
│ → "Sorry, we don't recognize your credentials" │
│ 3. new Error(message) with code = 400 │
│ │ │
└─────────────────┼───────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Route Handler │
│ │
│ try { │
│ await service.signin(email, password); │
│ } catch (error) { │
│ // Error propagates to error handler │
│ } │
│ │ │
└─────────────────┼───────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Error Handler Middleware │
│ (index.js) │
│ │
│ app.use((err, req, res, _next) => { │
│ logger.error({ err, url: req.url }, 'Unhandled error'); │
│ res.status(err.code || 500).json({ │
│ message: err.message || 'Internal server error' │
│ }); │
│ }); │
│ │
│ → Response: { "message": "Sorry, we don't recognize your credentials" }│
│ → Status: 400 │
└─────────────────────────────────────────────────────────────────────┘
```
### Message Resolution Flow
```
throw new ValidationError('auth.passwordReset.invalidToken')
┌─────────────────────────────────────┐
│ isNotification(messageCode) │
│ lodash.get(errors, key) │
│ → "Password reset link..." │
│ → true │
└─────────────────┬───────────────────┘
│ (exists)
┌─────────────────────────────────────┐
│ getNotification(messageCode) │
│ → "Password reset link is │
│ invalid or has expired" │
└─────────────────┬───────────────────┘
┌─────────────────────────────────────┐
│ new Error(message) │
│ this.message = "Password reset..."│
│ this.code = 400 │
└─────────────────────────────────────┘
```
---
## Comparison: Notifications vs Utils Errors
The backend has two error systems that serve different purposes:
### `services/notifications/errors/` (Primary)
- **Used by**: Services, middleware, service factory
- **Integration**: Uses notification catalog for i18n
- **Message resolution**: Dynamic via `getNotification()`
- **Properties**: `message`, `code`
```javascript
// notifications/errors/validation.js
class ValidationError extends Error {
constructor(messageCode) {
const message = isNotification(messageCode)
? getNotification(messageCode)
: getNotification('errors.validation.message');
super(message);
this.code = 400;
}
}
```
### `utils/errors.js` (Utility)
- **Used by**: Lower-level utilities
- **Integration**: Direct message strings
- **Message resolution**: Static, passed at construction
- **Properties**: `message`, `statusCode`, `details`, `isOperational`
```javascript
// utils/errors.js
class AppError extends Error {
constructor(message, statusCode = 500, details = null) {
super(message);
this.statusCode = statusCode;
this.details = details;
this.isOperational = true;
}
}
class ValidationError extends AppError {
constructor(message, details = null) {
super(message, 400, details);
}
}
```
### When to Use Which
| Scenario | Use |
|----------|-----|
| Service business logic errors | `notifications/errors/ValidationError` |
| Authorization failures | `notifications/errors/ForbiddenError` |
| Email subject/body text | `getNotification()` |
| Low-level utility errors | `utils/errors.js` |
| Errors needing details object | `utils/errors.js` |
---
## Adding New Messages
### 1. Add to Catalog
```typescript
// services/notifications/list.ts
const notifications = {
// ... existing ...
projects: {
errors: {
notFound: 'Project not found',
slugTaken: 'A project with this slug already exists',
invalidSlug: 'Project slug must contain only letters, numbers, and hyphens',
},
},
};
```
### 2. Use in Service
```javascript
// services/projects.ts
const ValidationError = require('./notifications/errors/validation');
async function createProject(data) {
const existing = await findBySlug(data.slug);
if (existing) {
throw new ValidationError('projects.errors.slugTaken');
}
// ...
}
```
### 3. With Parameters
```javascript
// Add to catalog
const errors = {
projects: {
errors: {
tooManyPages: 'Project cannot have more than {0} pages',
},
},
};
// Use with parameter
const MAX_PAGES = 100;
if (pageCount > MAX_PAGES) {
const message = getNotification('projects.errors.tooManyPages', MAX_PAGES);
throw new ValidationError(message);
}
// → "Project cannot have more than 100 pages"
```
---
## Testing
### Unit Testing Error Classes
```javascript
describe('ValidationError', () => {
it('should resolve message from catalog', () => {
const error = new ValidationError('auth.userNotFound');
expect(error.message).toBe("Sorry, we don't recognize your credentials");
expect(error.code).toBe(400);
});
it('should use fallback for unknown keys', () => {
const error = new ValidationError('unknown.key');
expect(error.message).toBe('An error occurred');
expect(error.code).toBe(400);
});
it('should use fallback without argument', () => {
const error = new ValidationError();
expect(error.message).toBe('An error occurred');
});
});
describe('ForbiddenError', () => {
it('should resolve message from catalog', () => {
const error = new ForbiddenError('auth.forbidden');
expect(error.message).toBe('Forbidden');
expect(error.code).toBe(403);
});
});
```
### Unit Testing Helpers
```javascript
const { getNotification, isNotification } = require('./helpers');
describe('getNotification', () => {
it('should return message for valid key', () => {
expect(getNotification('auth.userNotFound')).toBe(
"Sorry, we don't recognize your credentials"
);
});
it('should substitute parameters', () => {
expect(getNotification('emails.invitation.subject', 'My App')).toBe(
"You've been invited to My App"
);
});
it('should return key for unknown path', () => {
expect(getNotification('unknown.path')).toBe('unknown.path');
});
});
describe('isNotification', () => {
it('should return true for existing keys', () => {
expect(isNotification('auth.userNotFound')).toBe(true);
});
it('should return false for unknown keys', () => {
expect(isNotification('unknown.key')).toBe(false);
});
});
```
---
## Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| `lodash/get` | ^4.x | Deep object property access |
---
## Best Practices
### 1. Use Catalog Keys, Not Raw Strings
```javascript
// Good - uses catalog for consistency
throw new ValidationError('auth.userNotFound');
// Avoid - raw strings bypass i18n
throw new ValidationError('User was not found');
```
### 2. Group Related Messages
```javascript
// Good - organized by domain
auth: {
passwordReset: {
invalidToken: '...',
error: '...',
}
}
// Avoid - flat structure
authPasswordResetInvalidToken: '...',
authPasswordResetError: '...',
```
### 3. Security-Conscious Messages
```javascript
// Good - doesn't reveal if email exists
userNotFound: "Sorry, we don't recognize your credentials",
wrongPassword: "Sorry, we don't recognize your credentials",
// Avoid - reveals email existence
userNotFound: "No account with this email exists",
wrongPassword: "Password is incorrect",
```
### 4. Parameter Substitution for Dynamic Content
```javascript
// Good - parameterized
subject: "You've been invited to {0}",
getNotification('emails.invitation.subject', appName);
// Avoid - hardcoded
subject: "You've been invited to Tour Builder",
```
---
## Related Documentation
- [Services Module](./services.md) - Service layer error handling
- [Auth Module](./auth.md) - Authentication errors
- [Email Module](./email.md) - Email message templates
- [Middleware Module](./middleware.md) - Permission error handling

View File

@ -0,0 +1,757 @@
# Backend Routes Module Documentation
## Overview
The Routes module defines all HTTP endpoints for the application. It uses a factory pattern for entity CRUD operations and custom routes for specialized functionality.
**Directory:** `src/routes/`
**Files (25 total):**
| File | Lines | Pattern | Description |
|------|-------|---------|-------------|
| `auth.ts` | 327 | Custom | Authentication endpoints |
| `file.ts` | 150 | Custom | File upload/download endpoints |
| `publish.ts` | 107 | Custom | Publishing workflow endpoints |
| `search.ts` | 64 | Custom | Global search endpoint |
| `runtime-context.ts` | 16 | Custom | Runtime context inspection |
| `projects.ts` | 46 | Hybrid | Projects CRUD + custom clone endpoint |
| `users.ts` | 64 | Hybrid | Users CRUD via factory + sanitized GET by ID |
| `tour_pages.ts` | 380 | Manual CRUD | Tour pages CRUD plus reorder, duplicate, reverse-video status |
| `roles.ts` | 141 | Factory | Roles CRUD via factory |
| `permissions.ts` | 188 | Factory | Permissions CRUD via factory |
| `assets.ts` | 155 | Factory | Assets CRUD via factory |
| `asset_variants.ts` | 147 | Factory | Asset variants CRUD via factory |
| `access_logs.ts` | 145 | Factory | Access logs CRUD via factory |
| `project_memberships.ts` | 145 | Factory | Project memberships CRUD via factory |
| `project_audio_tracks.ts` | 151 | Factory | Project audio tracks CRUD via factory |
| `global_transition_defaults.ts` | 145 | Custom | Runtime-readable global transition defaults |
| `global_ui_control_defaults.ts` | 79 | Custom | Runtime-readable global UI-control defaults |
| `project_transition_settings.ts` | 212 | Custom | Project/environment transition overrides |
| `project_ui_control_settings.ts` | 125 | Custom | Project/environment global UI-control overrides |
| `presigned_url_requests.ts` | 150 | Factory | Presigned URL requests CRUD via factory |
| `publish_events.ts` | 157 | Factory | Publish events CRUD via factory |
| `pwa_caches.ts` | 148 | Factory | PWA caches CRUD via factory |
| `element_type_defaults.ts` | 12 | Factory | Element type defaults via factory |
| `project_element_defaults.ts` | 92 | Hybrid | Project element defaults CRUD + custom (reset, diff) |
---
## Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────────┐
│ Route Patterns │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Factory Pattern (createEntityRouter) │ │
│ │ │ │
│ │ routes/roles.ts ──────────────┐ │ │
│ │ routes/permissions.ts ────────┤ │ │
│ │ routes/assets.ts ─────────────┼───▶ factories/router.factory.ts│ │
│ │ routes/asset_variants.ts ─────┤ │ │
│ │ routes/access_logs.ts ────────┤ Generates: │ │
│ │ routes/pwa_caches.ts ─────────┤ • POST / │ │
│ │ routes/publish_events.ts ─────┤ • POST /bulk-import │ │
│ │ routes/element_type_defaults.ts • PUT /:id │ │
│ │ routes/presigned_url_requests.ts • DELETE /:id │ │
│ │ routes/project_memberships.ts • POST /deleteByIds │ │
│ │ routes/project_audio_tracks.ts • GET / │ │
│ │ ... (11 entities) • GET /count │ │
│ │ • GET /autocomplete │ │
│ │ • GET /:id │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Hybrid Pattern (Factory + Custom Routes) │ │
│ │ │ │
│ │ routes/projects.ts ──────────────────┐ │ │
│ │ • Standard CRUD (manual) │ │ │
│ │ + POST /:id/clone │ │ │
│ │ + GET /:id/offline-manifest │ │ │
│ │ │ │ │
│ │ routes/project_element_defaults.ts ──┘ │ │
│ │ • Standard CRUD (factory) │ │
│ │ + POST /:id/reset │ │
│ │ + GET /:id/diff │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Custom Pattern (Full Manual Implementation) │ │
│ │ │ │
│ │ routes/auth.ts ───────── Authentication flows │ │
│ │ routes/file.ts ───────── File upload/download │ │
│ │ routes/publish.ts ────── Publishing workflow │ │
│ │ routes/search.ts ─────── Global search │ │
│ │ routes/runtime-context.ts ─ Runtime inspection │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
---
## Route Factory
### factories/router.factory.ts
Generates standardized CRUD routes for entities.
```javascript
const router = createEntityRouter(
'tour_pages', // Entity name
Tour_pagesService, // Service class
Tour_pagesDBApi, // Database API class
{
permissionEntity: 'tour_pages', // Permission entity name (optional)
csvFields: ['id', 'name'], // CSV export fields (optional)
validation: { // Request validation overrides (optional)
create: customCreateSchema,
update: customUpdateSchema,
},
customRoutes: (router, Service, DBApi) => { // Custom routes (optional)
router.post('/custom', handler);
}
}
);
```
#### Generated Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/` | Create new item |
| `POST` | `/bulk-import` | Bulk import items |
| `PUT` | `/:id` | Update item by ID |
| `DELETE` | `/:id` | Delete item by ID |
| `POST` | `/deleteByIds` | Delete multiple items |
| `GET` | `/` | List items (with pagination, filters) |
| `GET` | `/count` | Count items matching filters |
| `GET` | `/autocomplete` | Autocomplete search |
| `GET` | `/:id` | Get single item by ID |
#### Factory Features
**Permission Checking:**
```javascript
router.use(checkCrudPermissions(permissionEntity));
// Maps HTTP methods to permissions:
// GET → READ_ENTITY
// POST → CREATE_ENTITY
// PUT/PATCH → UPDATE_ENTITY
// DELETE → DELETE_ENTITY
```
**Request Validation:**
```typescript
import { validateRequest } from '../middlewares/validate-request.ts';
import { crud as crudSchemas } from '../validators/request-schemas.ts';
router.get(
'/:id',
validateRequest(crudSchemas.findOne),
wrapAsync(async (req, res) => {
const payload = await DBApi.findBy({ id: req.params.id });
res.status(200).send(payload);
}),
);
```
Factory CRUD routes validate request bodies, params, and common query controls before service/DB calls. List and count routes validate `limit`, `page`, `field`, `sort`, and `filetype` while keeping existing entity filter query parameters. Entity routers can pass `validation` overrides for stricter contracts, as `users` and `projects` do.
**CSV Export:**
```javascript
// GET /?filetype=csv
if (filetype === 'csv') {
const csv = parse(payload.rows, { fields });
res.status(200).attachment('export.csv').send(csv);
}
```
**Runtime Context:**
```javascript
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findAll(req.query, {
currentUser,
runtimeContext,
});
```
---
## Route Mounting (index.js)
Routes are mounted with authentication and rate limiting:
```javascript
// No auth required
app.use('/api/auth', authRoutes);
app.use('/api/runtime-context', runtimeContextRoutes);
app.get('/api/health', healthHandler);
// File routes (before body parser)
app.use('/api/file/download', downloadLimiter);
app.use('/api/file/presign', downloadLimiter);
app.use('/api/file/upload', uploadLimiter);
app.use('/api/file', fileRoutes);
// JWT auth required
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/project-element-defaults', jwtAuth, project_element_defaultsRoutes);
app.use('/api/publish', jwtAuth, publishRoutes);
// JWT + Rate limiting
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes);
// Runtime public access (production environment)
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes);
mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes);
mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', ...);
```
---
## Custom Routes Detail
### 1. auth.ts (327 lines)
Authentication and account management.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/signin/local` | No | Email/password login |
| POST | `/signup` | No | Register new user |
| 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 |
| POST | `/send-password-reset-email` | No | Send reset email |
| GET | `/email-configured` | No | Check email config |
| GET | `/signin/google` | No | Google OAuth start |
| GET | `/signin/google/callback` | No | Google OAuth callback |
| GET | `/signin/microsoft` | No | Microsoft OAuth start |
| GET | `/signin/microsoft/callback` | No | Microsoft OAuth callback |
`auth.ts` is a typed ESM route boundary. It uses reusable request body/query contracts from `backend/src/types/auth-routes.ts`, the typed `backend/src/services/auth.ts` service, and the shared Passport helpers in `backend/src/auth/passport-middleware.ts`.
---
### 2. file.ts (150 lines)
File upload and download operations.
`file.ts` is a typed ESM route boundary. It calls the typed unified storage facade in `backend/src/services/file.ts`, while provider contracts and request/response shapes live in reusable `backend/src/types/file.ts` contracts. S3 and GCloud use official SDK-provided types instead of local SDK declarations.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/download` | No | Download file by privateUrl |
| POST | `/presign` | No | Generate presigned URLs (max 50) |
| POST | `/upload/:table/:field` | JWT | Legacy single file upload |
| POST | `/upload-sessions/init` | JWT | Initialize chunked upload |
| GET | `/upload-sessions/:sessionId` | JWT | Get upload session status |
| PUT | `/upload-sessions/:sessionId/chunks/:chunkIndex` | JWT | Upload chunk |
| POST | `/upload-sessions/:sessionId/finalize` | JWT | Finalize chunked upload |
**Presigned URLs Request:**
```json
{
"urls": ["assets/image.jpg", "assets/video.mp4"]
}
```
**Presigned URLs Response:**
```json
{
"presignedUrls": {
"assets/image.jpg": "https://s3.../assets/image.jpg?X-Amz-...",
"assets/video.mp4": "https://s3.../assets/video.mp4?X-Amz-..."
}
}
```
---
### 3. publish.ts (107 lines)
Publishing workflow for Dev → Stage → Production.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/` | JWT | Publish stage to production |
| POST | `/publish` | JWT | Alias for publish |
| POST | `/save-to-stage` | JWT | Save dev to stage |
**Publish Request:**
```json
{
"projectId": "uuid",
"title": "Release 1.0",
"description": "Initial release"
}
```
**Save to Stage Request:**
```json
{
"projectId": "uuid"
}
```
---
### 4. search.ts (64 lines)
Global full-text search across entities.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/` | JWT | Search across all entities |
**Request:**
```json
{
"searchQuery": "my search term"
}
```
**Response:**
```json
{
"users": [...],
"projects": [...],
"tour_pages": [...]
}
```
### 5. runtime-context.ts (16 lines)
Runtime context inspection for debugging.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/` | No | Get current runtime context |
**Response:**
```json
{
"mode": "admin",
"projectSlug": null,
"headerEnvironment": "production",
"headerProjectSlug": "my-tour"
}
```
---
## Entity Routes Detail
### projects.ts (46 lines)
Projects with factory CRUD plus a clone endpoint.
**Standard CRUD Endpoints:**
| Method | Path | Description |
|--------|------|-------------|
| POST | `/` | Create project |
| POST | `/bulk-import` | Bulk import projects |
| PUT | `/:id` | Update project |
| DELETE | `/:id` | Delete project |
| POST | `/deleteByIds` | Delete multiple projects |
| GET | `/` | List projects |
| GET | `/count` | Count projects |
| GET | `/autocomplete` | Autocomplete search |
| GET | `/:id` | Get project by ID |
**Custom Endpoints:**
| Method | Path | Description |
|--------|------|-------------|
| POST | `/:id/clone` | Clone project with all pages |
---
### tour_pages.ts (380 lines)
Typed manual CRUD route for tour pages. In addition to standard create/update/delete/list/count/autocomplete/find-by-id operations, it owns page reorder, dev-page duplication, CSV export, and reverse-video status endpoints. The route uses reusable contracts from `backend/src/types/tour-pages.ts`.
**Schema Fields:**
- `source_key`, `name`, `slug`
- `background_image_url`, `background_video_url`, `background_audio_url`
- `ui_schema_json`, `sort_order`
**Endpoints:**
| Method | Path | Description |
|--------|------|-------------|
| POST | `/` | Create page |
| POST | `/bulk-import` | Bulk import pages |
| POST | `/reorder` | Reorder dev pages in a project/environment |
| POST | `/:id/duplicate` | Duplicate a dev page |
| PUT | `/:id` | Update page |
| DELETE | `/:id` | Remove page |
| POST | `/deleteByIds` | Remove multiple pages |
| GET | `/` | List pages with reverse video URL population and optional CSV export |
| POST | `/reverse-video-status` | Check generated reverse-video variants |
| GET | `/count` | Count pages |
| GET | `/autocomplete` | Page autocomplete |
| GET | `/:id` | Get one page with reverse video URL population |
- `POST /api/tour_pages/reorder` accepts `{ data: { projectId, environment,
orderedPageIds } }`, validates a complete page list for the project/dev
environment, and updates only `sort_order`.
- `POST /api/tour_pages/:id/duplicate` accepts `{ data: { projectId,
environment, name, slug } }`, duplicates a dev page into a new independent dev
page, appends it to the project order, deep-copies `ui_schema_json`, and
regenerates inline element IDs.
- Constructor page deletion uses the standard `DELETE /api/tour_pages/:id`
route after a frontend confirmation modal; no separate delete endpoint is
needed.
- Custom routes are protected by Passport JWT like other tour page mutations and
are implemented before `/:id` where needed so custom paths are not treated as
entity IDs.
- Non-dev environments are rejected for reorder and duplicate; stage and
production changes only flow through Save to Stage and Publish.
---
### element_type_defaults.ts (12 lines)
Minimal factory usage with permission override.
```javascript
module.exports = createEntityRouter(
'element_type_defaults',
Element_type_defaultsService,
Element_type_defaultsDBApi,
{
permissionEntity: 'page_elements', // Uses PAGE_ELEMENTS permissions
},
);
```
**URL Aliases:**
- `/api/element-type-defaults` (primary)
- `/api/ui-elements` (backwards compatibility)
---
### project_element_defaults.ts (92 lines)
Factory-generated routes plus custom endpoints for resetting and comparing defaults.
```javascript
const baseRouter = createEntityRouter(
'project_element_defaults',
Project_element_defaultsService,
Project_element_defaultsDBApi,
{
permissionEntity: 'page_elements',
},
);
// Add custom endpoints
baseRouter.post('/:id/reset', ...);
baseRouter.get('/:id/diff', ...);
```
**Standard CRUD Endpoints:** (via factory)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/` | Create project element default |
| PUT | `/:id` | Update project element default |
| DELETE | `/:id` | Delete project element default |
| GET | `/` | List project element defaults |
| GET | `/:id` | Get project element default by ID |
**Custom Endpoints:**
| Method | Path | Description |
|--------|------|-------------|
| POST | `/:id/reset` | Reset project element default to global |
| GET | `/:id/diff` | Get diff from global element type default |
**URL Alias:**
- `/api/project-element-defaults` (primary)
---
## Request/Response Patterns
### List Endpoint (GET /)
**Query Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `page` | number | Page number (0-indexed) |
| `limit` | number | Items per page |
| `field` | string | Sort field |
| `sort` | string | Sort direction (`asc`/`desc`) |
| `filetype` | string | Export format (`csv`) |
| `[fieldName]` | string | Filter by field value |
| `[fieldName]Range` | array | Filter by range `[start, end]` |
**Response:**
```json
{
"rows": [...],
"count": 150
}
```
### Create Endpoint (POST /)
**Request:**
```json
{
"data": {
"name": "New Item",
"field1": "value1"
}
}
```
**Response:**
```json
{
"id": "uuid",
"name": "New Item",
"field1": "value1",
"createdAt": "2024-01-01T00:00:00.000Z"
}
```
### Update Endpoint (PUT /:id)
**Request:**
```json
{
"id": "uuid",
"data": {
"name": "Updated Name"
}
}
```
**Response:**
```json
true
```
### Delete Endpoints
**Single Delete (DELETE /:id):**
```json
true
```
**Bulk Delete (POST /deleteByIds):**
```json
{
"data": ["uuid1", "uuid2", "uuid3"]
}
```
---
## Swagger Documentation
Swagger UI is served at `GET /api-docs`. The OpenAPI document is maintained in
`backend/src/openapi/document.ts`, not in route-local JSDoc comments.
```javascript
const specs = createOpenApiDocument({
serverUrl: config.server.swaggerServerUrl,
});
```
The OpenAPI module contains:
- Shared schemas for auth, users, projects, tour pages, assets, publishing,
runtime access, file upload, UI controls, transition settings, and audit
entities.
- A reusable factory CRUD path generator covering `POST /`, `POST /bulk-import`,
`PUT /:id`, `DELETE /:id`, `POST /deleteByIds`, `GET /`, `GET /count`,
`GET /autocomplete`, and `GET /:id`.
- Explicit path definitions for custom routes such as auth flows, publishing,
file upload/download, runtime access/context, project clone, tour page reorder
and duplicate, transition/UI-control settings, and project element default
reset/diff.
When adding routes, update `backend/src/openapi/document.ts` alongside the route
implementation. For factory-backed entities, add a schema and `CrudResource`
entry instead of copying per-route Swagger boilerplate.
**Access Swagger UI:** `http://localhost:3000/api-docs` in local development.
On the standard VM, the backend runs in `NODE_ENV=dev_stage` on port `3000`;
Apache serves the public domain on port `80`. See
[`deployment-vm.md`](../../../documentation/deployment-vm.md) for VM health checks.
---
## Error Handling
### wrapAsync Helper
All async route handlers use `wrapAsync` for error propagation:
```javascript
router.post(
'/',
wrapAsync(async (req, res) => {
// Errors thrown here are caught and passed to error handler
const payload = await Service.create({
data: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(payload);
}),
);
```
### commonErrorHandler
Each route file includes error handler:
```javascript
router.use('/', require('../helpers').commonErrorHandler);
```
**Error Response Mapping:**
| Status Code | Description |
|-------------|-------------|
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 409 | Conflict |
| 422 | Unprocessable Entity |
| 500 | Internal Server Error |
---
## Route Categories
### Public Routes (No Auth)
| Route | Description |
|-------|-------------|
| `GET /api/health` | Health check |
| `POST /api/auth/signin/local` | Login |
| `GET /api/auth/signin/google` | Google OAuth |
| `GET /api/auth/signin/microsoft` | Microsoft OAuth |
| `GET /api/file/download` | File download |
| `POST /api/file/presign` | Generate presigned URLs |
| `GET /api/runtime-context` | Runtime context |
### Runtime Public Routes (Production Environment)
| Route | Description |
|-------|-------------|
| `GET /api/projects` | List projects (sanitized) |
| `GET /api/tour_pages` | List tour pages (sanitized) |
| `GET /api/project_audio_tracks` | List audio tracks (sanitized) |
### Authenticated Routes (JWT Required)
All other routes require JWT authentication via:
```javascript
app.use('/api/users', jwtAuth, usersRoutes);
```
### Rate Limited Routes
| Route | Limiter | Config |
|-------|---------|--------|
| `/api/auth/signin/local` | authLimiter | 10/15min |
| `/api/auth/send-password-reset-email` | passwordResetLimiter | 5/hour |
| `/api/file/upload*` | uploadLimiter | 10/min |
| `/api/file/download`, `/presign` | downloadLimiter | 200/min |
| `/api/search` | searchLimiter | 30/min |
## Dependencies
| Package | Purpose |
|---------|---------|
| `express` | Router and middleware |
| `json2csv` | CSV export functionality |
| `passport` | JWT authentication |
| `body-parser` | JSON body parsing |
---
## Testing
### Test Entity CRUD
```bash
# Create
curl -X POST http://localhost:3000/api/projects \
-H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-d '{"data": {"name": "Test Project", "slug": "test"}}'
# List
curl http://localhost:3000/api/projects \
-H "Authorization: Bearer <JWT>"
# Get by ID
curl http://localhost:3000/api/projects/<uuid> \
-H "Authorization: Bearer <JWT>"
# Update
curl -X PUT http://localhost:3000/api/projects/<uuid> \
-H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-d '{"id": "<uuid>", "data": {"name": "Updated Name"}}'
# Delete
curl -X DELETE http://localhost:3000/api/projects/<uuid> \
-H "Authorization: Bearer <JWT>"
```
### Test Public Runtime
```bash
curl http://localhost:3000/api/projects \
-H "X-Runtime-Environment: production"
```
### Test Presigned URLs
```bash
curl -X POST http://localhost:3000/api/file/presign \
-H "Content-Type: application/json" \
-d '{"urls": ["assets/test.jpg"]}'
```
---
## Summary
The Routes module provides:
1. **Factory Pattern** - 12 entities with standardized CRUD (createEntityRouter)
2. **Hybrid Pattern** - 2 entities with factory CRUD + custom endpoints (projects, project_element_defaults)
3. **Custom Routes** - 7 specialized route files for auth, files, search, etc.
4. **Swagger Documentation** - centralized OpenAPI document for all endpoints
5. **Permission Checking** - RBAC via checkCrudPermissions middleware
6. **Runtime Public Access** - Production environment public read access
7. **Rate Limiting** - Per-endpoint rate limiting
8. **CSV Export** - Export capability via `?filetype=csv`
9. **Request Validation** - Joi schemas via validateRequest before service calls
10. **Error Handling** - Centralized via wrapAsync and commonErrorHandler
**Route Statistics:**
- 26 route files
- ~50+ unique endpoints
- 11 factory-generated entity routers
- 3 hybrid entity routers (factory + custom)
- 7 custom route implementations

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,857 @@
# Utilities Module
## Overview
The Utilities module provides centralized helper functions, error handling, logging, environment validation, and i18n message management. Utilities are organized across several locations based on their domain.
**Locations:**
- `backend/src/utils/` - Core utilities (errors, logging, env validation, request context)
- `backend/src/helpers.ts` - Request helpers (async wrapper, error handler, JWT)
- `backend/src/db/utils.ts` - Database utilities
- `backend/src/services/notifications/` - i18n messages and legacy error classes
---
## Architecture
```
backend/src/
├── utils/
│ ├── index.ts # Re-exports
│ ├── errors.ts # Error classes
│ ├── logger.ts # Pino logging
│ ├── request-context.ts # Request-scoped context storage
│ └── env-validation.ts # Joi env schema
├── helpers.ts # Request helpers
├── db/
│ └── utils.ts # DB utilities
└── services/notifications/
├── list.ts # i18n message catalog
├── helpers.ts # Message formatting
└── errors/
├── forbidden.ts # ForbiddenError
└── validation.ts # ValidationError
```
---
## Core Utilities (`utils/`)
### Error Classes (`utils/errors.js`)
Modern error hierarchy for consistent HTTP error responses.
```javascript
class AppError extends Error {
constructor(message, statusCode = 500, details = null) {
super(message);
this.statusCode = statusCode;
this.details = details;
this.isOperational = true; // Distinguishes from programming errors
Error.captureStackTrace(this, this.constructor);
}
}
```
**Error Types:**
| Class | Status Code | Default Message | Usage |
|-------|-------------|-----------------|-------|
| `AppError` | 500 | (custom) | Base class |
| `NotFoundError` | 404 | `{resource} not found` | Missing resources |
| `ValidationError` | 400 | (custom) | Invalid input |
| `ForbiddenError` | 403 | `Access denied` | Permission denied |
| `UnauthorizedError` | 401 | `Unauthorized` | Auth required |
| `ConflictError` | 409 | `Resource conflict` | Duplicate resources |
**Usage:**
```javascript
const { NotFoundError, ValidationError, ForbiddenError } = require('./utils');
// In route handler
if (!user) {
throw new NotFoundError('User');
}
const currentUser = getCurrentUser(req);
if (!currentUser) {
throw new ForbiddenError();
}
if (errors.length > 0) {
throw new ValidationError('Invalid data', errors);
}
```
---
### Structured Logging (`utils/logger.ts`)
Pino-based logging with request correlation and environment-aware formatting.
Logger initialization is a bootstrap exception where direct `process.env`
access is allowed; runtime services should use `config.ts` instead.
```javascript
const pino = require('pino');
const crypto = require('crypto');
const isDevelopment = process.env.NODE_ENV === 'development';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: isDevelopment
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
base: {
service: 'tour-builder-api',
env: process.env.NODE_ENV || 'development',
},
});
```
**Log Levels:**
- `fatal` - Unrecoverable errors
- `error` - Errors requiring attention
- `warn` - Warning conditions (400-499 responses)
- `info` - Normal operations (default)
- `debug` - Detailed debugging
- `trace` - Very detailed tracing
**Process-Level Failure Logging:**
`registerProcessErrorHandlers()` is called from `src/index.ts` during backend
startup. It installs handlers for `uncaughtException` and `unhandledRejection`,
logs them through Pino at `fatal`, normalizes non-`Error` rejection reasons into
`Error` instances with `cause`, flushes Pino, and then exits with status `1`.
These failures happen outside the Express request lifecycle, so route error
middleware cannot catch them.
```typescript
registerProcessErrorHandlers();
process.on('unhandledRejection', (reason) => {
logger.fatal(
{ err: normalizeLoggedError(reason) },
'Unhandled promise rejection, shutting down process',
);
exitAfterLogging();
});
```
**Request Logger Middleware:**
```javascript
function requestLogger(req, res, next) {
// Generate or use existing request ID
const requestId = req.headers['x-request-id'] || crypto.randomUUID();
// Store child logger in request context
setRequestLogger(req, logger.child({ requestId }));
setRequestId(req, requestId);
res.setHeader('X-Request-Id', requestId);
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const logData = {
method: req.method,
url: req.originalUrl || req.url,
status: res.statusCode,
duration,
userAgent: req.headers['user-agent'],
};
// Log level based on status code
if (res.statusCode >= 500) {
getRequestLogger(req)?.error(logData, 'Request completed with server error');
} else if (res.statusCode >= 400) {
getRequestLogger(req)?.warn(logData, 'Request completed with client error');
} else {
getRequestLogger(req)?.info(logData, 'Request completed');
}
});
next();
}
```
**Log Output Examples:**
Development (pino-pretty):
```
[12:34:56.789] INFO (tour-builder-api): Request completed
requestId: "abc-123"
method: "GET"
url: "/api/users"
status: 200
duration: 45
```
Production (JSON):
```json
{"level":30,"time":1711723456789,"service":"tour-builder-api","env":"production","requestId":"abc-123","method":"GET","url":"/api/users","status":200,"duration":45,"msg":"Request completed"}
```
**Usage:**
```javascript
const { logger, requestLogger } = require('./utils/logger');
// App setup
app.use(requestLogger);
// Manual logging
logger.info({ userId: user.id }, 'User logged in');
logger.error({ err }, 'Database connection failed');
// Request-scoped logging (in routes)
getRequestLogger(req)?.info({ data }, 'Processing request');
```
---
### Request Context (`utils/request-context.ts`)
Request-scoped data is stored outside the Express `Request` object through a
`WeakMap<Request, AppRequestContext>`. Do not add global
`Express.Request` augmentation for project fields such as `currentUser`,
`runtimeContext`, `log`, or runtime-public flags. Middleware should write with
`setCurrentUser`, `setRuntimeContext`, `setRequestLogger`, and related helpers;
routes/services should read through `getCurrentUser`, `getRuntimeContext`,
`getRequestLogger`, and `getRouteServiceContext`.
### Environment Validation (`utils/env-validation.ts`)
Joi-based validation ensuring all required environment variables are present with correct types.
**Schema Definition:**
```javascript
const Joi = require('joi');
const envSchema = Joi.object({
// Server
NODE_ENV: Joi.string()
.valid('development', 'test', 'production', 'dev_stage')
.default('development'),
PORT: Joi.number().default(8080),
// Database
DB_HOST: Joi.string().default('localhost'),
DB_PORT: Joi.number().default(5432),
DB_NAME: Joi.string().default('db_tour_builder_platform'),
DB_USER: Joi.string().default('postgres'),
DB_PASS: Joi.string().allow('').default(''),
// Authentication
SECRET_KEY: Joi.string()
.min(16)
.default('88dbeaf8-e906-405e-9e41-c3baadeda5c6'),
ADMIN_PASS: Joi.string().default('88dbeaf8'),
USER_PASS: Joi.string().default('c3baadeda5c6'),
ADMIN_EMAIL: Joi.string().email().default('admin@flatlogic.com'),
// OAuth
GOOGLE_CLIENT_ID: Joi.string().allow('').default(''),
GOOGLE_CLIENT_SECRET: Joi.string().allow('').default(''),
MS_CLIENT_ID: Joi.string().allow('').default(''),
MS_CLIENT_SECRET: Joi.string().allow('').default(''),
// AWS S3
AWS_ACCESS_KEY_ID: Joi.string().allow('').default(''),
AWS_SECRET_ACCESS_KEY: Joi.string().allow('').default(''),
AWS_S3_BUCKET: Joi.string().allow('').default(''),
AWS_S3_REGION: Joi.string().default('us-east-1'),
AWS_S3_PREFIX: Joi.string().default('afeefb9d49f5b7977577876b99532ac7'),
// Email
EMAIL_USER: Joi.string().allow('').default(''),
EMAIL_PASS: Joi.string().allow('').default(''),
EMAIL_TLS_REJECT_UNAUTHORIZED: Joi.string()
.valid('true', 'false')
.default('true'),
// External APIs
PEXELS_KEY: Joi.string().allow('').default(''),
// Logging
LOG_LEVEL: Joi.string()
.valid('fatal', 'error', 'warn', 'info', 'debug', 'trace')
.default('info'),
}).unknown(true); // Allow additional env vars
```
**Validation Function:**
```javascript
function validateEnv() {
const { error, value } = envSchema.validate(process.env, {
abortEarly: false, // Report all errors
stripUnknown: false, // Keep unknown vars
});
if (error) {
const messages = error.details.map((d) => ` - ${d.message}`);
logger.error({ errors: messages }, 'Environment validation failed');
// Strict in production, lenient in development
if (process.env.NODE_ENV === 'production') {
process.exit(1);
} else {
logger.warn('Continuing with default values in non-production mode');
}
}
return value; // Returns validated/defaulted values
}
```
**Environment Variable Categories:**
| Category | Variables | Required |
|----------|-----------|----------|
| **Server** | `NODE_ENV`, `PORT` | Defaults |
| **Database** | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | Defaults |
| **Auth** | `SECRET_KEY`, `ADMIN_PASS`, `USER_PASS`, `ADMIN_EMAIL` | Defaults |
| **OAuth** | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | Optional |
| **AWS S3** | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET`, `AWS_S3_REGION`, `AWS_S3_PREFIX` | Optional |
| **Email** | `EMAIL_USER`, `EMAIL_PASS`, `EMAIL_TLS_REJECT_UNAUTHORIZED` | Optional |
| **External APIs** | `PEXELS_KEY` | Optional |
| **Logging** | `LOG_LEVEL` | Defaults |
---
### Index Re-exports (`utils/index.js`)
Convenient re-export of utilities:
```javascript
module.exports = {
...require('./errors'),
...require('./logger'),
envValidation: require('./env-validation'),
};
```
**Exported:**
- `AppError`, `NotFoundError`, `ValidationError`, `ForbiddenError`, `UnauthorizedError`, `ConflictError`
- `logger`, `requestLogger`, `registerProcessErrorHandlers`,
`exitAfterLogging`,
`normalizeLoggedError`
- `envValidation.validateEnv`, `envValidation.envSchema`
---
## Request Helpers (`helpers.js`)
Core helper class for Express route handling.
```javascript
const jwt = require('jsonwebtoken');
const config = require('./config');
module.exports = class Helpers {
// Wrap async route handlers to catch errors
static wrapAsync(fn) {
return function (req, res, next) {
fn(req, res, next).catch(next);
};
}
// Centralized error response handler
static commonErrorHandler(error, req, res, _next) {
const statusCode = error.code || error.status;
if ([400, 401, 403, 404, 409, 422].includes(statusCode)) {
return res.status(statusCode).send(error.message);
}
console.error(error);
return res.status(500).send('Internal server error');
}
// Generate JWT token
static jwtSign(data) {
return jwt.sign(data, config.secret_key, { expiresIn: '6h' });
}
// Validate UUID v4 format
static isUuidV4(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
);
}
};
```
**Functions:**
| Function | Purpose | Usage |
|----------|---------|-------|
| `wrapAsync(fn)` | Wraps async handlers to propagate errors | All async route handlers |
| `commonErrorHandler(err, req, res, next)` | Standardizes error responses | Route error middleware |
| `jwtSign(data)` | Creates JWT with 6h expiry | Auth service |
| `isUuidV4(value)` | Validates UUID v4 format | Route parameter validation |
## Request Validation
Request validation is centralized in:
- `src/middlewares/validate-request.ts` - Joi middleware for `params`, `query`, and `body`
- `src/validators/request-schemas.ts` - shared schemas for CRUD, auth, users, projects, tour pages, publish, and file upload endpoints
New external routes must validate all incoming `params`, `query`, and `body` before calling services. Factory CRUD routes use default schemas automatically and can override them through the `validation` option.
```typescript
import { validateRequest } from '../middlewares/validate-request.ts';
import { publish as publishSchemas } from '../validators/request-schemas.ts';
router.post(
'/save-to-stage',
validateRequest(publishSchemas.saveToStage),
wrapAsync(async (req, res) => {
const result = await PublishService.saveToStage(
req.body.projectId,
getCurrentUser(req),
);
res.status(200).json(result);
}),
);
```
Request validation errors return JSON:
```json
{
"error": "Invalid request",
"details": [
{
"path": "projectId",
"message": "\"projectId\" must be a valid GUID",
"type": "string.guid"
}
]
}
```
Service/domain `ValidationError` responses keep the legacy plain-text format unless they are raised by request validation middleware.
**Usage Pattern:**
```javascript
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
// Async route handler
router.get('/users/:id', wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send('Invalid ID format');
}
const user = await UserService.findOne(req.params.id);
res.json(user);
}));
// Register error handler at end of router
router.use('/', commonErrorHandler);
```
---
## Database Utilities (`db/utils.js`)
Utilities specific to Sequelize database operations, including clean UUID validation functions.
```javascript
const validator = require('validator');
const { v4: uuidv4 } = require('uuid');
const Sequelize = require('./models').Sequelize;
module.exports = class Utils {
// Check if value is a valid UUID
static isValidUuid(value) {
return Boolean(value && validator.isUUID(String(value)));
}
// Generate a new UUID v4
static generateUuid() {
return uuidv4();
}
// Filter array to only valid UUIDs
static filterValidUuids(values) {
return values.filter((v) => this.isValidUuid(v));
}
// Case-insensitive LIKE query
static ilike(model, column, value) {
return Sequelize.where(
Sequelize.fn('lower', Sequelize.col(`${model}.${column}`)),
{ [Sequelize.Op.like]: `%${value}%`.toLowerCase() },
);
}
};
```
**Functions:**
| Function | Purpose | Returns |
|----------|---------|---------|
| `isValidUuid(value)` | Check if value is a valid UUID | `boolean` |
| `generateUuid()` | Generate a new UUID v4 | `string` |
| `filterValidUuids(values)` | Filter array to only valid UUIDs | `string[]` |
| `ilike(model, column, value)` | Case-insensitive LIKE search | Sequelize where clause |
**UUID Validation Behavior:**
- Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately
- Invalid UUIDs in relation filters (`?project=uuid|name`) → filtered out for ID search, kept for text search
- Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately
**Usage in DB API:**
```javascript
const Utils = require('../utils');
class GenericDBApi {
async findAll(filter = {}, options = {}) {
// Single ID validation
if (filter.id) {
if (!Utils.isValidUuid(filter.id)) {
return { rows: [], count: 0 };
}
where.id = filter.id;
}
// Relation filter with mixed UUID/text search
for (const rel of this.RELATION_FILTERS) {
if (filter[rel.filterKey]) {
const searchTerms = filter[rel.filterKey].split('|');
const validUuids = Utils.filterValidUuids(searchTerms);
// UUID search: only valid UUIDs
// Text search: all terms
}
}
}
async findAllAutocomplete({ query, limit, offset }) {
const orConditions = [
Utils.ilike(this.TABLE_NAME, this.AUTOCOMPLETE_FIELD, query),
];
// Only add UUID search if query is a valid UUID
if (Utils.isValidUuid(query)) {
orConditions.unshift({ id: query });
}
// ...
}
}
```
---
## Notifications Module (`services/notifications/`)
i18n message management and legacy error classes.
### Message Catalog (`list.js`)
Centralized message definitions with placeholders:
```javascript
const errors = {
app: {
title: 'Tour Builder Platform',
},
auth: {
userDisabled: 'Your account is disabled',
forbidden: 'Forbidden',
unauthorized: 'Unauthorized',
userNotFound: `Sorry, we don't recognize your credentials`,
wrongPassword: `Sorry, we don't recognize your credentials`,
weakPassword: 'This password is too weak',
emailAlreadyInUse: 'Email is already in use',
invalidEmail: 'Please provide a valid email',
passwordReset: {
invalidToken: 'Password reset link is invalid or has expired',
error: `Email not recognized`,
},
passwordUpdate: {
samePassword: `You can't use the same password. Please create new password`,
},
userNotVerified: `Sorry, your email has not been verified yet`,
emailAddressVerificationEmail: {
invalidToken: 'Email verification link is invalid or has expired',
error: `Email not recognized`,
},
},
iam: {
errors: {
userAlreadyExists: 'User with this email already exists',
userNotFound: 'User not found',
disablingHimself: `You can't disable yourself`,
revokingOwnPermission: `You can't revoke your own owner permission`,
deletingHimself: `You can't delete yourself`,
emailRequired: 'Email is required',
},
},
importer: {
errors: {
invalidFileEmpty: 'The file is empty',
invalidFileExcel: 'Only excel (.xlsx) files are allowed',
invalidFileUpload: 'Invalid file. Make sure you are using the last version of the template.',
importHashRequired: 'Import hash is required',
importHashExistent: 'Data has already been imported',
userEmailMissing: 'Some items in the CSV do not have an email',
},
},
errors: {
forbidden: { message: 'Forbidden' },
validation: { message: 'An error occurred' },
searchQueryRequired: { message: 'Search query is required' },
},
emails: {
invitation: {
subject: `You've been invited to {0}`,
body: `<p>Hello,</p><p>You've been invited to {0}...</p>`,
},
emailAddressVerification: {
subject: `Verify your email for {0}`,
body: `<p>Hello,</p><p>Follow this link to verify...</p>`,
},
passwordReset: {
subject: `Reset your password for {0}`,
body: `<p>Hello,</p><p>Follow this link to reset...</p>`,
},
},
};
```
**Message Categories:**
| Category | Purpose | Examples |
|----------|---------|----------|
| `app` | Application metadata | `app.title` |
| `auth` | Authentication errors | `auth.userDisabled`, `auth.wrongPassword` |
| `iam` | User management errors | `iam.errors.userAlreadyExists` |
| `importer` | Import/export errors | `importer.errors.invalidFileEmpty` |
| `errors` | Generic errors | `errors.forbidden.message` |
| `emails` | Email templates | `emails.invitation.subject` |
---
### Message Helpers (`helpers.js`)
Functions for message formatting and lookup:
```javascript
const _get = require('lodash/get');
const errors = require('./list');
// Format message with placeholder substitution
function format(message, args) {
if (!message) return null;
return message.replace(/{(\d+)}/g, function (match, number) {
return typeof args[number] != 'undefined' ? args[number] : match;
});
}
// Check if key exists in catalog
const isNotification = (key) => {
const message = _get(errors, key);
return !!message;
};
// Get formatted message by key
const getNotification = (key, ...args) => {
const message = _get(errors, key);
if (!message) return key;
return format(message, args);
};
```
**Usage:**
```javascript
const { getNotification, isNotification } = require('./helpers');
// Lookup message
getNotification('auth.userDisabled');
// → 'Your account is disabled'
// Format with placeholders
getNotification('emails.invitation.subject', 'Tour Builder');
// → "You've been invited to Tour Builder"
// Check existence
isNotification('auth.userDisabled'); // → true
isNotification('unknown.key'); // → false
```
---
### Legacy Error Classes (`errors/`)
i18n-aware error classes (legacy pattern, prefer `utils/errors.js`):
**ForbiddenError:**
```javascript
const { getNotification, isNotification } = require('../helpers');
module.exports = class ForbiddenError extends Error {
constructor(messageCode) {
let message;
if (messageCode && isNotification(messageCode)) {
message = getNotification(messageCode);
}
message = message || getNotification('errors.forbidden.message');
super(message);
this.code = 403;
}
};
```
**ValidationError:**
```javascript
module.exports = class ValidationError extends Error {
constructor(messageCode) {
let message;
if (messageCode && isNotification(messageCode)) {
message = getNotification(messageCode);
}
message = message || getNotification('errors.validation.message');
super(message);
this.code = 400;
}
};
```
**Usage:**
```javascript
const ForbiddenError = require('./services/notifications/errors/forbidden');
const ValidationError = require('./services/notifications/errors/validation');
// With i18n key
throw new ForbiddenError('auth.forbidden');
throw new ValidationError('iam.errors.emailRequired');
// With default message
throw new ForbiddenError(); // → 'Forbidden'
throw new ValidationError(); // → 'An error occurred'
```
---
## Error Handling Flow
```
┌─────────────────────────────────────────────────────────────┐
│ Route Handler │
│ │
│ router.get('/users/:id', wrapAsync(async (req, res) => { │
│ throw new NotFoundError('User'); │
│ })); │
└─────────────────────────────────────────────────────────────┘
wrapAsync catches
┌─────────────────────────────────────────────────────────────┐
│ commonErrorHandler │
│ │
│ - Checks error.code or error.status │
│ - Known codes (400-422): returns error.message │
│ - Unknown: returns 'Internal server error' │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ HTTP Response │
│ │
│ HTTP/1.1 404 Not Found │
│ Content-Type: text/plain │
│ │
│ User not found │
└─────────────────────────────────────────────────────────────┘
```
---
## Usage Summary
### Recommended Imports
```javascript
// Modern error classes
const { NotFoundError, ValidationError, ForbiddenError } = require('./utils');
// Logging
const { logger, requestLogger } = require('./utils/logger');
// Request helpers
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('./helpers');
// Database utilities
const Utils = require('./db/utils');
// i18n messages
const { getNotification } = require('./services/notifications/helpers');
```
### Error Class Selection
| Scenario | Recommended Class |
|----------|-------------------|
| Resource not found | `NotFoundError` from `utils/errors.js` |
| Invalid input | `ValidationError` from `utils/errors.js` |
| Permission denied | `ForbiddenError` from `utils/errors.js` |
| Auth required | `UnauthorizedError` from `utils/errors.js` |
| Duplicate resource | `ConflictError` from `utils/errors.js` |
| i18n error message | Legacy classes from `notifications/errors/` |
---
## Environment Variables Reference
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `NODE_ENV` | string | `development` | `development`, `test`, `production`, `dev_stage` |
| `PORT` | number | `8080` | Server port |
| `DB_HOST` | string | `localhost` | PostgreSQL host |
| `DB_PORT` | number | `5432` | PostgreSQL port |
| `DB_NAME` | string | `db_tour_builder_platform` | Database name |
| `DB_USER` | string | `postgres` | Database user |
| `DB_PASS` | string | `` | Database password |
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
| `ADMIN_EMAIL` | email | `admin@flatlogic.com` | Admin account email |
| `ADMIN_PASS` | string | `88dbeaf8` | Admin account password |
| `USER_PASS` | string | `c3baadeda5c6` | Default user password |
| `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 |
| `AWS_ACCESS_KEY_ID` | string | `` | AWS access key |
| `AWS_SECRET_ACCESS_KEY` | string | `` | AWS secret key |
| `AWS_S3_BUCKET` | string | `` | S3 bucket name |
| `AWS_S3_REGION` | string | `us-east-1` | S3 region |
| `AWS_S3_PREFIX` | string | UUID | S3 key prefix |
| `EMAIL_USER` | string | `` | SMTP username |
| `EMAIL_PASS` | string | `` | SMTP password |
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS cert validation |
| `PEXELS_KEY` | string | `` | Pexels API key |
| `LOG_LEVEL` | string | `info` | Pino log level |
---
## Related Documentation
- [Backend Architecture](../backend-architecture.md) - Overall backend structure
- [Auth Module](./auth.md) - Authentication using JWT helpers
- [Middleware Module](./middleware.md) - Request middleware
- [Routes Module](./routes.md) - Route handlers using wrapAsync
- [Services Module](./services.md) - Business logic services
- [DB Config Module](./db-config.md) - Database configuration

77
backend/docs/testing.md Normal file
View File

@ -0,0 +1,77 @@
# Backend Testing
## Overview
Backend tests use Node.js 24's built-in test runner and TypeScript test files.
The suite is split into three layers:
| Layer | Command | Location | Purpose |
|-------|---------|----------|---------|
| Unit | `npm run test` | `backend/tests/*.test.ts` | Pure helpers, validators, policy decisions, service contracts, and file/session utilities |
| Integration | `npm run test:integration` | `backend/tests/integration/*.test.ts` | Cross-module behavior such as DB-backed access policy and Express router factory contracts |
| E2E | `npm run test:e2e` | `backend/tests/e2e/*.test.ts` | Real HTTP request/response checks against local Express test apps |
| Full suite | `npm run test:all` | all test folders | Runs unit, integration, and e2e in sequence |
| Verification | `npm run verify` | static checks plus all test folders | Runs typecheck, lint, ESM boundary checks, and the full test suite |
## Current Coverage
- Request validation and `commonErrorHandler` JSON handling.
- Publish, file upload/presign, upload chunk, and tour page reorder request
validation contracts.
- File service path validation, structured file error payloads, and local
presigned URL fallback behavior.
- Environment validation for config-only runtime settings, including normalized
file-storage provider overrides.
- Circuit breaker behavior for ignored failures, recorded failures, and open
breaker rejection.
- Auth service password reset, password update rejection rules, and email
verification token behavior with real bcrypt and DB writes.
- Route ID/body ID update contract enforcement.
- Permission name mapping, own-user route bypass, runtime public read bypass,
and `AccessPolicy` public-user hardening.
- Generic DB API and entity service object-signature contracts.
- OpenAPI route coverage and internal `$ref` resolution.
- `UploadSessionManager` metadata, chunk tracking, assembly order, and cleanup behavior.
- `createEntityRouter` service/DB option propagation, query normalization, update ID mismatch handling, and CSV export behavior.
- Public runtime list blocking and response sanitization for presentation data.
- Publish service environment copy behavior for dev to stage and stage to production,
including target replacement, source preservation, audio tracks, transition
settings, and UI-control settings.
- Runtime context HTTP behavior for default, valid, and unsupported runtime headers.
## Environment Notes
DB-backed integration tests authenticate against the configured PostgreSQL
connection. If PostgreSQL is unavailable, those tests call `t.skip()` with the
connection error so non-DB integration tests can still run.
`npm run test:integration` runs with `--test-concurrency=1` because several
integration tests share a PostgreSQL transaction connection; keep DB-backed
tests sequential unless they use isolated connections.
E2E tests bind a local HTTP listener on `127.0.0.1` with an ephemeral port. In
restricted sandboxes this may require elevated permission for local socket
binding.
## Test Helpers
`backend/tests/http-test-utils.ts` provides `startTestServer(app)`, which starts
an Express app on an ephemeral local port and returns `{ baseUrl, close }`.
Use it only for e2e-style tests that must exercise real HTTP semantics.
Integration tests that do not need a network socket should drive Express apps in
memory with `node-mocks-http`; this keeps them usable in restricted sandboxes.
## Adding Tests
- Prefer unit tests for pure helpers, validators, policies, and isolated service
logic.
- Use integration tests when behavior crosses route, middleware, service, DB API,
or Sequelize transaction boundaries.
- Use e2e tests when the behavior depends on real HTTP parsing, headers, status
codes, or socket-level request handling.
- Keep new backend tests strict TypeScript compatible and avoid casts, `any`,
`eslint-disable`, and `@ts-ignore`.
- When config-backed behavior must be overridden in tests, mutate the typed
config object and restore it in `finally`; do not rely on changing
`process.env` after modules have imported `backend/src/config.ts`.
- For DB integration tests, wrap writes in a transaction and roll it back.

View File

@ -0,0 +1,708 @@
# Access Logs & Audit Trail
Complete documentation for the Tour Builder Platform's Access Logs and Audit Trail system including activity tracking, security auditing, and data preservation.
## Overview
The platform implements a comprehensive audit system with two complementary mechanisms:
- **Access Logs** - Dedicated table recording API requests with user, path, and client info
- **Audit Trail** - CreatedBy/UpdatedBy fields on all entities tracking who modified what
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Access Logs & Audit Trail Architecture │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Request Flow │ │
│ │ │ │
│ │ Client Request │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Request Logger Middleware │ │ │
│ │ │ │ │ │
│ │ │ • Generate/Extract X-Request-Id │ │ │
│ │ │ • Capture: method, path, user-agent, duration │ │ │
│ │ │ • Log to Pino (structured logging) │ │ │
│ │ └──────────────────────────┬──────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ JWT Authentication │ │ │
│ │ │ │ │ │
│ │ │ • Validate token │ │ │
│ │ │ • Attach currentUser to request │ │ │
│ │ └──────────────────────────┬──────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Permission Check │ │ │
│ │ │ │ │ │
│ │ │ • Check READ/CREATE/UPDATE/DELETE permissions │ │ │
│ │ │ • Role-based + custom permissions │ │ │
│ │ └──────────────────────────┬──────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Database Operation │ │ │
│ │ │ │ │ │
│ │ │ CREATE: set createdById, updatedById │ │ │
│ │ │ UPDATE: set updatedById │ │ │
│ │ │ DELETE: set deletedBy, mark deletedAt (soft delete) │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Data Model
### Access Logs Schema
**Table:** `access_logs`
**Source:** `backend/src/db/models/access_logs.js`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| id | UUID | Yes | UUIDv4 | Primary key |
| projectId | UUID (FK) | No | null | Related project (CASCADE delete) |
| userId | UUID (FK) | No | null | User who accessed (SET NULL delete) |
| environment | ENUM | Yes | - | 'admin', 'stage', 'production' |
| path | TEXT | No | null | API path accessed (max 2048 chars) |
| ip_address | TEXT | No | null | Client IP address (max 45 chars for IPv6) |
| user_agent | TEXT | No | null | HTTP User-Agent header (max 1024 chars) |
| accessed_at | TIMESTAMP | Yes | NOW | When access occurred |
| importHash | VARCHAR(255) | No | null | Unique hash for CSV imports |
| createdAt | TIMESTAMP | Yes | Auto | Record creation time |
| updatedAt | TIMESTAMP | Yes | Auto | Record update time |
| deletedAt | TIMESTAMP | No | null | Soft delete timestamp |
### Indexes
```javascript
indexes: [
{ fields: ['projectId'] }, // Query logs by project
{ fields: ['environment'] }, // Filter by environment
{ fields: ['userId'] }, // Query logs by user
{ fields: ['accessed_at'] }, // Time-range queries and sorting
]
```
### Relationships
```
access_logs
├── belongsTo projects (as project)
│ ├── foreignKey: projectId
│ ├── onDelete: CASCADE ← Logs deleted when project deleted
│ └── onUpdate: CASCADE
├── belongsTo users (as user)
│ ├── foreignKey: userId
│ ├── onDelete: CASCADE ← Logs deleted when user deleted
│ └── onUpdate: CASCADE
├── belongsTo users (as createdBy) ─── Audit: who created this log
└── belongsTo users (as updatedBy) ─── Audit: who last updated this log
```
## Audit Trail Pattern
### CreatedBy/UpdatedBy Implementation
All entities in the system implement the audit trail pattern:
**Source:** `backend/src/db/api/base.api.js`
```javascript
// On CREATE
static async create({ data, currentUser = { id: null }, transaction }) {
const mappedData = this.getFieldMapping(data);
const record = await this.MODEL.create({
...mappedData,
createdById: currentUser.id, // Track creator
updatedById: currentUser.id, // Initial updater = creator
}, { transaction });
}
// On UPDATE
static async update({ id, data, currentUser = { id: null }, transaction }) {
await record.update({
...updatePayload,
updatedById: currentUser.id, // Track who modified
}, { transaction });
}
// On DELETE (soft delete)
static async remove({ id, currentUser = { id: null }, transaction }) {
await record.update({ deletedBy: currentUser.id }, { transaction });
await record.destroy({ transaction }); // Sets deletedAt
}
```
### Entities with Audit Trail
All models include audit relationships:
```javascript
// Applied to ALL models:
db.[entity].belongsTo(db.users, { as: 'createdBy' });
db.[entity].belongsTo(db.users, { as: 'updatedBy' });
```
**Audited Entities:**
- users, roles, permissions
- projects, project_memberships
- tour_pages, project_audio_tracks
- assets, asset_variants
- element_type_defaults, project_element_defaults
- publish_events, pwa_caches
- presigned_url_requests, access_logs
**Note:** Page elements, navigation links, and transitions are stored in `tour_pages.ui_schema_json` and audited as part of the tour_pages record.
### Timestamp Tracking
Sequelize provides automatic timestamps:
```javascript
{
timestamps: true, // Enables createdAt, updatedAt
paranoid: true, // Enables deletedAt (soft delete)
}
```
| Field | Set When | Never Changes |
|-------|----------|---------------|
| `createdAt` | Record created | ✓ Immutable |
| `updatedAt` | Any modification | Updates each save |
| `deletedAt` | Soft delete called | - |
## Request Logging Middleware
### Logger Configuration
**Source:** `backend/src/utils/logger.js`
```javascript
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: isDevelopment
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
base: {
service: 'tour-builder-api',
env: process.env.NODE_ENV || 'development',
},
});
```
### Request Logger Middleware
```javascript
function requestLogger(req, res, next) {
// Generate or extract request ID for tracing
const requestId = req.headers['x-request-id'] || crypto.randomUUID();
req.log = logger.child({ requestId });
req.requestId = requestId;
res.setHeader('X-Request-Id', requestId);
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const logData = {
method: req.method,
url: req.originalUrl || req.url,
status: res.statusCode,
duration,
userAgent: req.headers['user-agent'],
};
// Log level based on response status
if (res.statusCode >= 500) {
req.log.error(logData, 'Request completed with server error');
} else if (res.statusCode >= 400) {
req.log.warn(logData, 'Request completed with client error');
} else {
req.log.info(logData, 'Request completed');
}
});
next();
}
```
### Applied in Application
**Source:** `backend/src/index.ts`
```javascript
app.enable('trust proxy'); // Extract real IP behind proxies
app.use(requestLogger); // Apply to all routes
```
### Log Output Format
**Development (pino-pretty):**
```
[10:30:45.123] INFO: Request completed
method: "GET"
url: "/api/projects"
status: 200
duration: 45
userAgent: "Mozilla/5.0..."
requestId: "abc123-def456"
```
**Production (JSON):**
```json
{
"level": 30,
"time": 1711234567890,
"service": "tour-builder-api",
"env": "production",
"requestId": "abc123-def456",
"method": "GET",
"url": "/api/projects",
"status": 200,
"duration": 45,
"userAgent": "Mozilla/5.0..."
}
```
## API Reference
### Endpoints
**Source:** `backend/src/routes/access_logs.ts`
| Method | Endpoint | Permission | Description |
|--------|----------|------------|-------------|
| GET | `/api/access_logs` | READ_ACCESS_LOGS | List with filtering |
| GET | `/api/access_logs/:id` | READ_ACCESS_LOGS | Get single log |
| POST | `/api/access_logs` | CREATE_ACCESS_LOGS | Create log entry |
| PUT | `/api/access_logs/:id` | UPDATE_ACCESS_LOGS | Update log |
| DELETE | `/api/access_logs/:id` | DELETE_ACCESS_LOGS | Soft delete log |
### Query Parameters
**Source:** `backend/src/db/api/access_logs.ts`
| Parameter | Type | Description |
|-----------|------|-------------|
| `limit` | number | Results per page (default: 50) |
| `page` | number | Page number (0-based) |
| `field` | string | Sort field |
| `sort` | 'asc' \| 'desc' | Sort direction |
| `path` | string | Search path (ILIKE) |
| `ip_address` | string | Search IP address (ILIKE) |
| `user_agent` | string | Search user agent (ILIKE) |
| `accessed_atRange` | [start, end] | Date range filter |
| `environment` | enum | Filter by environment |
| `project` | UUID \| string | Filter by project |
| `user` | UUID \| string | Filter by user |
| `filetype` | 'csv' | Export as CSV |
### Request/Response Examples
**List Logs with Filters:**
```http
GET /api/access_logs?limit=50&page=1&environment=production&accessed_atRange=["2024-01-01","2024-12-31"]&sort=DESC&field=accessed_at
Authorization: Bearer <jwt-token>
```
**Response:**
```json
{
"rows": [
{
"id": "log-uuid-1",
"projectId": "project-uuid",
"userId": "user-uuid",
"environment": "production",
"path": "/api/tour_pages",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)...",
"accessed_at": "2024-03-15T10:30:00Z",
"project": {
"id": "project-uuid",
"name": "My Tour"
},
"user": {
"id": "user-uuid",
"firstName": "John",
"lastName": "Doe"
},
"createdAt": "2024-03-15T10:30:00Z",
"updatedAt": "2024-03-15T10:30:00Z"
}
],
"count": 1542
}
```
**Create Log Entry:**
```http
POST /api/access_logs
Content-Type: application/json
Authorization: Bearer <jwt-token>
{
"data": {
"project": "project-uuid",
"user": "user-uuid",
"environment": "production",
"path": "/api/tour_pages",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0...",
"accessed_at": "2024-03-15T10:30:00Z"
}
}
```
### CSV Export
**Fields Exported:**
- id
- environment
- path
- ip_address
- user_agent
- accessed_at
- createdAt
```http
GET /api/access_logs?filetype=csv
Authorization: Bearer <jwt-token>
```
## Frontend Implementation
### Admin Pages
**Location:** `frontend/src/pages/access_logs/`
| Page | Path | Description |
|------|------|-------------|
| List | `/access_logs/access_logs-list` | Filterable table view |
| Table | `/access_logs/access_logs-table` | Data grid variant |
| New | `/access_logs/access_logs-new` | Create log entry |
| Edit | `/access_logs/access_logs-edit?id=<uuid>` | Edit log |
| View | `/access_logs/access_logs-view?id=<uuid>` | Read-only details |
| Dynamic | `/access_logs/[access_logsId]` | Dynamic route (ID in path) |
**Note:** Uses `useEditPageSync` hook for edit page data synchronization.
### List Page Filters
**Source:** `frontend/src/pages/access_logs/access_logs-list.tsx`
| Filter | Type | Description |
|--------|------|-------------|
| Path | Text | Search by API path |
| IP Address | Text | Search by client IP |
| User Agent | Text | Search by browser/client |
| Accessed At | Date Range | Filter by time range |
| Project | Dropdown | Filter by project |
| User | Dropdown | Filter by user |
| Environment | Enum | admin / stage / production |
### Table Columns
**Source:** `frontend/src/components/Access_logs/configureAccess_logsCols.tsx`
Uses `createColumnLoader` factory from `configBuilderFactory.tsx`:
| Column | Editable | Type |
|--------|----------|------|
| project | Yes | singleSelectRelation (projects) |
| environment | Yes | text |
| user | Yes | singleSelectRelation (users) |
| path | Yes | text |
| ip_address | Yes | text |
| user_agent | Yes | text |
| accessed_at | Yes | datetime |
| actions | - | actions (Edit / View / Delete) |
### TypeScript Interface
**Source:** `frontend/src/types/entities.ts`
```typescript
export interface AccessLog extends BaseEntity {
user?: User | string | null;
project?: Project | string | null;
environment?: 'admin' | 'stage' | 'production';
path?: string;
action?: string;
ip_address?: string;
user_agent?: string;
accessed_at?: string | Date;
metadata?: Record<string, unknown>;
}
```
### Redux State
**Source:** `frontend/src/stores/access_logs/access_logsSlice.ts`
Uses `createEntitySlice` factory for standardized CRUD operations:
```typescript
const { slice, actions, reducer } = createEntitySlice<AccessLog>({
name: 'access_logs',
endpoint: 'access_logs',
singularName: 'Access Log',
});
export const {
fetch, // Load logs with query
create, // Create new log
update, // Update existing log
deleteItem, // Delete single log
deleteItemsByIds,// Batch delete
uploadCsv, // Import from CSV
setRefetch, // Trigger refresh
} = actions;
// Usage
dispatch(fetch({ query: '?environment=production&limit=100' }));
```
## Security & Permissions
### Role-Permission Matrix
**Source:** `backend/src/db/seeders/20200430130760-user-roles.js`
| Role | CREATE | READ | UPDATE | DELETE |
|------|--------|------|--------|--------|
| PlatformOwner | ✓ | ✓ | ✓ | ✓ |
| Administrator | ✓ | ✓ | ✓ | ✓ |
| AccountManager | ✗ | ✓ | ✗ | ✗ |
| TourDesigner | ✗ | ✓ | ✗ | ✗ |
| ContentReviewer | ✗ | ✓ | ✗ | ✗ |
| AnalyticsViewer | ✗ | ✓ | ✗ | ✗ |
### Permission Check Flow
```
Request → JWT Auth → checkCrudPermissions('access_logs') → Route Handler
┌───────────────────────────────┐
│ 1. AccessPolicy │
│ 2. Custom user permissions │
│ 3. Role-based permissions │
│ 4. Public hardening │
└───────────────────────────────┘
```
### Cascade Delete Behavior
Both user and project deletions cascade to access logs:
```javascript
// access_logs -> users
onDelete: 'CASCADE' // Logs deleted with user
// access_logs -> projects
onDelete: 'CASCADE' // Logs deleted with project
```
> **Note:** Access logs are NOT preserved when users or projects are deleted. If audit history retention is required, consider archiving logs before deletion or implementing SET NULL behavior.
## Data Capture Scope
### What IS Captured
| Data Point | Location | Purpose |
|------------|----------|---------|
| User ID | access_logs.userId | Who made the request |
| Project ID | access_logs.projectId | Which project context |
| API Path | access_logs.path | What endpoint accessed |
| IP Address | access_logs.ip_address | Client location/identity |
| User Agent | access_logs.user_agent | Browser/client identification |
| Timestamp | access_logs.accessed_at | When it happened |
| Environment | access_logs.environment | admin/stage/production |
### What IS NOT Captured
| Data Point | Reason |
|------------|--------|
| Request body | Privacy protection |
| Response content | Privacy/performance |
| Query parameters | Only path stored |
| Authentication credentials | Security |
| Personal user data | Beyond ID reference |
### Audit Trail vs Access Logs
| Aspect | Audit Trail | Access Logs |
|--------|-------------|-------------|
| Scope | All entities | Dedicated table |
| Granularity | Per-record changes | Per-request |
| Data | Who modified when | Request details |
| Retention | With entity | Separate lifecycle |
| Purpose | Change tracking | Security monitoring |
## Soft Delete & Retention
### Paranoid Mode
```javascript
{
timestamps: true,
paranoid: true, // Enables soft deletes
}
```
### Deletion Behavior
1. **Soft Delete**: Sets `deletedAt` timestamp
2. **Query Filtering**: Normal queries exclude deleted records
3. **Restoration**: Possible by clearing `deletedAt`
4. **Hard Delete**: Requires explicit permanent removal
### Retention Considerations
**Current Implementation:**
- No automatic log pruning
- Logs retained indefinitely
- Manual cleanup via DELETE endpoints
**Recommended Practices:**
- Implement TTL-based cleanup for old logs
- Archive logs to cold storage after 90 days
- Consider GDPR right-to-erasure requirements
## File Structure
### Backend
| File | Purpose |
|------|---------|
| `backend/src/db/models/access_logs.js` | Sequelize model definition |
| `backend/src/db/api/access_logs.ts` | Database API (CRUD) |
| `backend/src/db/api/base.api.js` | Audit trail implementation |
| `backend/src/routes/access_logs.ts` | REST endpoints |
| `backend/src/services/access_logs.ts` | Business logic |
| `backend/src/utils/logger.js` | Request logging middleware |
| `backend/src/middlewares/check-permissions.ts` | Permission enforcement |
### Frontend
| File | Purpose |
|------|---------|
| `frontend/src/pages/access_logs/access_logs-list.tsx` | List page |
| `frontend/src/pages/access_logs/access_logs-table.tsx` | Table page |
| `frontend/src/pages/access_logs/access_logs-new.tsx` | Create page |
| `frontend/src/pages/access_logs/access_logs-edit.tsx` | Edit page |
| `frontend/src/pages/access_logs/access_logs-view.tsx` | View page |
| `frontend/src/pages/access_logs/[access_logsId].tsx` | Dynamic route |
| `frontend/src/stores/access_logs/access_logsSlice.ts` | Redux state (createEntitySlice) |
| `frontend/src/components/Access_logs/TableAccess_logs.tsx` | Data grid component |
| `frontend/src/components/Access_logs/ListAccess_logs.tsx` | List view component |
| `frontend/src/components/Access_logs/CardAccess_logs.tsx` | Card view component |
| `frontend/src/components/Access_logs/configureAccess_logsCols.tsx` | Column config (createColumnLoader) |
| `frontend/src/types/entities.ts` | TypeScript interfaces |
## Known Considerations
### 1. Manual Log Creation
**Issue:** Access logs are typically created manually via API, not automatically by middleware.
**Impact:** Requires explicit logging calls or additional middleware to auto-populate.
### 2. User Deletion Cascades
**Issue:** `CASCADE` on userId means logs are deleted when user is deleted.
**Impact:** Audit history lost with user deletion. Consider archiving logs before user deletion or changing to `SET NULL` for preservation.
### 3. Project Deletion Cascades
**Issue:** All access logs deleted when project is deleted.
**Impact:** Audit history lost with project. Consider archiving before deletion.
### 4. No Query Parameter Logging
**Issue:** Only `path` is stored, not query strings.
**Impact:** Cannot reconstruct full request URL. May miss filter/search context.
### 5. IPv6 Support
**Issue:** `ip_address` limited to 45 characters.
**Impact:** Supports full IPv6 addresses (39 chars max). Adequate for current needs.
### 6. User Agent Truncation
**Issue:** `user_agent` limited to 1024 characters.
**Impact:** Very long user agents may be truncated. Rare but possible.
### 7. No Automatic Retention
**Issue:** Logs grow indefinitely without cleanup.
**Impact:** Database size increases over time. Recommend implementing retention policy.
### 8. TypeScript Interface Extras
**Issue:** Frontend `AccessLog` interface includes `action` and `metadata` fields not in database model.
**Impact:** These fields exist only in TypeScript for potential future use. Currently not persisted.
## Integration Examples
### Log Access in Custom Middleware
```javascript
import Access_logsDBApi from '../db/api/access_logs.ts';
async function logAccess(req, res, next) {
// Log after response completes
res.on('finish', async () => {
try {
await Access_logsDBApi.create({
project: req.project?.id,
user: req.currentUser?.id,
environment: determineEnvironment(req),
path: req.path,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
accessed_at: new Date(),
}, { currentUser: req.currentUser });
} catch (err) {
// Don't fail request on logging error
console.error('Failed to log access:', err);
}
});
next();
}
```
### Query Logs by Time Range
```typescript
// Frontend
dispatch(fetch({
query: '?accessed_atRange=["2024-01-01T00:00:00Z","2024-01-31T23:59:59Z"]&environment=production&sort=DESC&field=accessed_at'
}));
```
### Export Logs for Compliance
```bash
curl -H "Authorization: Bearer $TOKEN" \
"https://api.example.com/api/access_logs?filetype=csv&accessed_atRange=[\"2024-01-01\",\"2024-03-31\"]" \
> access_logs_q1_2024.csv
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,974 @@
# Assets Preloading Feature - E2E Documentation
## Overview
The Tour Builder Platform implements a sophisticated dual-mode asset preloading system that optimizes user experience in both online and offline scenarios. The system intelligently prefetches assets based on navigation patterns, network conditions, and storage constraints.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Configuration Layer │
│ preload.config.ts │ offline.config.ts │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Hook Layer │
│ usePreloadOrchestrator │ usePageNavigationState │ useNetworkAware│
│ usePreloadProgress │ usePWAPreload │ useOfflineMode │
│ useStorageQuota │ useIconPreload │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Library Layer │
│ DownloadEventBus │ DownloadManager │ StorageManager │
│ OfflineDbManager │ extractPageLinks │ assetCache │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Service Worker (Serwist) │
│ sw.ts - CacheFirst, NetworkFirst, Range Request Support │
└─────────────────────────────────────────────────────────────────┘
```
**Recent Refactor:** The preloading system was simplified to use a "stream-first" approach:
- **Online mode:** Preloads current page + outgoing transition videos only (no neighbor preloading)
- **Offline mode:** Full project assets downloaded via `useOfflineMode.startDownload()`
- **Video playback:** Streams on-demand, then caches for replay (no bandwidth competition)
- `useNeighborGraph` was removed (neighbor preloading caused excessive network requests)
- `usePageSwitch`, `useBackgroundTransition`, and other hooks were consolidated into `usePageNavigationState`
---
## Online Mode (Stream-First)
### How It Works
Online mode uses a **stream-first** approach orchestrated by `usePreloadOrchestrator.ts`:
- **Current page assets** are preloaded (backgrounds, element images)
- **Outgoing transition videos** are preloaded for instant playback
- **Other videos** stream on-demand using presigned URLs (browser handles buffering)
- **No neighbor page preloading** - eliminated to reduce network contention
### Asset Discovery Flow
1. **Page Change Detection** - Monitors `currentPageId` changes
2. **Current Page Asset Extraction** - Extracts URLs from:
- **Page backgrounds** - `background_image_url`, `background_video_url`, `background_audio_url`
- **Element content** - URLs from `ui_schema_json` elements
3. **Transition Video Extraction** - Gets `transitionVideoUrl` from outgoing page links
4. **Priority Assignment** - Assigns download priority based on asset type
### Asset URL Extraction Fields
The system extracts URLs from these `content_json` fields (configured in `PRELOAD_CONFIG.assetFields`):
**Direct Fields:**
`iconUrl`, `imageUrl`, `mediaUrl`, `videoUrl`, `audioUrl`, `transitionVideoUrl`, `backgroundImageUrl`, `reverseVideoUrl`, `carouselPrevIconUrl`, `carouselNextIconUrl`, `src`, `url`, `poster`, `thumbnail`
**Nested Arrays:**
- `galleryCards[].imageUrl`, `galleryCards[].videoUrl`
- `carouselSlides[].imageUrl`, `carouselSlides[].videoUrl`
**Page Links:**
- `pageLink.transition.video_url` (eagerly loaded from backend API)
### Priority Calculation
```
Priority = basePriority + assetTypeBonus + variantBonus
Current Page Assets: priority = 1000 + bonuses
Transition Videos: priority = 1000 + 150 = 1150 (highest)
```
**Asset Type Multipliers:**
| Type | Weight | Notes |
|------|--------|-------|
| Transition | 150 | Highest - needed immediately on navigation click |
| Image | 100 | Background and UI elements |
| Audio | 50 | Background audio tracks |
| Video | 30 | Lower priority since they stream |
**Note:** Neighbor page preloading was removed to simplify the system and reduce network contention. Videos stream on-demand using presigned URLs with browser-managed buffering.
**Variant Multipliers:**
| Variant | Weight |
|---------|--------|
| Thumbnail | 50 |
| Preview | 40 |
| WebP | 35 |
| MP4 Low | 20 |
| MP4 High | 10 |
| Original | 5 |
### Network-Aware Concurrency
The system adapts download concurrency based on network conditions via `useNetworkAware.ts`:
| Connection | Concurrent Downloads | Strategy |
|------------|---------------------|----------|
| 4g / Good | 3 | Aggressive |
| 3g | 2 | Normal |
| 2g / Slow | 1 | Conservative |
| Save-Data | 1 | Minimal |
**Network Thresholds:**
- Aggressive: 4g OR downlink ≥ 5Mbps
- Suggest offline: slow-2g OR 2g OR RTT > 500ms OR downlink < 0.5Mbps
- Low quality: save-data flag OR slow-2g OR downlink < 1Mbps
### Download Process
1. Fetch asset with progress tracking via ReadableStream
2. Emit progress events via `DownloadEventBus`
3. Store in Cache API (`tour-builder-assets-v1`) under download URL
4. **Store in Cache API under storage key** (enables post-refresh lookups)
5. **Create Blob URL**: `URL.createObjectURL(blob)` for local reference
6. **Image Decoding**: Call `image.decode()` on the blob URL to eliminate white flash on navigation
7. **Store Ready URLs**: Map both download URL AND storage key to blob URL in `readyBlobUrlsRef`
8. Track cached assets in memory Set for quick lookups
**Storage Key Mapping (Key Feature):**
The system maps assets by **canonical storage key** (e.g., `assets/project-123/video.mp4`) in addition to download URLs. This solves the presigned URL mismatch problem where different requests generate different signatures.
```
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ STORAGE KEY MAPPING │
│ │
│ Storage Key: "assets/project-123/video.mp4" ← CANONICAL IDENTIFIER (never changes) │
│ │ │
│ ┌──────────────┴──────────────┐ │
│ ▼ ▼ │
│ Download URL #1 Download URL #2
│ "https://s3...?Sig=ABC" "https://s3...?Sig=XYZ" │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ readyBlobUrlsRef Map │ │
│ │ key: storageKey ────────────► blob://... │ │
│ │ key: downloadUrl ────────────► blob://... (same blob URL) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Lookup: getReadyBlobUrl(storageKey) → O(1) instant → blob://... │
└─────────────────────────────────────────────────────────────────────────────────────────┘
```
**Why storage key mapping matters:**
| Scenario | Download URL | Storage Key | Lookup Result |
|----------|-------------|-------------|---------------|
| Same session | `https://s3...?Sig=ABC` | `assets/vid.mp4` | ✅ Instant via storage key |
| New presigned URL | `https://s3...?Sig=XYZ` | `assets/vid.mp4` | ✅ Instant via storage key |
| Page refresh | N/A (in-memory cleared) | `assets/vid.mp4` | ✅ From Cache API by storage key |
**Ready Blob URL Flow:**
```
Download → Cache API (URL key) → Cache API (storage key) → Blob URL → Image Decode
┌── readyBlobUrlsRef.set(downloadUrl, blobUrl)
└── readyBlobUrlsRef.set(storageKey, blobUrl)
└── readyUrlsVersion++ (triggers re-render)
Navigation Click → getReadyBlobUrl(storageKey) → O(1) lookup → Instant display
```
**Re-render Trigger Mechanism:**
Since `readyBlobUrlsRef` is a ref (not state), updates don't trigger React re-renders. The `readyUrlsVersion` counter solves this:
1. When a blob URL becomes ready, `setReadyUrlsVersion(v => v + 1)` is called
2. Components using `readyUrlsVersion` in dependency arrays re-render
3. `resolveUrlWithBlob` callbacks are recreated with fresh lookups
4. UI switches from direct URLs to cached blob URLs
```typescript
// In constructor.tsx
const resolveUrlWithBlob = useCallback(
(url) => {
const blobUrl = preloadOrchestrator.getReadyBlobUrl(url);
if (blobUrl) return blobUrl;
return resolveAssetPlaybackUrl(url);
},
[preloadOrchestrator, preloadOrchestrator.readyUrlsVersion], // Re-render when ready
);
```
### Presigned URL Integration
For S3 storage, the preloader fetches presigned URLs before downloading assets for direct S3 access:
```typescript
// In usePreloadOrchestrator.ts
// 1. Collect storage paths that need presigning
const storagePaths = assets
.map(a => a.url)
.filter(isRelativeStoragePath);
// 2. Batch fetch presigned URLs (auto-batches and caches)
queuePresignedUrls(storagePaths)
.then(() => {
// 3. Add assets to queue with resolved URLs
assets.forEach(asset => {
const presignedUrl = getPresignedUrl(asset.url);
const resolvedUrl = presignedUrl || resolveAssetPlaybackUrl(asset.url);
addToQueue({ url: resolvedUrl, storageKey: asset.url, ... });
});
})
.catch(() => {
// Fallback to proxy URLs
addAssetsToQueue();
});
// 4. On successful download, mark presigned URLs as verified
// (enables resolveAssetPlaybackUrl to use presigned URLs)
if (isPresignedUrl(item.url)) {
markPresignedUrlsVerified();
}
```
**Key Functions (lib/assetUrl.ts):**
| Function | Purpose |
|----------|---------|
| `queuePresignedUrl()` | Queue single URL for presigning |
| `queuePresignedUrls()` | Batch queue URLs for presigning |
| `flushPresignedUrlQueue()` | Fetch queued presigned URLs |
| `getPresignedUrl()` | Get cached presigned URL (sync) |
| `resolveAssetPlaybackUrl()` | Resolve URL with presigned cache lookup |
| `markPresignedUrlFailed()` | Mark presigned URL as failed for specific key |
| `markPresignedUrlsVerified()` | Mark presigned URLs as verified (enables playback URL resolution) |
| `isRelativeStoragePath()` | Check if URL is a relative storage path (needs presigning) |
| `extractStoragePath()` | Extract storage path from full URL (handles S3, CDN, proxy URLs) |
| `setupPresignedUrlInterceptor()` | Setup Axios interceptor for CORS failure detection |
| `disablePresignedUrls()` | Globally disable presigned URLs (fallback to proxy) |
| `arePresignedUrlsDisabled()` | Check if presigned URLs are disabled |
| `clearPresignedUrlCache()` | Clear all cached presigned URLs |
**Key Functions (usePreloadOrchestrator):**
| Function | Purpose |
|----------|---------|
| `getReadyBlobUrl(key)` | O(1) instant lookup by storage key or resolved URL |
| `getCachedBlobUrl(key)` | Async lookup from Cache API by storage key or resolved URL |
| `isUrlPreloaded(key)` | Check if asset is ready for instant display |
**Lookup Priority (in usePageSwitch and useTransitionPlayback):**
```
1. getReadyBlobUrl(storageKey) → instant O(1) (same session)
2. getCachedBlobUrl(storageKey) → Cache API (~5ms, post-refresh)
3. getReadyBlobUrl(resolvedUrl) → fallback
4. getCachedBlobUrl(resolvedUrl) → fallback
5. Network fetch → last resort
```
**Fallback Behavior:**
- If presigned URL fails (CORS not configured), DownloadManager automatically retries with proxy URL
- This fallback is built into DownloadManager as single source of truth (no duplicate retry logic)
- Retry happens immediately with reset retry count
- Logger tracks presigned URL status changes: `[DownloadManager] Presigned URL failed, retrying with proxy`
### Key Files
| File | Location | Purpose |
|------|----------|---------|
| `usePreloadOrchestrator.ts` | `frontend/src/hooks/` | Main orchestrator with ready blob URL management |
| `usePageSwitch.ts` | `frontend/src/hooks/` | Page navigation using preloaded blob URLs |
| `useNeighborGraph.ts` | `frontend/src/hooks/` | BFS navigation graph, extracts page backgrounds + element assets |
| `useNetworkAware.ts` | `frontend/src/hooks/` | Network condition monitoring |
| `useIconPreload.ts` | `frontend/src/hooks/` | Constructor icon preloading to prevent flash |
| `extractPageLinks.ts` | `frontend/src/lib/` | Extract navigation targets and preload elements from pages |
| `preload.config.ts` | `frontend/src/config/` | Queue and priority settings |
| `assetUrl.ts` | `frontend/src/lib/` | URL resolution with presigned URL cache |
### Key Methods
| Method | Hook | Purpose |
|--------|------|---------|
| `getReadyBlobUrl(key)` | usePreloadOrchestrator | O(1) instant lookup by storage key or resolved URL |
| `getCachedBlobUrl(key)` | usePreloadOrchestrator | Async lookup from Cache API by storage key or URL |
| `isUrlPreloaded(key)` | usePreloadOrchestrator | Check if asset is ready for instant display |
| `preloadAsset(url)` | usePreloadOrchestrator | Manually trigger preload for a specific URL |
| `clearQueue()` | usePreloadOrchestrator | Clear the preload queue |
| `switchToPage(page, onSwitched?)` | usePageSwitch | Navigate with preloaded assets (resolves by storage key first) |
| `setBackgroundsDirectly(img, vid, aud)` | usePageSwitch | Set backgrounds without transition overlay (initial load) |
| `markBackgroundReady()` | usePageSwitch | Mark new background as ready (call from Image onLoad) |
| `clearPreviousBackground()` | usePageSwitch | Clear overlay after transition completes |
---
## Offline Mode
### Storage Strategy
The system uses a hybrid storage approach for optimal performance:
```
Assets < 5MB Cache API (tour-builder-assets-v1)
Assets ≥ 5MB → IndexedDB (TourBuilderOffline)
```
**Why Hybrid:**
- Cache API has better browser support but size limitations
- IndexedDB provides reliable large file storage
- `StorageManager` abstracts both for transparent access
### IndexedDB Schema (Dexie.js)
**Database:** `TourBuilderOffline` (version 1)
**Tables and Indexes:**
```javascript
{
assets: 'id, projectId, url, variantType, assetType, downloadedAt',
projects: 'id, slug, status, lastSyncedAt',
downloadQueue: 'id, projectId, status, priority, addedAt'
}
```
**TypeScript Interfaces:**
```typescript
// OfflineAsset - Large files stored in IndexedDB
interface OfflineAsset {
id: string;
projectId: string;
url: string;
filename: string;
variantType: AssetVariantType;
assetType: AssetType;
mimeType: string;
sizeBytes: number;
blob: Blob;
downloadedAt: number;
}
// OfflineProject - Project offline status
interface OfflineProject {
id: string;
slug: string;
name: string;
status: ProjectOfflineStatus;
totalAssets: number;
downloadedAssets: number;
totalSizeBytes: number;
downloadedSizeBytes: number;
lastSyncedAt?: number;
version?: string;
}
// DownloadQueueItem - Persistent download queue
interface DownloadQueueItem {
id: string;
projectId: string;
assetId: string;
url: string;
filename: string;
status: PreloadJobStatus;
priority: number;
retryCount: number;
bytesLoaded: number;
totalBytes: number;
addedAt: number;
lastAttemptAt?: number;
error?: string;
}
```
### Asset Storage Flow
```
storeAsset(blob, metadata)
├── Size < 5MB?
│ │
│ YES → Create Response with headers
│ → Store in Cache API
└── Size ≥ 5MB?
YES → Create OfflineAsset object
→ Store in IndexedDB
```
### Asset Retrieval Flow
```
getAsset(url) [StorageManager.getAsset()]
├── Check IndexedDB first (large files)
│ │
│ └── Found? → Return blob
└── Check Cache API (small files)
└── Found? → Return response blob
```
### Service Worker (Serwist)
Located at `frontend/src/sw.ts`, handles offline caching:
| Resource Type | Strategy | Cache Name |
|--------------|----------|------------|
| Static Assets (images, fonts, css, js) | CacheFirst | `tour-builder-assets-v1` |
| Videos | CacheFirst + Range | `tour-builder-assets-v1` |
| API Responses | NetworkFirst | `api-cache` |
| Pages | NetworkFirst | (default) |
**Unified Cache Keys (cacheKeyWillBeUsed Plugin):**
The Service Worker normalizes all asset URLs to storage keys using `cacheKeyWillBeUsed` plugin. This ensures preload (DownloadManager) and browser loads (SW interception) use the same cache keys:
```typescript
// SW plugin for static assets handler
cacheKeyWillBeUsed: async ({ request, mode }) => {
const storagePath = extractStoragePathFromUrl(request.url);
if (storagePath) {
return new Request(storagePath);
}
return request;
}
```
**Why this matters:**
| Before | After |
|--------|-------|
| Preload stores: `assets/project/file.jpg` | Same |
| Browser load stores: `https://s3.../file.jpg?X-Amz-Signature=abc` | `assets/project/file.jpg` |
| New session with new presigned URL → cache MISS | cache HIT (same key) |
**Range Request Support for Videos:**
- Handles byte-range requests for video seeking
- Extracts range from cached response
- Returns partial content (HTTP 206)
### Asset Discovery (Unified Frontend)
Located at `frontend/src/lib/assetCache/`, provides unified asset discovery for both online preload and offline download:
```typescript
// Discover all assets for a project (used by offline mode)
discoverProjectAssets(pages, pageLinks, elements): AssetToCache[]
// Get prioritized assets for preloading (used by online preload)
getPrioritizedAssets(currentPageId, pages, pageLinks, elements, neighbors): AssetToCache[]
```
**Asset Discovery Sources:**
- Page backgrounds: `background_image_url`, `background_video_url`, `background_audio_url`
- Element content_json: All URL fields from `PRELOAD_CONFIG.assetFields.all`
- Transition videos: `transition.video_url` from page links
- Nested arrays: `galleryCards[]`, `carouselSlides[]`
**AssetToCache Structure:**
```typescript
interface AssetToCache {
storageKey: string; // Canonical key for caching
originalUrl: string; // Original URL from data
assetType: AssetType; // 'image' | 'video' | 'audio' | 'transition' | 'other'
pageId: string; // Page the asset belongs to
priority: number; // Download priority (higher = first)
}
```
### Navigation Data in ui_schema_json
Navigation targets and transition videos are stored directly in `tour_pages.ui_schema_json`:
```typescript
// Element with navigation and transition
{
type: "navigation_next",
targetPageSlug: "gallery", // Slug-based navigation
transitionVideoUrl: "assets/.../transition.mp4", // Inline transition
transitionDurationSec: 1.5,
}
```
The preload system extracts navigation targets from elements to build the neighbor graph, and transition video URLs are used directly for preloading.
### Key Files
| File | Location | Purpose |
|------|----------|---------|
| `StorageManager.ts` | `frontend/src/lib/offline/` | Cache API + IndexedDB abstraction |
| `DownloadManager.ts` | `frontend/src/lib/offline/` | Download queue with auto proxy fallback |
| `OfflineDbManager.ts` | `frontend/src/lib/offlineDb/` | Dexie.js IndexedDB manager |
| `schema.ts` | `frontend/src/lib/offlineDb/` | Dexie.js schema definition |
| `AssetCacheService.ts` | `frontend/src/lib/assetCache/` | Unified asset caching service |
| `assetDiscovery.ts` | `frontend/src/lib/assetCache/` | Shared asset extraction logic |
| `offline.config.ts` | `frontend/src/config/` | Offline settings |
| `sw.ts` | `frontend/src/` | Serwist service worker |
| `useNeighborGraph.ts` | `frontend/src/hooks/` | Builds navigation graph from elements |
---
## Progress Tracking
### Event System
The `DownloadEventBus` singleton emits events consumed by `usePreloadProgress`:
```
DownloadEventBus
├── preloadStart { jobId, assetId, url }
├── preloadProgress { jobId, progress, bytesLoaded, totalBytes }
├── preloadComplete { jobId, assetId }
└── preloadError { jobId, assetId, error }
usePreloadProgress Hook
├── Maintains jobs[] array
├── Calculates totalProgress
├── Auto-removes completed (3s)
└── Auto-removes errors (10s)
UI Components
├── OfflineToggle
├── OfflineStatusIndicator
└── DownloadProgressPanel
```
---
## Configuration Reference
### preload.config.ts
```typescript
export const PRELOAD_CONFIG = {
// Queue settings
maxConcurrentDownloads: 3,
maxRetries: 3,
retryDelayMs: 1000,
// Size thresholds
largeFileThreshold: 5 * 1024 * 1024, // 5MB -> use IndexedDB
videoChunkSize: 5 * 1024 * 1024, // 5MB chunks
initialVideoBufferSeconds: 5,
// Priority weights
priority: {
currentPage: 1000,
neighborBase: 500,
assetType: {
transition: 150, // Highest - needed immediately on navigation click
image: 100,
audio: 50,
video: 30,
},
variant: {
thumbnail: 50,
preview: 40,
webp: 35,
mp4_low: 20,
mp4_high: 10,
original: 5,
},
linkCountMultiplier: 10,
maxLinkBonus: 50,
},
// Storage thresholds
storage: {
warningPercent: 80,
criticalPercent: 95,
minFreeBuffer: 50 * 1024 * 1024, // 50MB
},
// Auto-cleanup timeouts
autoRemove: {
completedMs: 3000,
errorMs: 10000,
},
// Neighbor graph traversal
neighborGraph: {
maxDepth: 1, // Only preload immediate neighbors (reduced from 2)
constructorMaxDepth: 1, // Same as maxDepth for constructor
},
// Asset URL field names for extraction
assetFields: {
all: [
'iconUrl', 'imageUrl', 'mediaUrl', 'videoUrl', 'audioUrl',
'transitionVideoUrl', 'backgroundImageUrl', 'reverseVideoUrl',
'carouselPrevIconUrl', 'carouselNextIconUrl', 'src', 'url',
'poster', 'thumbnail'
],
images: [
'iconUrl', 'imageUrl', 'backgroundImageUrl',
'carouselPrevIconUrl', 'carouselNextIconUrl', 'src'
],
nested: ['galleryCards', 'carouselSlides'],
nestedUrlFields: ['imageUrl', 'videoUrl'],
},
};
```
### offline.config.ts
```typescript
export const OFFLINE_CONFIG = {
// IndexedDB
dbName: 'TourBuilderOffline',
dbVersion: 1,
// Cache names (for Cache API)
cacheNames: {
static: 'tour-builder-static-v1',
dynamic: 'tour-builder-dynamic-v1',
assets: 'tour-builder-assets-v1',
},
// Events (EventEmitter event names)
events: {
preloadStart: 'asset-preload-start',
preloadProgress: 'asset-preload-progress',
preloadComplete: 'asset-preload-complete',
preloadError: 'asset-preload-error',
projectDownloadProgress: 'project-download-progress',
projectDownloadComplete: 'project-download-complete',
queueUpdate: 'queue-update',
},
// Service worker settings
serviceWorker: {
scope: '/',
updateInterval: 60 * 60 * 1000, // 1 hour
},
// Storage settings
storage: {
cacheApiMaxSize: 5 * 1024 * 1024, // 5MB
indexedDbMinSize: 5 * 1024 * 1024, // 5MB
},
// Retry settings
retry: {
maxRetries: 3,
backoffMs: 1000,
maxBackoffMs: 30000,
},
};
```
---
## Integration Examples
### Runtime Mode (Tour Playback)
```typescript
// frontend/src/pages/runtime.tsx
const preloadOrchestrator = usePreloadOrchestrator({
pages,
pageLinks, // Includes transition.video_url from API
elements,
currentPageId: selectedPageId,
pageHistory,
enabled: !isLoading && !error,
});
// Pass getCachedBlobUrl to reverse playback hook
const { startReverse, stopReverse } = useReversePlayback({
videoRef: overlayVideoRef,
onComplete: finishOverlayTransition,
preloadedUrls: preloadOrchestrator.preloadedUrls,
videoUrl: overlayTransition?.videoUrl,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
});
```
### RuntimePresentation Mode (Embedded/Standalone)
```typescript
// frontend/src/components/RuntimePresentation.tsx
// 1. Filter pages by environment (dev, stage, or production)
const filteredPages = pages.filter(p => p.environment === environment);
// 2. Extract navigation targets and preload elements from ui_schema_json
const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages);
// 3. Initialize preload orchestrator
const preloadOrchestrator = usePreloadOrchestrator({
pages: filteredPages,
pageLinks, // Navigation links with transition videos
elements: preloadElements, // All preloadable elements
currentPageId: selectedPageId,
pageHistory,
enabled: !isLoading && !error,
});
// 4. Initialize page switch with preload cache
const pageSwitch = usePageSwitch({
preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, // O(1) instant lookup
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
});
// 5. Navigate using preloaded assets for instant display
const handleNavigation = (targetPage, link) => {
pageSwitch.switchToPage(targetPage, link); // Uses ready blob URLs
};
// OfflineToggle for user-initiated downloads
<OfflineToggle
projectId={project?.id || null}
projectSlug={projectSlug}
projectName={project?.name}
pages={filteredPages} // Required: pages for asset discovery
showLabel={false}
size='small'
/>
```
**Environment Filtering:**
Pages are filtered by environment (`dev`, `stage`, `production`) before preloading. This ensures:
- Constructor always preloads `dev` environment pages
- Stage preview preloads `stage` environment pages
- Production runtime preloads `production` environment pages
### Constructor Mode (Editing)
```typescript
// frontend/src/pages/constructor.tsx
// Constructor always works with 'dev' environment pages
const { pageLinks, preloadElements } = extractPageLinksAndElements(pages);
const preloadOrchestrator = usePreloadOrchestrator({
pages,
pageLinks,
elements: preloadElements,
currentPageId: activePageId,
enabled: !isLoading && !!activePageId,
// maxNeighborDepth defaults to 1 (same as runtime)
});
// Page switch for preview navigation
const pageSwitch = usePageSwitch({
preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
});
```
### Offline Mode Hook
```typescript
// frontend/src/components/Offline/OfflineToggle.tsx
const {
isOfflineCapable,
isDownloaded,
isDownloading,
status,
progress,
startDownload,
pauseDownload,
resumeDownload,
cancelDownload,
deleteOfflineData,
estimatedSize,
formatSize,
} = useOfflineMode({
projectId,
projectSlug,
projectName,
pages, // Required: pages data for frontend asset discovery
});
```
### Video Reverse Playback
```typescript
// frontend/src/hooks/useReversePlayback.ts
// Tries native playbackRate = -1 first (Chrome 141+, Safari 16+)
// Falls back to frame-stepping with cached blob URL for better seeking
const cachedUrl = await getCachedBlobUrl(videoUrl);
if (cachedUrl) {
video.src = cachedUrl; // Better seeking with local blob
video.load();
}
```
---
## Complete Data Flow Example
**Scenario: User navigates from Page A to Page B**
```
1. Page Change Detection
└── currentPageId: "pageA" → "pageB"
2. Neighbor Discovery (BFS via useNeighborGraph, depth=1)
└── pageB neighbors: [pageC (dist=1)] // Only immediate neighbors
3. Asset Extraction (via useNeighborGraph.getAssetsForPages)
├── pageB backgrounds (from page object)
│ ├── background_image_url → [bg.jpg]
│ ├── background_video_url → [bg-video.mp4] (if exists)
│ └── background_audio_url → [bg-audio.mp3] (if exists)
├── pageB elements → [img1.webp, video1.mp4]
├── pageC backgrounds → [pageC-bg.jpg]
├── pageC elements → [img2.webp]
└── pageLink B→C transition.video_url → [trans.mp4]
4. Priority Assignment
├── img1.webp → 1000 + 100 + 35 = 1135
├── bg.jpg → 1000 + 100 + 5 = 1105
├── video1.mp4 → 1000 + 30 + 20 = 1050
├── trans.mp4 → 500 + 150 + 20 = 670 // Transition has highest type bonus
└── img2.webp → 500 + 100 + 35 = 635
5. Network Check (useNetworkAware)
└── 4g detected → concurrency = 3
6. Download Queue (sorted by priority)
[img1.webp, bg.jpg, video1.mp4, trans.mp4, img2.webp]
7. Parallel Download (3 concurrent)
├── Download blob via presigned URL
├── Store in Cache API
├── Create blob URL
├── Decode (images only)
└── Store in readyBlobUrlsRef Map
8. Progress Events
└── DownloadEventBus → usePreloadProgress → UI
9. Navigation Click (instant)
└── getReadyBlobUrl(url) → O(1) Map lookup → Pre-decoded blob URL
└── Set as background → No delay, no flash
```
---
## Storage Quota Management
The system monitors storage usage via `navigator.storage.estimate()`:
```typescript
const { usage, quota } = await navigator.storage.estimate();
const percentUsed = (usage / quota) * 100;
const available = quota - usage - PRELOAD_CONFIG.storage.minFreeBuffer;
if (percentUsed > 95) {
// Critical: stop preloading
} else if (percentUsed > 80) {
// Warning: reduce preload depth
}
```
The `useStorageQuota` hook provides reactive storage monitoring:
```typescript
const { canStore, isWarning, isCritical } = useStorageQuota();
// Used in OfflineToggle to warn users before download
if (isCritical) {
alert('Storage space is critically low.');
return;
}
```
---
## Troubleshooting
### Assets Not Preloading
1. Check network status in DevTools
2. Verify `enabled` prop is `true`
3. Check Console for preload errors (look for `[PRELOAD]` prefix)
4. Verify Cache API storage quota
5. Check that `pageLinks` include `transition.video_url`
### Offline Mode Not Working
1. Ensure Service Worker is registered
2. Check IndexedDB for stored assets (`TourBuilderOffline` database)
3. Verify `pages` prop is passed to `OfflineToggle` / `useOfflineMode`
4. Check for CORS issues on asset URLs
5. Verify `OfflineToggle` component is rendered
### Video Seeking Issues Offline
1. Verify video is in cache with correct headers
2. Check Service Worker handles range requests (HTTP 206)
3. Verify `getCachedBlobUrl` returns blob URL
4. Try clearing cache and re-downloading
### Reverse Playback Not Working
1. Check if `getCachedBlobUrl` is passed to `useReversePlayback`
2. Verify transition video URL is preloaded
3. Check Console for `[REVERSE]` logs
4. Verify `preloadedUrls` Set contains the video URL
---
## Performance Considerations
1. **Storage Key Mapping**: Assets mapped by canonical storage key (e.g., `assets/vid.mp4`) enabling cache hits regardless of presigned URL changes
2. **Ready Blob URLs**: Use `getReadyBlobUrl(storageKey)` for O(1) instant lookup of pre-decoded blob URLs
3. **Post-Refresh Cache**: Assets stored in Cache API under storage key survive page refresh
4. **Image Decoding**: Blob URLs are pre-decoded to prevent white flash on navigation
5. **Neighbor Depth**: `maxNeighborDepth=1` for both editing and playback (reduced to prevent too many requests)
6. **Transition Priority**: Transitions have highest priority (150) - needed immediately on navigation click
7. **Large Videos**: Stored in IndexedDB (≥5MB) for reliable playback
8. **Network Adaptation**: Reduces concurrency on slow connections
9. **Memory Management**: Auto-cleans completed jobs from progress tracking; blob URLs revoked on unmount
10. **Environment Filtering**: Pages filtered by environment before preloading to avoid loading wrong content
11. **Service Worker Cache**: Same cache name (`tour-builder-assets-v1`) used by preloading and SW
12. **Blob URL Rendering**: Use native `<img>` for blob URLs instead of Next.js Image to prevent re-fetching
---
## Blob URL Rendering Strategy
When displaying preloaded blob URLs, use native `<img>` tags instead of Next.js `<Image>`:
```tsx
// Background image with conditional rendering
{backgroundImageUrl.startsWith('blob:') ? (
<img
src={backgroundImageUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover"
onLoad={() => markBackgroundReady()}
/>
) : (
<NextImage
src={backgroundImageUrl}
fill
sizes="100vw"
className="object-cover"
onLoad={() => markBackgroundReady()}
/>
)}
```
**Why this matters:**
- Next.js `<Image>` re-fetches `src` on every component re-render, even with `unoptimized`
- Pages with frequent state updates (e.g., 100ms animation timers) cause thousands of requests
- Blob URLs are already in-memory and don't benefit from Next.js optimization
- Native `<img>` with blob URLs is cached by browser and doesn't re-fetch
**Where to apply:**
- Background images in RuntimePresentation and Constructor
- Element icons (tooltip, description, gallery, carousel)
- Any image element receiving blob URLs from `getReadyBlobUrl()`

View File

@ -0,0 +1,499 @@
# Audio Playback Feature - E2E Documentation
## Overview
The Tour Builder Platform implements a comprehensive audio playback system supporting background audio on pages, element-triggered audio effects (hover/click sounds), and media player elements. Key features:
- **User Interaction Unlock**: All audio waits for first user click/tap before playing (consistent across all browsers)
- **Sound Effects Layering**: Hover/click effects play over background audio (no interruption)
- **Media Player Ducking**: AudioPlayer and VideoPlayer elements pause background audio when playing
- **Constructor Modes**: Edit mode silences audio; Interact mode enables full audio playback
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Audio Types │
│ Background Audio │ Element Audio Effects │
│ (page-level) │ (hover/click sounds) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Playback Hooks │
│ useBackgroundAudioPlayback │ useAudioEffects │ useAudioEventMgr│
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Controller Layer │
│ backgroundAudioController (singleton for audio ducking) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Rendering │
│ CanvasBackground │ UiElements (hover/click audio) │
└─────────────────────────────────────────────────────────────────┘
```
---
## Audio Types
### 1. Background Audio
Page-level ambient audio that plays behind all content with configurable playback settings.
```tsx
// Using useBackgroundAudioPlayback hook for controlled playback
const { audioRef } = useBackgroundAudioPlayback({
audioUrl: page.background_audio_url,
autoplay: page.background_audio_autoplay ?? true,
loop: page.background_audio_loop ?? true,
startTime: page.background_audio_start_time ?? null,
endTime: page.background_audio_end_time ?? null,
});
<audio ref={audioRef} src={audioUrl} preload="auto" hidden />
```
**Configurable Properties (stored in `tour_pages`):**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `background_audio_autoplay` | boolean | true | Start playback automatically |
| `background_audio_loop` | boolean | true | Loop continuously |
| `background_audio_start_time` | DECIMAL(10,1) \| null | null | Start playback at this time (seconds) |
| `background_audio_end_time` | DECIMAL(10,1) \| null | null | Stop/loop at this time (seconds) |
**DECIMAL Parsing (Critical):** Sequelize DECIMAL fields return strings from the database (e.g., `"2.5"` not `2.5`). In runtime code, these must be parsed with `parseFloat(String(value))` before passing to the hook.
**Note:** When `background_audio_end_time` is set, looping is handled via JavaScript (`timeupdate` event) to properly seek back to `startTime`.
### 2. Element Audio Effects
Interactive audio triggered by user hover and click on UI elements.
```tsx
// Using useAudioEffects hook
const { playClickAudio, stopAll } = useAudioEffects({
hoverAudioUrl: element.hoverAudioUrl,
clickAudioUrl: element.clickAudioUrl,
volume: element.audioVolume ?? 1,
isHovered,
isActive,
resolveUrl: preloadOrchestrator.getReadyBlobUrl,
});
```
**Configurable Properties (stored in element's `ui_schema_json`):**
| Property | Type | Description |
|----------|------|-------------|
| `hoverAudioUrl` | string | Audio to play on mouse hover |
| `clickAudioUrl` | string | Audio to play on click/tap |
| `audioVolume` | number (0-1) | Volume level (iOS ignores this) |
---
## User Interaction Unlock
All audio waits for first user interaction (click/tap) before playing. This ensures consistent behavior across all browsers (Safari, Chrome, Firefox).
### backgroundAudioController
Module-level singleton that manages audio unlock and ducking:
```typescript
// frontend/src/lib/backgroundAudioController.ts
class BackgroundAudioController {
private audioElement: HTMLAudioElement | null = null;
private waitingForInteraction = false;
private hasUserInteracted = false;
// Register background audio element
register(audio: HTMLAudioElement | null): void;
// Mark audio as waiting for interaction
setWaitingForInteraction(waiting: boolean): void;
// Called on first user click/tap - unlocks all audio
notifyUserInteraction(): void;
// Check if audio is unlocked
hasInteracted(): boolean;
// Ducking methods for media players
notifyForegroundStart(): void;
notifyForegroundEnd(): void;
}
```
### Unlock Flow
```
┌──────────────────────────────────────────────────────────────┐
│ 1. Page loads │
│ └── backgroundAudioController.setWaitingForInteraction() │
│ └── All audio is silent │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 2. User clicks/taps anywhere on canvas │
│ └── handleCanvasInteraction() triggered │
│ └── backgroundAudioController.notifyUserInteraction() │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 3. Audio unlocked │
│ └── Background audio starts playing │
│ └── Hover/click effects now work │
│ └── hasInteracted() returns true │
└──────────────────────────────────────────────────────────────┘
```
---
## Audio Layering & Ducking
Different audio types have different behaviors:
| Audio Type | Background Audio Behavior |
|------------|---------------------------|
| **Hover/click effects** | Layers over (plays simultaneously) |
| **AudioPlayer element** | Pauses background (ducking) |
| **VideoPlayer element** | Pauses background (ducking) |
### Sound Effects (No Ducking)
Hover and click effects play over background audio without interruption:
```typescript
// useAudioEffects.ts - no ducking calls
if (backgroundAudioController.hasInteracted()) {
audio.play().catch(() => undefined);
}
```
### Media Players (With Ducking)
AudioPlayer and VideoPlayer elements pause background audio:
```typescript
// AudioPlayerElement.tsx / VideoPlayerElement.tsx
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handlePlay = () => backgroundAudioController.notifyForegroundStart();
const handlePause = () => backgroundAudioController.notifyForegroundEnd();
const handleEnded = () => backgroundAudioController.notifyForegroundEnd();
audio.addEventListener('play', handlePlay);
audio.addEventListener('pause', handlePause);
audio.addEventListener('ended', handleEnded);
return () => { /* cleanup */ };
}, []);
```
### Reference Counting
The controller uses reference counting for multiple media players:
```typescript
notifyForegroundStart(); // count = 1, background pauses
notifyForegroundStart(); // count = 2, still paused
notifyForegroundEnd(); // count = 1, still paused
notifyForegroundEnd(); // count = 0, background resumes
```
---
## Browser Autoplay Policy
All major browsers restrict audio autoplay. Our implementation handles this uniformly.
### Browser Policies
| Browser | Policy |
|---------|--------|
| **Safari** | Strictest - blocks ALL audio until user interaction |
| **Chrome** | Blocks until user interaction or high Media Engagement Index |
| **Firefox** | Blocks by default, user-configurable |
### Our Strategy: Always Wait for Interaction
Instead of trying autoplay and handling failures, we consistently wait for user interaction:
```typescript
// useBackgroundAudioPlayback.ts
if (!shouldBlockAutoplay) {
// Always wait for user interaction before playing
backgroundAudioController.setWaitingForInteraction(true);
}
// RuntimePresentation.tsx & constructor.tsx
const handleCanvasInteraction = useCallback(() => {
backgroundAudioController.notifyUserInteraction();
}, []);
<div onClick={handleCanvasInteraction} onTouchEnd={handleCanvasInteraction}>
```
**Why `onTouchEnd` not `onTouchStart`?** iOS Safari only unlocks audio when finger is LIFTED from screen.
### Session-Scoped Play Once
When `loop=false`, audio only plays once per browser session:
```typescript
// Module-level Set (cleared on browser refresh)
const playedAudios = new Set<string>();
// In useBackgroundAudioPlayback
if (!loop && playedAudios.has(audioStoragePath)) {
// Skip autoplay - already played this session
return { shouldBlockAutoplay: true };
}
// After audio plays
audioElement.onended = () => {
playedAudios.add(audioStoragePath);
};
```
---
## Constructor Mode
### Edit vs Interact Mode
| Mode | Audio Behavior |
|------|---------------|
| **Edit Mode** | All audio silenced (`pauseAudio={true}`) |
| **Interact Mode** | Full audio playback (after user interaction) |
The constructor passes `pauseAudio={isConstructorEditMode}` to CanvasBackground and only triggers `handleCanvasInteraction` when NOT in edit mode.
### Audio Settings Editor
The Constructor provides UI controls for background audio settings via `BackgroundSettingsEditor`:
```tsx
// frontend/src/components/Constructor/BackgroundSettingsEditor.tsx
// When type='audio', shows playback settings
<BackgroundSettingsEditor
type="audio"
value={backgroundAudioUrl}
options={audioAssetOptions}
onChange={setBackgroundAudioUrl}
audioAutoplay={pageBackground.audioSettings.autoplay}
audioLoop={pageBackground.audioSettings.loop}
audioStartTime={pageBackground.audioSettings.startTime}
audioEndTime={pageBackground.audioSettings.endTime}
onAudioSettingsChange={setBackgroundAudioSettings}
/>
```
### Settings UI
| Setting | Control | Description |
|---------|---------|-------------|
| Autoplay | Checkbox | Start audio automatically on page load |
| Loop | Checkbox | Continuously loop audio |
| Start Time | Number input | Begin playback at specific time (seconds) |
| End Time | Number input | Stop/loop at specific time (seconds) |
---
## Runtime Mode (Presentations)
### RuntimePresentation Component
Passes audio settings to CanvasBackground for playback:
```tsx
// frontend/src/components/RuntimePresentation.tsx
// Extract audio URL from navigation state
const backgroundAudioUrl = navCurrentBgAudioUrl;
// Extract settings from selected page
const audioAutoplay = selectedPage?.background_audio_autoplay ?? true;
const audioLoop = selectedPage?.background_audio_loop ?? true;
const audioStartTime = selectedPage?.background_audio_start_time != null
? parseFloat(String(selectedPage.background_audio_start_time))
: null;
const audioEndTime = selectedPage?.background_audio_end_time != null
? parseFloat(String(selectedPage.background_audio_end_time))
: null;
// Pass to CanvasBackground
<CanvasBackground
backgroundAudioUrl={backgroundAudioUrl}
audioAutoplay={audioAutoplay}
audioLoop={audioLoop}
audioStartTime={audioStartTime}
audioEndTime={audioEndTime}
audioStoragePath={selectedPage?.background_audio_url}
/>
```
### CanvasBackground Audio Integration
```tsx
// frontend/src/components/Constructor/CanvasBackground.tsx
const { audioRef } = useBackgroundAudioPlayback({
audioUrl: backgroundAudioUrl,
audioStoragePath,
autoplay: audioAutoplay,
loop: audioLoop,
startTime: audioStartTime,
endTime: audioEndTime,
});
// Register for audio ducking
useEffect(() => {
backgroundAudioController.register(audioRef.current);
return () => backgroundAudioController.register(null);
}, [audioRef.current]);
// Render hidden audio element
{backgroundAudioUrl && (
<audio ref={audioRef} src={backgroundAudioUrl} preload="auto" hidden />
)}
```
---
## Hooks Reference
### useBackgroundAudioPlayback
Manages background audio playback with start/end time control and ducking integration.
```typescript
interface UseBackgroundAudioPlaybackOptions {
audioUrl?: string; // Audio URL (may be blob URL)
audioStoragePath?: string; // Original storage path for play-once tracking
autoplay?: boolean; // Default: true
loop?: boolean; // Default: true
startTime?: number | null; // Start at time (seconds)
endTime?: number | null; // Stop/loop at time (seconds)
paused?: boolean; // External pause control
}
interface UseBackgroundAudioPlaybackResult {
audioRef: RefObject<HTMLAudioElement | null>;
shouldBlockAutoplay: boolean; // True if already played this session
}
```
**Location:** `frontend/src/hooks/useBackgroundAudioPlayback.ts` (~220 LOC)
### useAudioEventManager
Declarative event listener management for audio elements.
```typescript
interface UseAudioEventManagerOptions {
audioRef: RefObject<HTMLAudioElement | null>;
enabled?: boolean;
handlers: {
onLoadedMetadata?: (event: Event) => void;
onTimeUpdate?: (event: Event) => void;
onEnded?: (event: Event) => void;
onError?: (event: Event) => void;
// ... other audio events
};
}
```
**Location:** `frontend/src/hooks/audio/useAudioEventManager.ts` (~115 LOC)
### useAudioEffects
Manages element hover/click audio (layers over background audio without ducking).
```typescript
interface UseAudioEffectsOptions {
hoverAudioUrl?: string;
clickAudioUrl?: string;
volume?: number; // 0-1 (iOS ignores)
isHovered: boolean;
isActive: boolean;
resolveUrl?: (url: string) => string;
resetKey?: string; // Reset on page navigation
}
interface UseAudioEffectsResult {
playClickAudio: () => void;
stopAll: () => void;
}
```
**Location:** `frontend/src/hooks/useAudioEffects.ts` (~302 LOC)
---
## Database Schema
Audio settings are stored in the `tour_pages` table:
```sql
-- Added via migration 20260605000001-add-background-audio-settings.js
ALTER TABLE tour_pages
ADD COLUMN background_audio_autoplay BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN background_audio_loop BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN background_audio_start_time DECIMAL(10, 1),
ADD COLUMN background_audio_end_time DECIMAL(10, 1);
```
---
## Key Files Reference
| File | Location | LOC | Purpose |
|------|----------|-----|---------|
| `useBackgroundAudioPlayback.ts` | `frontend/src/hooks/` | 220 | Background audio playback control |
| `useAudioEventManager.ts` | `frontend/src/hooks/audio/` | 115 | Audio event listener management |
| `useAudioEffects.ts` | `frontend/src/hooks/` | 302 | Element hover/click audio (no ducking) |
| `backgroundAudioController.ts` | `frontend/src/lib/` | 81 | Audio ducking coordination |
| `BackgroundSettingsEditor.tsx` | `frontend/src/components/Constructor/` | - | Audio settings UI |
| `CanvasBackground.tsx` | `frontend/src/components/Constructor/` | - | Audio element rendering |
| `RuntimePresentation.tsx` | `frontend/src/components/` | - | Runtime audio integration |
| `constructor.tsx` | `frontend/src/pages/` | - | Constructor audio integration |
| `AudioPlayerElement.tsx` | `frontend/src/components/UiElements/elements/` | 77 | Audio player with ducking |
| `VideoPlayerElement.tsx` | `frontend/src/components/UiElements/elements/` | 91 | Video player with ducking |
---
## Troubleshooting
### Audio Won't Play
1. Verify user has interacted (click/tap) - check `backgroundAudioController.hasInteracted()`
2. In constructor, verify you're in Interact mode (not Edit mode)
3. Verify audio URL resolves correctly
4. Check CORS headers on audio source
5. Ensure audio format is supported (MP3/OGG/WAV)
### Audio Ducking Not Working
1. Verify `backgroundAudioController.register()` is called in CanvasBackground
2. Check `notifyForegroundStart/End` calls in AudioPlayerElement and VideoPlayerElement
3. Ensure foreground audio count is balanced (start/end pairs)
### Audio Doesn't Loop Correctly
1. Check if `endTime` is set (uses JS loop instead of native)
2. Verify `startTime` is valid for seeking
3. Ensure `timeupdate` event handler is attached
### Memory Leaks
1. Verify blob URLs are revoked on cleanup
2. Check `backgroundAudioController.register(null)` on unmount
3. Ensure event listeners are removed via useAudioEventManager cleanup

View File

@ -0,0 +1,764 @@
# Authentication System
Complete documentation for the Tour Builder Platform authentication system including local auth, OAuth, JWT tokens, and security features.
## Overview
The platform uses a multi-strategy authentication system built on:
- **Passport.js** - Authentication middleware with JWT, Google OAuth, and Microsoft OAuth strategies
- **JWT Tokens** - 6-hour expiration, Bearer token in Authorization header
- **bcrypt** - Password hashing with 12 salt rounds
- **Rate Limiting** - In-memory rate limiting for auth endpoints
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ Login Page │ │ authSlice.ts │ │ Axios Interceptors │ │
│ │ Register │ │ (Redux) │ │ - Token injection │ │
│ │ Forgot │ │ │ │ - 401 handling │ │
│ └─────────────┘ └──────────────┘ └────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ auth.js │ │ AuthService │ │ Passport.js Strategies │ │
│ │ (routes) │ │ (business) │ │ - JWT │ │
│ │ │ │ │ │ - Google OAuth │ │
│ │ │ │ │ │ - Microsoft OAuth │ │
│ └─────────────┘ └──────────────┘ └────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ UsersDBApi ││
│ │ - Token generation (email verification, password reset) ││
│ │ - User CRUD operations ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
```
## Passport.js Strategies
### JWT Strategy
**File:** `backend/src/auth/auth.ts`
```javascript
passport.use(new JWTstrategy({
passReqToCallback: true,
secretOrKey: config.secret_key,
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken()
}, async (req, token, done) => {
// Validates token and checks if user is disabled
// Sets req.currentUser for authenticated requests
}));
```
| Property | Value |
|----------|-------|
| Token Location | `Authorization: Bearer {token}` header |
| Secret Key | `process.env.SECRET_KEY` |
| Token Expiration | 6 hours |
| Validation | Checks user exists and is not disabled |
### Google OAuth Strategy
```javascript
passport.use(new GoogleStrategy({
clientID: config.google.clientId,
clientSecret: config.google.clientSecret,
callbackURL: config.apiUrl + '/auth/signin/google/callback',
passReqToCallback: true
}));
```
| Property | Value |
|----------|-------|
| Scopes | `profile`, `email` (defined in routes when calling `passport.authenticate()`) |
| Callback URL | `/api/auth/signin/google/callback` |
| Auto-Verification | Social auth users are automatically `emailVerified: true` |
### Microsoft OAuth Strategy
```javascript
passport.use(new MicrosoftStrategy({
clientID: config.microsoft.clientId,
clientSecret: config.microsoft.clientSecret,
callbackURL: config.apiUrl + '/auth/signin/microsoft/callback',
passReqToCallback: true
}));
```
| Property | Value |
|----------|-------|
| Scopes | `https://graph.microsoft.com/user.read openid` (defined in routes when calling `passport.authenticate()`) |
| Callback URL | `/api/auth/signin/microsoft/callback` |
| Email Source | `profile._json.mail` or `profile._json.userPrincipalName` |
## API Endpoints
### Authentication Routes
| Method | Endpoint | Auth | Rate Limited | Description |
|--------|----------|------|--------------|-------------|
| POST | `/auth/signin/local` | No | Yes (10/15m) | Login with email/password |
| GET | `/auth/me` | JWT | No | Get current user info |
| PUT | `/auth/password-reset` | No | No | Reset password with token |
| PUT | `/auth/password-update` | JWT | No | Change password |
| POST | `/auth/send-password-reset-email` | No | Yes (5/1h) | Request reset email |
| POST | `/auth/send-email-address-verification-email` | JWT | No | Resend verification |
| PUT | `/auth/verify-email` | No | No | Verify email with token |
| PUT | `/auth/profile` | JWT | No | Update user profile |
| GET | `/auth/email-configured` | No | No | Check email config |
| GET | `/auth/signin/google` | No | No | Initiate Google OAuth |
| GET | `/auth/signin/google/callback` | No | No | Google OAuth callback |
| GET | `/auth/signin/microsoft` | No | No | Initiate Microsoft OAuth |
| GET | `/auth/signin/microsoft/callback` | No | No | Microsoft OAuth callback |
### File Endpoints (Public)
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/file/download` | No | Download file (backend proxy for local/GCloud) |
| POST | `/file/presign` | No | Generate presigned URLs for S3 direct downloads |
| POST | `/file/upload/:table/:field` | JWT | Legacy single-file upload |
| POST | `/file/upload-sessions/init` | JWT | Initialize chunked upload |
| PUT | `/file/upload-sessions/:id/chunks/:idx` | JWT | Upload chunk |
| GET | `/file/upload-sessions/:id` | JWT | Check upload session status |
| POST | `/file/upload-sessions/:id/finalize` | JWT | Finalize chunked upload |
**Note:** The download and presign endpoints are intentionally public to support runtime asset preloading without authentication. Access control for assets should be implemented at the storage level (S3 bucket policies) if needed.
### Endpoint Details
#### POST /auth/signin/local
**Request:**
```json
{
"email": "user@example.com",
"password": "password123"
}
```
**Response (Success 200):** JWT token string
**Error Codes (400):**
- `auth.userNotFound` - User doesn't exist
- `auth.userDisabled` - Account is disabled
- `auth.wrongPassword` - Incorrect password
- `auth.userNotVerified` - Email not verified
#### Self-Registration
Self-registration is disabled. The application does not expose
`POST /auth/signup` and the frontend does not provide a `/register` page. New
users are created by authorized staff through the Users flow and receive an
invitation/setup link.
#### GET /auth/me
**Headers:** `Authorization: Bearer {token}`
**Response (Success 200):**
```json
{
"id": "uuid",
"email": "user@example.com",
"firstName": "John",
"lastName": "Doe",
"emailVerified": true,
"disabled": false,
"provider": "local",
"app_role": { "id": "...", "name": "User" },
"custom_permissions": []
}
```
#### PUT /auth/verify-email
**Request:**
```json
{
"token": "email_verification_token_40_chars"
}
```
**Response (Success 200):** `true`
**Error Codes (400):**
- `auth.emailAddressVerificationEmail.invalidToken` - Invalid or expired token
#### PUT /auth/password-reset
**Request:**
```json
{
"token": "password_reset_token_40_chars",
"password": "newPassword123"
}
```
**Response (Success 200):** User object
**Error Codes (400):**
- `auth.passwordReset.invalidToken` - Invalid or expired token
#### PUT /auth/password-update
**Headers:** `Authorization: Bearer {token}`
**Request:**
```json
{
"currentPassword": "oldPassword123",
"newPassword": "newPassword123"
}
```
**Error Codes (400):**
- `auth.wrongPassword` - Current password incorrect
- `auth.passwordUpdate.samePassword` - New password same as old
## Rate Limiting
**File:** `backend/src/middlewares/rateLimiter.js`
| Endpoint | Limiter | Max Requests | Window | Key |
|----------|---------|--------------|--------|-----|
| `/auth/signin/local` | `authLimiter` | 10 | 15 minutes | IP address |
| `/auth/send-password-reset-email` | `passwordResetLimiter` | 5 | 1 hour | IP address |
**Implementation:**
```javascript
// backend/src/routes/auth.ts imports from centralized rate limiter
const {
authLimiter: signinLimiter,
passwordResetLimiter,
} = require('../middlewares/rateLimiter');
// Rate limiters are created with createRateLimiter factory
const authLimiter = createRateLimiter({
keyPrefix: 'auth',
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many authentication attempts. Please try again later.',
skipFailedRequests: false, // Count failed attempts
});
```
- Uses centralized in-memory Map: `rateLimitStore`
- Key format: `${keyPrefix}:${req.ip}`
- Adds standard rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`)
- Returns 429 status with JSON response when exceeded
- Automatic cleanup of expired entries every 5 minutes
- Skips rate limiting in development for localhost
## User Model
**File:** `backend/src/db/models/users.js`
| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| id | UUID | Yes | UUIDV4 | Primary key |
| email | TEXT | Yes | - | Unique, validated as email |
| password | TEXT | Yes | - | bcrypt hashed |
| firstName | TEXT | No | null | Trimmed on save |
| lastName | TEXT | No | null | Trimmed on save |
| phoneNumber | TEXT | No | null | |
| disabled | BOOLEAN | No | false | Account status |
| emailVerified | BOOLEAN | No | false | Verification status |
| emailVerificationToken | TEXT | No | null | 40-char hex |
| emailVerificationTokenExpiresAt | DATE | No | null | 24h expiry |
| passwordResetToken | TEXT | No | null | 40-char hex |
| passwordResetTokenExpiresAt | DATE | No | null | 24h expiry |
| provider | TEXT | No | 'local' | local/google/microsoft |
| app_roleId | UUID | No | null | FK to roles |
| importHash | STRING(255) | No | null | Unique, for CSV deduplication |
**Note:** The `authenticationUid` field is set dynamically during `createFromAuth()` and `updatePassword()` operations (set to user ID after creation).
### Model Hooks
```javascript
users.beforeCreate((users) => {
// Trim email, firstName, lastName
if (users.provider !== 'local') {
users.emailVerified = true; // Auto-verify social auth
if (!users.password) {
// Generate random password for OAuth users
users.password = bcrypt.hashSync(randomPassword, 12);
}
}
});
```
## Token Generation
**File:** `backend/src/db/api/users.js`
### Email Verification Token
```javascript
const token = crypto.randomBytes(20).toString('hex'); // 40-char hex
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
```
### Password Reset Token
```javascript
const token = crypto.randomBytes(20).toString('hex'); // 40-char hex
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
```
### JWT Token
**File:** `backend/src/helpers.js`
```javascript
static jwtSign(data) {
return jwt.sign(data, config.secret_key, { expiresIn: '6h' });
}
```
Payload structure:
```json
{
"user": {
"id": "uuid",
"email": "user@example.com"
}
}
```
## Frontend Implementation
### Auth Slice (Redux)
**File:** `frontend/src/stores/authSlice.ts`
```typescript
interface MainState {
isFetching: boolean;
errorMessage: string;
currentUser: any;
token: string;
notify: any; // Contains showNotification, textNotification, typeNotification
}
```
### Async Thunks
**loginUser:**
```typescript
export const loginUser = createAsyncThunk(
'auth/loginUser',
async (creds: Record<string, string>, { rejectWithValue }) => {
const response = await axios.post('auth/signin/local', creds);
return response.data; // JWT token
},
);
```
Expected `creds` object: `{ email: string, password: string }`
**findMe:**
```typescript
export const findMe = createAsyncThunk('auth/findMe', async () => {
const response = await axios.get('auth/me');
return response.data; // Current user
});
```
### Token Storage
On successful login:
```typescript
builder.addCase(loginUser.fulfilled, (state, action) => {
const token = action.payload;
const user = jwt.decode(token);
// Store in both storages
sessionStorage.setItem('token', token);
sessionStorage.setItem('user', JSON.stringify(user));
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
// Set default header
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
});
```
On logout (reducer action in authSlice):
```typescript
logoutUser: (state) => {
sessionStorage.removeItem('token');
sessionStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('user');
axios.defaults.headers.common['Authorization'] = ''; // Set to empty in reducer
state.currentUser = null;
state.token = '';
}
```
### Axios Interceptors
**File:** `frontend/src/pages/_app.tsx`
**Request Interceptor (Token Injection):**
```typescript
axios.interceptors.request.use((config) => {
if (typeof window !== 'undefined') {
const token = sessionStorage.getItem('token') || localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
```
**Response Interceptor (401 Handling + Presigned URL Failure Detection):**
```typescript
axios.interceptors.response.use(
(response) => response,
(error) => {
if (typeof window !== 'undefined') {
const status = error?.response?.status;
const requestUrl = `${error?.config?.url || ''}`;
const isLoginRequest =
requestUrl.includes('/auth/signin/local') ||
requestUrl.includes('auth/signin/local');
// Detect presigned S3 URL failures (CORS not configured)
// Network errors (status 0) or CORS errors typically indicate S3 CORS issues
if (
isPresignedS3Url(requestUrl) &&
(!status || status === 0 || error.message?.includes('Network Error'))
) {
logger.info('[axios] Presigned URL failed, disabling presigned URLs', {
url: requestUrl.slice(0, 80),
});
disablePresignedUrls(); // Falls back to backend proxy
}
if (status === 401 && !isLoginRequest) {
// Clear stored tokens
sessionStorage.removeItem('token');
sessionStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('user');
delete axios.defaults.headers.common['Authorization'];
// Redirect to login if not already there
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
}
}
return Promise.reject(error);
},
);
// Helper function to detect presigned S3 URLs
const isPresignedS3Url = (url: string): boolean => {
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
};
```
**Presigned URL Fallback:** When S3 CORS is not configured, presigned URL requests fail with network errors. The interceptor detects these failures and automatically disables presigned URLs, falling back to the backend proxy for file downloads.
```
## Authentication Flows
### Local Login Flow
```
1. User enters email/password on login.tsx
2. dispatch(loginUser({ email, password }))
3. POST /auth/signin/local
4. Backend validates:
- User exists
- User not disabled
- Email verified (if email configured)
- Password matches (bcrypt.compare)
5. Return JWT token (6h expiration)
6. Frontend stores token in sessionStorage + localStorage
7. Set axios Authorization header
8. dispatch(findMe()) → GET /auth/me
9. Redirect to /dashboard
```
### Invitation-Only Account Flow
```
1. Administrator, Platform Owner, or Account Manager creates a user from Users.
2. Backend creates the user, assigns the selected role, and sends an invitation/setup email when email is configured.
3. User opens the setup link.
4. User sets a password through the password-reset/setup screen.
5. User can log in with email/password.
```
### Password Reset Flow
```
1. User enters email on forgot.tsx
2. POST /auth/send-password-reset-email
3. Backend generates passwordResetToken (40-char hex, 24h expiry)
4. Send email with link: /password-reset?token={token}
5. User clicks link → PasswordSetOrReset component
6. User enters new password
7. PUT /auth/password-reset with token + newPassword
8. Backend validates token, hashes new password, updates user
9. Clear token fields
10. Redirect to /login
```
### OAuth Flow (Google/Microsoft)
```
1. User clicks "Sign in with Google/Microsoft"
2. GET /auth/signin/google (or microsoft)
3. Passport initiates OAuth flow
4. User grants permissions
5. Provider redirects to callback
6. GET /auth/signin/google/callback?code=...
7. Backend exchanges code for access token
8. Retrieve user profile (email, name)
9. findOrCreate user by email
10. Auto-set emailVerified: true
11. Generate JWT token
12. Redirect to /login?token={jwt}
13. Frontend extracts token from URL, stores, redirects to /dashboard
```
### Protected Route Access
```
1. Frontend makes API request
2. Axios interceptor adds: Authorization: Bearer {token}
3. Backend: passport.authenticate('jwt') middleware
4. JWT strategy validates token signature and expiry
5. Decode payload: { user: { id, email } }
6. Query database for user
7. Check user.disabled - reject if true
8. Set req.currentUser = user
9. Proceed to route handler
```
## Security Features
### Password Security
| Feature | Implementation |
|---------|----------------|
| Algorithm | bcrypt |
| Salt Rounds | 12 |
| Storage | Hashed only, never plaintext |
| Comparison | `bcrypt.compare()` (constant-time) |
### Token Security
| Feature | Implementation |
|---------|----------------|
| JWT Algorithm | HS256 |
| Expiration | 6 hours |
| Secret | Environment variable `SECRET_KEY` |
| Transmission | Bearer token in Authorization header |
| Storage | sessionStorage + localStorage |
### Verification Tokens
| Token Type | Length | Format | Expiry |
|------------|--------|--------|--------|
| Email Verification | 40 chars | hex | 24 hours |
| Password Reset | 40 chars | hex | 24 hours |
### User Disabling
- **Field:** `disabled` boolean on users table
- **JWT Validation:** Strategy rejects disabled users
- **Auth Check:** Both signin and signup reject disabled users
- **Use Case:** Admin can disable accounts without deletion
## Protected Routes Pattern
**Backend middleware:**
```javascript
const jwtAuth = passport.authenticate('jwt', { session: false });
// Protected routes
app.use('/api/users', jwtAuth, usersRoutes);
app.use('/api/roles', jwtAuth, rolesRoutes);
app.use('/api/permissions', jwtAuth, permissionsRoutes);
```
**Runtime routes with optional auth (environment-based):**
The frontend sends `X-Runtime-Environment` header to indicate the environment context:
- `production` - Public tour pages (no auth required for GET requests)
- `stage` - Preview environment (requires authentication)
- `dev` - Constructor editing (requires authentication)
```javascript
const requireRuntimeReadOrAuth = (req, res, next) => {
const headerEnvironment = req.runtimeContext?.headerEnvironment;
const headerProjectSlug = req.runtimeContext?.headerProjectSlug;
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
// Only production is public. Stage requires authentication (workspace for review).
const isPublicEnvironment = headerEnvironment === 'production';
if (isPublicEnvironment && isReadOnlyRequest && !isPrivateProductionPresentation(headerProjectSlug)) {
req.isRuntimePublicRequest = true; // Allow public read access
return next();
}
// Private production presentations require JWT + staff permission
// or a production_presentation_access grant.
return jwtAuth(req, res, next);
};
```
Private production presentation visibility and customer grants are stored in the
database separately from broad RBAC permissions. See
[private-production-presentations.md](./private-production-presentations.md).
**Entities with public read access (production environment only):**
- `projects` - Project metadata (filtered fields)
- `tour_pages` - Page content including `ui_schema_json`
- `project_audio_tracks` - Background audio tracks
**File endpoints (no auth required):**
```javascript
// No authentication - used for runtime asset loading
GET /api/file/download?privateUrl={path} // Backend proxy for local/GCloud storage
POST /api/file/presign // Generate presigned URLs for S3 direct download
```
## Configuration
**File:** `backend/src/config.ts`
### Role Configuration
```javascript
roles: {
admin: 'Administrator',
user: 'Analytics Viewer',
}
```
Self-registration is disabled, so `config.roles.user` is not used by a public
signup endpoint.
### Password Configuration
```javascript
bcrypt: {
saltRounds: 12 // bcrypt hashing rounds
}
```
### Seed Credentials
Seeded user credentials are loaded from backend configuration and environment
values. The frontend login page must not display or prefill seeded credentials in
any environment.
## Environment Variables
**Required:**
```bash
# JWT
SECRET_KEY=your_secret_key_here
# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Microsoft OAuth
MS_CLIENT_ID=your_microsoft_client_id
MS_CLIENT_SECRET=your_microsoft_client_secret
# Email (for verification/reset)
EMAIL_USER=your_smtp_username
EMAIL_PASS=your_smtp_password
```
**Frontend:**
```bash
NEXT_PUBLIC_BACK_API=http://localhost:3000/api
```
## Error Handling
### Validation Errors (400)
```javascript
throw new ValidationError('auth.emailAlreadyInUse');
throw new ValidationError('auth.userNotFound');
throw new ValidationError('auth.wrongPassword');
throw new ValidationError('auth.userNotVerified');
```
### Forbidden Errors (403)
```javascript
throw new ForbiddenError(); // JWT invalid/missing
```
### Rate Limit Errors (429)
```javascript
res.status(429).send({
message: 'Too many requests. Please try again later.',
});
```
## File Reference
| File | Purpose |
|------|---------|
| `backend/src/auth/auth.ts` | Passport.js strategy configuration |
| `backend/src/routes/auth.ts` | Authentication route handlers |
| `backend/src/services/auth.ts` | Authentication business logic |
| `backend/src/middlewares/rateLimiter.ts` | Centralized rate limiting middleware |
| `backend/src/db/api/users.js` | User database operations |
| `backend/src/db/models/users.js` | User Sequelize model |
| `backend/src/config.ts` | Auth configuration (bcrypt, secrets) |
| `backend/src/helpers.ts` | JWT signing helper |
| `backend/src/middlewares/runtime-context.ts` | Runtime environment context (X-Runtime-Environment header) |
| `backend/src/middlewares/runtime-public.ts` | Public runtime access filtering and field sanitization |
| `frontend/src/stores/authSlice.ts` | Redux auth state |
| `frontend/src/pages/login.tsx` | Login page |
| `frontend/src/pages/forgot.tsx` | Forgot password page |
| `frontend/src/pages/verify-email.tsx` | Email verification page |
| `frontend/src/components/PasswordSetOrReset.tsx` | Password reset component |
| `frontend/src/pages/_app.tsx` | Axios interceptors, presigned URL failure detection |
| `frontend/src/lib/assetUrl.ts` | Presigned URL management and fallback logic |
## Troubleshooting
### Common Issues
**"auth.userNotVerified" error:**
- Email verification is required when email is configured
- Check user's `emailVerified` field in database
- Resend verification email via `/auth/send-email-address-verification-email`
**401 errors on all requests:**
- Token may be expired (6h limit)
- Token may be malformed
- Check Authorization header format: `Bearer {token}`
- Verify SECRET_KEY matches between token generation and validation
**Rate limit exceeded (429):**
- Wait for window to expire (15 min for signin, 1 hour for signup/reset)
- Rate limits are per IP address
- Consider adjusting limits in `backend/src/routes/auth.ts`
**OAuth callback fails:**
- Verify callback URLs match in provider console
- Check client ID and secret in environment variables
- Ensure OAuth scopes are properly configured
**Token not persisting after refresh:**
- Check both sessionStorage and localStorage
- Verify axios interceptors are properly attached
- Check for errors in browser console

View File

@ -0,0 +1,336 @@
# Custom Domains via Apache on the VM
Operational plan for serving public production presentations on customer-owned
hostnames without changing Cloudflare configuration.
## Current VM Facts
The checked VM uses this public IPv4 address:
```text
185.8.107.221
```
The current runtime ports are:
| Component | Port | Notes |
|-----------|------|-------|
| Apache | 80 | Public HTTP reverse proxy |
| Frontend | 3001 | Next.js production server |
| Backend | 3000 | Express API |
Apache currently listens on `:80`; `:443` is not enabled on the VM until a
certificate is issued and an SSL virtual host is created.
The existing Apache proxy pattern is:
```text
/api/* -> http://127.0.0.1:3000
/* -> http://127.0.0.1:3001
```
Cloudflare settings are out of scope for this workflow. Do not rely on a
customer CNAME to `tbp.flatlogic.app`, because that hostname may be proxied by
Cloudflare and reject unknown customer hostnames before the request reaches the
VM.
## Customer DNS Setup
For each customer hostname, ask the customer to create an `A` record pointing
directly to the VM public IP:
```text
Type: A
Name: presentation
Value: 185.8.107.221
TTL: 300 or Auto
Proxy/CDN: DNS only / Off
```
For the full hostname:
```text
presentation.customer.com A 185.8.107.221
```
Customer-side notes:
- DNS records cannot contain paths such as `/p/presentation`.
- Do not configure an HTTP redirect to `tbp.flatlogic.app/p/...`.
- If the customer uses Cloudflare or another CDN, start with DNS-only mode.
- The customer can use multiple hostnames; each hostname should point to the
same VM IP and will be routed by our host/path mapping.
## Required Route Data
Before VM and app setup, collect these values:
```text
CUSTOM_DOMAIN=presentation.customer.com
CERTBOT_EMAIL=admin@flatlogic.com
ROUTES:
/ -> presentation
/normal -> presentation-normal
/premium -> presentation-premium
```
Routes are resolved by `hostname + path`, not by DNS. The same customer can
have several hostnames and several paths per hostname.
Example mapping:
```text
presentation.customer.com | / | presentation
presentation.customer.com | /normal | presentation-normal
presentation.customer.com | /premium | presentation-premium
hotel-a.customer.com | / | hotel-a-tour
hotel-b.customer.com | / | hotel-b-tour
```
## DNS Verification
After the customer creates the DNS record, verify resolution from the VM:
```bash
dig +short presentation.customer.com
```
Expected output:
```text
185.8.107.221
```
Verify HTTP reaches the VM:
```bash
curl -I http://presentation.customer.com
curl -I -H 'Host: presentation.customer.com' http://127.0.0.1/
```
Before HTTPS is configured, only HTTP is expected to work.
## Apache Virtual Host
Create a customer-specific Apache site:
```bash
sudo nano /etc/apache2/sites-available/custom-presentation.customer.com.conf
```
Use this HTTP virtual host as the starting point:
```apache
<VirtualHost *:80>
ServerName presentation.customer.com
ProxyPreserveHost On
ProxyRequests Off
RewriteEngine On
RewriteRule ^/api(/.*)?$ http://127.0.0.1:3000$0 [P,L]
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule /(.*) ws://127.0.0.1:3001/$1 [P,L]
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
RewriteRule /(.*) http://127.0.0.1:3001/$1 [P,L]
ProxyPassReverse /api http://127.0.0.1:3000/api
ProxyPassReverse / http://127.0.0.1:3001/
ErrorLog /var/log/apache2/custom-presentation.customer.com-error.log
CustomLog /var/log/apache2/custom-presentation.customer.com-access.log combined
</VirtualHost>
```
Enable and reload:
```bash
sudo a2ensite custom-presentation.customer.com.conf
sudo apache2ctl configtest
sudo systemctl reload apache2
```
## HTTPS with Certbot
Install Certbot if needed:
```bash
sudo apt update
sudo apt install -y certbot python3-certbot-apache
```
Issue the certificate:
```bash
sudo certbot --apache \
-d presentation.customer.com \
--email admin@flatlogic.com \
--agree-tos \
--no-eff-email
```
Verify Apache and certificate state:
```bash
sudo ss -ltnp | grep -E ':80|:443'
sudo certbot certificates
curl -I https://presentation.customer.com
sudo certbot renew --dry-run
```
After Certbot succeeds, Apache should listen on `:443` and serve HTTPS for the
customer hostname.
## Application Changes
The application needs a host/path routing layer for public production runtime.
### Backend Mapping
Add a backend entity such as `custom_domain_routes` with these minimum fields:
```text
hostname text, required
path text, required
project_slug text, required
environment text, default production
is_active boolean, default true
created_at
updated_at
```
Required constraint:
```text
UNIQUE(hostname, path)
```
Normalization rules:
- `hostname`: lowercase, no port.
- `path`: starts with `/`; remove trailing slash except for `/`.
- `environment`: first version should use only `production`.
### Backend Resolve Endpoint
Add a public endpoint:
```text
GET /api/runtime-context/custom-domain/resolve?path=/normal
```
Behavior:
```text
hostname = request Host header / req.hostname
path = normalized query path
find active route by hostname + path
return { projectSlug, environment }
```
Failure behavior:
```text
400 for invalid path
404 if no active mapping exists
```
Security requirements:
- Do not accept hostname from query or body.
- Use only the request host that reached Apache/backend.
- Do not expose stage, constructor, or admin through customer hostnames.
- Return only the data needed to render the public runtime.
### Frontend Custom-Domain Route
Add a public catch-all route for customer-domain paths:
```text
/
/normal
/premium
```
Runtime behavior:
```text
1. Read window.location.pathname.
2. Detect that the current host is not a standard platform host.
3. Call /api/runtime-context/custom-domain/resolve?path=<pathname>.
4. Render <RuntimePresentation projectSlug={projectSlug} environment="production" />.
```
Keep existing platform routes unchanged:
```text
/p/[projectSlug]
/p/[projectSlug]/stage
/constructor
```
## End-to-End Checks
DNS:
```bash
dig +short presentation.customer.com
```
Apache:
```bash
sudo apache2ctl -S
sudo ss -ltnp | grep -E ':80|:443|:3000|:3001'
```
HTTP and HTTPS:
```bash
curl -I http://presentation.customer.com
curl -I https://presentation.customer.com
```
Routes:
```bash
curl -I https://presentation.customer.com/
curl -I https://presentation.customer.com/normal
curl -I https://presentation.customer.com/premium
curl -I https://presentation.customer.com/api/health
```
Direct upstream checks:
```bash
curl -I http://127.0.0.1:3001
curl -I http://127.0.0.1:3000/api/health
```
Expected outcome:
- Customer hostname resolves to `185.8.107.221`.
- HTTPS certificate matches the customer hostname.
- `/api/health` works through the customer hostname.
- `/`, `/normal`, and `/premium` route to configured production
presentations.
- Browser URL remains on the customer hostname.
## Rollout Strategy
1. Configure one test customer hostname.
2. Start with a single route: `/ -> one project slug`.
3. Verify DNS, Apache, HTTPS, API, assets, and first page load.
4. Add `/normal` and `/premium`.
5. Verify images, video, audio, and service worker behavior.
6. Repeat for additional customer hostnames.
## Out of Scope
- Cloudflare custom hostnames or DNS changes in the `flatlogic.app` zone.
- Customer-side HTTP redirects.
- Stage, constructor, or admin access through customer domains.
- UI for managing custom-domain routes in the first version.

View File

@ -0,0 +1,77 @@
# DB Cleanup Audit
This document describes the non-destructive database cleanup audit for finding
orphan records, legacy schema leftovers, and soft-deleted row volume.
## Manual Audit
The cleanup audit is intentionally run as one-time read-only SQL on the target
database instead of a permanent application command. It should check the same
categories listed below and must not delete or update rows.
If rows are found, first capture affected-row details and a backup/export, then
apply cleanup through a reviewed migration or operations runbook.
## What It Checks
Orphan and broken-reference checks:
- active `asset_variants` with missing or soft-deleted `assets` parents
- active `pwa_caches` with missing or soft-deleted `projects` parents
- active `access_logs` with missing or soft-deleted `projects` or `users`
parents
- active `access_logs` with `NULL projectId` or `NULL userId` as warnings,
because admin/system or public/anonymous access may be valid
- active `production_presentation_access` grants with missing, soft-deleted, or
`NULL` `projectId`/`userId`
Legacy schema checks:
- old normalized constructor tables: `page_elements`, `page_links`,
`transitions`
- known removed columns: `assets.is_deleted`, `assets.deleted_at_time`,
`projects.is_deleted`, `projects.deleted_at_time`, `projects.phase`,
`projects.entry_page_slug`, `projects.transition_settings`
Soft-delete summary:
- every table with a `deletedAt` column is scanned for soft-deleted row count
- the report includes oldest and newest `deletedAt` timestamps per table
## Retention Policy
Keep soft-deleted rows indefinitely by default.
Reason: the project uses Sequelize paranoid models, deletion volume is expected
to be low, and restoring accidental deletes is more valuable than speculative
storage cleanup.
Any physical deletion must be a separate migration or operations runbook with:
- a fresh backup
- explicit affected-row query
- rollback or restore plan
- table-specific retention threshold
## Prevention
Asset deletion is handled in `AssetsService`. Because PostgreSQL foreign-key
`ON DELETE CASCADE` does not fire for Sequelize paranoid soft deletes,
`AssetsService.remove()` and `AssetsService.deleteByIds()` soft-delete active
`asset_variants` rows in the same transaction as the parent `assets` soft
delete. This prevents active variants from remaining attached to soft-deleted
assets after commit.
## Local Audit Result
Last local run: 2026-07-02.
Result:
- failed orphan checks: `0`
- warning orphan checks: `0`
- legacy schema candidates: `0`
- tables with soft-deleted rows: `4`
Soft-deleted rows were present in local `assets`, `projects`, `tour_pages`, and
`production_presentation_access`. No deletion was performed.

View File

@ -0,0 +1,299 @@
# VM Deployment Runbook
Operational notes for the standard Flatlogic VM deployment used by this
project. This document describes the VM runtime layout, health checks, and the
June 2026 `503 Service Unavailable` recovery path.
## Runtime Topology
The standard VM runs the app behind Apache and Cloudflare:
```
Cloudflare
-> Apache :80
-> Frontend Next.js production server :3001
-> Backend API :3000
```
Do not assume older local development ports on the VM. The standard port split
is frontend `3001` and backend `3000`:
| Component | VM process | Port | Notes |
|-----------|------------|------|-------|
| Apache | `apache2` | 80 | Public entrypoint, reverse proxy |
| Frontend | `frontend-dev` | 3001 | `npm run build`, then `npm run start` |
| Backend | `backend-dev` | 3000 | `NODE_ENV=dev_stage npm run start` |
| Telemetry | `fl-telemetry` | 4317/4318 | Executor telemetry daemon |
| Executor | `fl-executor` | n/a | VM command/executor bridge |
The backend returns `401 Unauthorized` for protected API endpoints without a
JWT. A `401` from `http://127.0.0.1:3000/api/...` means the backend is alive.
The backend default is port `3000` for `dev_stage`; an explicit `PORT` env var
overrides that when needed.
## Process Manager
PM2 is managed by systemd:
```bash
sudo systemctl status pm2-ubuntu --no-pager
pm2 status
```
Expected PM2 apps:
| Name | Purpose |
|------|---------|
| `frontend-dev` | Next.js frontend production server |
| `backend-dev` | Express API, migrations, seed, watcher |
| `fl-telemetry` | Local telemetry daemon |
| `fl-executor` | Standard VM executor bridge |
The frontend PM2 app name may remain `frontend-dev` for compatibility with the
standard VM image, but the process should run the production script. Build the
VM frontend with:
```bash
cd /home/ubuntu/executor/workspace/frontend
npm run build
```
Start it with:
```bash
FRONT_PORT=3001 npm run start
```
The production frontend is a Next.js server build served by `next start`.
Do not run the VM frontend with `next dev`; the dev server displays the Next.js
dev indicator in presentations.
## Frontend Release Deploys
Automatic VM pulls should deploy the frontend as immutable releases instead of
rebuilding in the live workspace. The executor VCS layer builds a fresh copy
under:
```bash
/home/ubuntu/executor/frontend-releases/<timestamp>-<git-sha>/frontend
```
The deploy order is:
1. Pull the requested branch into `/home/ubuntu/executor/workspace`.
2. Archive `HEAD` into a new release directory.
3. Copy frontend env files from the live workspace when present:
`.env`, `.env.local`, `.env.production`, `.env.production.local`.
4. Run `npm ci`.
5. Run `npm run build`.
6. Remove non-runtime build caches from the new release:
`.next`, `.turbo`, `build/cache`. Production runtime assets stay in
`build`; local `next dev --turbopack` uses `.next` to avoid conflicts with
production build manifests.
7. Switch `frontend-dev` to the new release with
`FRONT_PORT=3001 pm2 start npm --name frontend-dev -- run start`.
8. Save PM2 and remove old frontend releases.
The active frontend release is the PM2 `frontend-dev` working directory. Check
it with:
```bash
pm2 jlist | jq '.[] | select(.name=="frontend-dev") | {
cwd:.pm2_env.pm_cwd,
script:.pm2_env.pm_exec_path,
args:.pm2_env.args,
env:{FRONT_PORT:.pm2_env.FRONT_PORT}
}'
```
Retention defaults to the latest 2 release directories. Override it by setting
`FRONTEND_RELEASES_KEEP` for the executor process before deploy. Do not delete
the active release directory; `next start` serves production assets from its
`build` directory.
Manual rollback is possible by starting `frontend-dev` from an older retained
release:
```bash
cd /home/ubuntu/executor/frontend-releases/<release-id>/frontend
pm2 delete frontend-dev
FRONT_PORT=3001 pm2 start npm --name frontend-dev -- run start
pm2 save --force
```
The PM2 dump is stored at:
```bash
~/.pm2/dump.pm2
```
This file contains environment variables and may contain secrets. Do not paste
it into public tools or tickets without redacting tokens, DB passwords, SMTP
credentials, API keys, and tunnel credentials.
## Health Checks
Use these checks after a deploy or incident:
```bash
df -h
df -ih
free -h
sudo ss -ltnp | grep -E ':80|:3001|:3000|:4317|:4318'
curl -I http://127.0.0.1:3001
curl -I http://127.0.0.1:3000/api/auth/me
curl -I http://tbp.flatlogic.app
pm2 status
```
Expected healthy responses:
- `http://127.0.0.1:3001` returns `200 OK`.
- `http://127.0.0.1:3000/api/auth/me` returns `401 Unauthorized` without JWT.
- `http://tbp.flatlogic.app` returns `200 OK`.
- PM2 shows all four apps `online`.
## Recovering From Apache `503 Service Unavailable`
If Apache returns:
```text
Service Unavailable
Apache/2.4.x Server at tbp.flatlogic.app Port 80
```
first check whether upstream app processes are listening:
```bash
sudo ss -ltnp | grep -E ':80|:3001|:3000'
curl -I http://127.0.0.1:3001
curl -I http://127.0.0.1:3000/api/auth/me
sudo systemctl status pm2-ubuntu --no-pager
```
If Apache is listening but `3001` and `3000` are not, PM2 did not restore or was
stopped. Restart it:
```bash
sudo systemctl reset-failed pm2-ubuntu
sudo systemctl restart pm2-ubuntu
pm2 status
```
Then re-run the health checks.
## OOM-Kill Diagnosis
A VM can have enough disk and still fail if the kernel kills PM2 or a child
process because memory spikes. Check kernel logs:
```bash
journalctl -k --since "YYYY-MM-DD HH:MM" --until "YYYY-MM-DD HH:MM" \
| grep -Ei 'oom|killed process|out of memory'
```
Known June 2026 incident:
- `pm2-ubuntu.service` failed with `Result: oom-kill`.
- Kernel killed `ffmpeg`.
- `ffmpeg` used about 3.3 GiB RSS on a 3.8 GiB RAM VM.
- PM2 then stopped `frontend-dev`, `backend-dev`, `fl-telemetry`, and
`fl-executor`.
This points to reversed video generation rather than Apache, disk space, or
frontend routing.
## FFmpeg and Reverse Video Generation
The backend uses bundled `ffmpeg-static`/`ffprobe-static` via
`backend/src/services/videoProcessing.ts`; manual OS-level FFmpeg installation
is not required for this project.
Reverse video generation can be memory-heavy for large videos. Operational
guardrails:
- FFmpeg reversal is serialized by `videoProcessing.reverseVideo()`: only one
FFmpeg process runs at a time in the backend process, and additional reverse
generation requests wait in an in-process queue.
- FFmpeg reversal uses `-threads 1`.
- FFmpeg reversal has a hard timeout (`FFMPEG_REVERSE_TIMEOUT_MS`, default
`600000`, exposed as `config.resilience.ffmpeg.reverseTimeoutMs`) and kills
the child process if it exceeds the limit.
- FFmpeg reversal is protected by an in-process circuit breaker
(`FFMPEG_BREAKER_FAILURE_THRESHOLD`, `FFMPEG_BREAKER_COOLDOWN_MS`,
`FFMPEG_BREAKER_SUCCESS_THRESHOLD`, exposed under
`config.resilience.ffmpeg.breaker`) so repeated media failures stop launching
new heavy jobs during the cooldown window.
- FFprobe metadata extraction has a timeout (`FFPROBE_TIMEOUT_MS`, default
`30000`, exposed as `config.resilience.ffmpeg.ffprobeTimeoutMs`).
- `TourPagesService` deduplicates reverse generation for the same source video
storage key.
- Treat large source videos as risky on small VMs.
- Check backend PM2 logs for `ffmpeg` or publish/save background errors.
- If the VM OOMs, inspect kernel logs before changing Apache or database config.
Remaining hardening work and follow-up:
- Add input duration/resolution/size checks before reversal.
- Structured logs now include reverse-video input/output size and probed media
metadata. Continue tuning rejection thresholds as real VM media patterns are
observed.
- Consider running media processing in a separate worker with memory limits.
## Logs
Useful log commands:
```bash
sudo journalctl -u pm2-ubuntu -n 200 --no-pager
pm2 logs frontend-dev --lines 100
pm2 logs backend-dev --lines 100
pm2 logs fl-executor --lines 100
pm2 logs fl-telemetry --lines 100
sudo tail -n 100 /var/log/apache2/error.log
```
`pm2 logs` tails by default. Press `Ctrl-C` before running the next command.
## Executor Notes
The standard VM `executor.js` in `~/executor` is not the web app startup script.
It handles VM commands, VCS operations, AI runner prompts, screenshots, and
telemetry. Starting it manually does not start the frontend/backend app.
Executor workspace path:
```bash
/home/ubuntu/executor/workspace
```
The executor can perform git operations when commanded, including reset/clean
workflows through VCS commands. Do not run executor commands blindly when the
goal is only to restore the web app. Use PM2/systemd for process recovery.
## Node Version
The project requirement is Node.js 20.x LTS. Some standard VMs may report
`/usr/bin/node` as Node 22 in PM2. If startup fails after a system update,
verify:
```bash
node -v
which node
pm2 describe backend-dev
pm2 describe frontend-dev
```
Changing the VM Node version should be coordinated with PM2 startup paths and a
full frontend/backend build check.
## Persistence
After changing PM2 process definitions, save the process list:
```bash
pm2 save
```
For an incident-only restart where the process definitions were unchanged,
`pm2 save` is still safe and keeps the current expected app list for reboot.

View File

@ -0,0 +1,476 @@
# Email & Notification Service
Complete documentation for the Tour Builder Platform's email and notification system including Nodemailer/SES integration, verification emails, and invitations.
## Overview
The platform implements a transactional email system using Nodemailer with AWS SES (Simple Email Service) for reliable email delivery. The system handles user verification, password resets, and team invitations.
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Email Service Architecture │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Email Triggers │ │
│ │ │ │
│ │ User Signup ──────────┐ │ │
│ │ Password Reset ───────┼──> AuthService ──> EmailSender ──> AWS SES │ │
│ │ User Invitation ──────┘ │ │
│ │ Email Verification ───────────────────────────────────────────────> │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Email Templates │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ Address │ │ Password │ │ Invitation │ │ │
│ │ │ Verification │ │ Reset │ │ │ │ │
│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Configuration
### Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `EMAIL_USER` | SMTP username (AWS SES SMTP credentials) | Yes |
| `EMAIL_PASS` | SMTP password (AWS SES SMTP credentials) | Yes |
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | TLS certificate validation (`true`/`false`) | No (default: `true`) |
### Config Settings
**Source:** `backend/src/config.ts`
```javascript
email: {
from: 'Tour Builder Platform <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {
user: process.env.EMAIL_USER || '',
pass: process.env.EMAIL_PASS,
},
tls: {
rejectUnauthorized: process.env.EMAIL_TLS_REJECT_UNAUTHORIZED !== 'false',
}
}
```
### AWS SES Configuration
The system uses AWS SES SMTP interface in `us-east-1` region:
- **Host:** `email-smtp.us-east-1.amazonaws.com`
- **Port:** 587 (TLS)
- **Configuration Set:** `flatlogic-app` (for tracking/analytics)
## Email Service Class
**Source:** `backend/src/services/email/index.ts`
```typescript
import nodemailer from 'nodemailer';
export default class EmailSender {
constructor(private readonly email: EmailTemplate) {}
async send(): Promise<EmailSendResult> {
// Validates: email, to, subject, html
const htmlContent = await this.email.html();
const transporter = nodemailer.createTransport(this.transportConfig);
const mailOptions = {
from: this.from,
to: this.email.to,
subject: this.email.subject,
html: htmlContent,
headers: {
'X-SES-CONFIGURATION-SET': 'flatlogic-app',
},
};
return transporter.sendMail(mailOptions);
}
static get isConfigured(): boolean {
return Boolean(config.email.auth.pass && config.email.auth.user);
}
}
```
### Configuration Check
The `EmailSender.isConfigured` static property checks if email credentials are set. When email is not configured:
- Email verification is skipped when email delivery is disabled
- Users are automatically marked as verified
- Password reset/invitation emails are not sent
## Email Types
### 1. Email Address Verification
**Source:** `backend/src/services/email/list/addressVerification.ts`
**Triggered by:**
- Manual verification request (`POST /api/auth/send-email-address-verification-email`)
**Template:** `backend/src/services/email/htmlTemplates/addressVerification/emailAddressVerification.html`
**Template Variables:**
| Variable | Description |
|----------|-------------|
| `{appTitle}` | Application name ("Tour Builder Platform") |
| `{signupUrl}` | Verification link with token |
> **Note:** The code attempts to replace `{to}` but this placeholder is not present in the HTML template.
**Token Generation:**
```javascript
// UsersDBApi._generateToken
const token = crypto.randomBytes(20).toString('hex');
const tokenExpiresAt = Date.now() + (24 * 60 * 60 * 1000); // 24 hours
// Stored in users table:
// - emailVerificationToken
// - emailVerificationTokenExpiresAt
```
### 2. Password Reset
**Source:** `backend/src/services/email/list/passwordReset.ts`
**Triggered by:**
- Password reset request (`POST /api/auth/send-password-reset-email`)
**Template:** `backend/src/services/email/htmlTemplates/passwordReset/passwordResetEmail.html`
**Template Variables:**
| Variable | Description |
|----------|-------------|
| `{appTitle}` | Application name |
| `{resetUrl}` | Password reset link with token |
| `{accountName}` | User's email address |
**Token Generation:**
```javascript
// Stored in users table:
// - passwordResetToken
// - passwordResetTokenExpiresAt (24 hours)
```
### 3. User Invitation
**Source:** `backend/src/services/email/list/invitation.ts`
**Triggered by:**
- User creation with `sendInvitationEmails: true`
- Bulk user import
**Template:** `backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html`
**Template Variables:**
| Variable | Description |
|----------|-------------|
| `{appTitle}` | Application name |
| `{signupUrl}` | Invitation link (password reset link with `&invitation=true`) |
> **Note:** The code attempts to replace `{to}` but this placeholder is not present in the HTML template.
**Usage in UsersService:**
```javascript
// backend/src/services/users.ts
if (emailsToInvite && emailsToInvite.length) {
AuthService.sendPasswordResetEmail(email, 'invitation', host);
}
```
## HTML Template Structure
All email templates follow a consistent structure:
```html
<!DOCTYPE html>
<html>
<head>
<style>
.email-container { max-width: 600px; margin: auto; }
.email-header { background-color: #3498db; color: #fff; padding: 16px; }
.email-body { padding: 16px; }
.email-footer { background-color: #f7fafc; padding: 16px; }
.btn-primary { background-color: #3498db; color: #fff; padding: 8px 16px; }
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">Welcome to {appTitle}!</div>
<div class="email-body"><!-- Content --></div>
<div class="email-footer">Thanks, The {appTitle} Team</div>
</div>
</body>
</html>
```
## Notification System
### Message Lookup
**Source:** `backend/src/services/notifications/helpers.js`
The notification system uses a key-based lookup for all user-facing messages:
```javascript
const { getNotification } = require('./notifications/helpers');
// Usage
getNotification('emails.invitation.subject', appTitle);
// Returns: "You've been invited to Tour Builder Platform"
```
### Message Catalog
**Source:** `backend/src/services/notifications/list.ts`
| Key | Message |
|-----|---------|
| `app.title` | "Tour Builder Platform" |
| `emails.invitation.subject` | "You've been invited to {0}" |
| `emails.emailAddressVerification.subject` | "Verify your email for {0}" |
| `emails.passwordReset.subject` | "Reset your password for {0}" |
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" |
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" |
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" |
> **Note:** The message catalog also contains `emails.*.body` text templates, but these are not used. The system reads HTML templates from the `htmlTemplates/` directory instead.
## API Endpoints
### Email-Related Auth Endpoints
| Method | Endpoint | Description | Rate Limit |
|--------|----------|-------------|------------|
| `POST` | `/api/auth/signin/local` | User login | 10/15min |
| `POST` | `/api/auth/send-email-address-verification-email` | Resend verification (auth required) | - |
| `PUT` | `/api/auth/verify-email` | Verify email token | - |
| `POST` | `/api/auth/send-password-reset-email` | Send password reset | 5/hour |
| `PUT` | `/api/auth/password-reset` | Reset password with token | - |
| `PUT` | `/api/auth/password-update` | Change password (auth required) | - |
| `GET` | `/api/auth/email-configured` | Check if email is configured | - |
### Rate Limiting
**Source:** `backend/src/middlewares/rateLimiter.ts`
Rate limiters are imported from a centralized middleware:
```javascript
// backend/src/routes/auth.js
const {
authLimiter: signinLimiter,
passwordResetLimiter,
} = require('../middlewares/rateLimiter');
// Preconfigured limiters in rateLimiter.ts:
const authLimiter = createRateLimiter({
keyPrefix: 'auth',
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many authentication attempts. Please try again later.',
});
// Self-registration is disabled; no signup limiter is registered.
const passwordResetLimiter = createRateLimiter({
keyPrefix: 'password-reset',
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: 'Too many password reset requests. Please try again later.',
});
```
Features:
- Uses centralized in-memory Map with automatic cleanup every 5 minutes
- Adds standard rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`)
- Returns 429 status with JSON response when exceeded
- Skips rate limiting in development for localhost
## Token Management
### User Model Fields
| Field | Type | Description |
|-------|------|-------------|
| `emailVerified` | BOOLEAN | Whether email has been verified |
| `emailVerificationToken` | TEXT | Token for email verification (40-char hex) |
| `emailVerificationTokenExpiresAt` | DATE | Token expiration timestamp |
| `passwordResetToken` | TEXT | Token for password reset (40-char hex) |
| `passwordResetTokenExpiresAt` | DATE | Token expiration timestamp |
### Token Lifecycle
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Token Lifecycle │
│ │
│ 1. Token Generated │
│ └── crypto.randomBytes(20).toString('hex') │
│ └── Expiration: Date.now() + 24 hours │
│ │
│ 2. Token Stored │
│ └── users.emailVerificationToken / passwordResetToken │
│ └── users.emailVerificationTokenExpiresAt / passwordResetTokenExpiresAt │
│ │
│ 3. Email Sent │
│ └── Link: {host}/verify-email?token={token} │
│ └── Link: {host}/password-reset?token={token} │
│ │
│ 4. Token Validated │
│ └── Check token exists │
│ └── Check tokenExpiresAt > Date.now() │
│ │
│ 5. Token Consumed │
│ └── emailVerified = true (verification) │
│ └── password updated (reset) │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Email Verification Flow
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Email Verification Flow │
│ │
│ User Backend AWS SES │
│ │ │ │ │
│ │ POST /api/auth/send-email-address-verification-email │ │
│ │───────────────────────────>│ │ │
│ │ │ │ │
│ │ │ Create user │ │
│ │ │ Generate verification token │ │
│ │ │ │ │
│ │ │ EmailSender.send() │ │
│ │ │──────────────────────────────>│ │
│ │ │ │ │
│ │ │<──────────────────────────────│ │
│ │<───────────────────────────│ 200 OK (JWT token) │ │
│ │ │ │ │
│ │ [Email arrives with verification link] │
│ │ │ │ │
│ │ PUT /api/auth/verify-email │ │ │
│ │ { token: "..." } │ │ │
│ │───────────────────────────>│ │ │
│ │ │ │ │
│ │ │ Validate token & expiry │ │
│ │ │ Set emailVerified = true │ │
│ │ │ │ │
│ │<───────────────────────────│ 200 OK │ │
│ │ │ │ │
│ │ [User can now sign in normally] │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Invitation Flow
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Invitation Flow │
│ │
│ Admin Backend New User │
│ │ │ │ │
│ │ POST /api/users │ │ │
│ │ { email, sendInvite:true } │ │ │
│ │───────────────────────────>│ │ │
│ │ │ │ │
│ │ │ Create user record │ │
│ │ │ Generate password reset token │ │
│ │ │ Send invitation email ───────>│ │
│ │ │ │ │
│ │<───────────────────────────│ 200 OK │ │
│ │ │ │ │
│ │ │ [Email with invitation link] │
│ │ │ │ │
│ │ │ Click link: /password-reset?token= │
│ │ │<──────────────────────────────│ │
│ │ │ │ │
│ │ │ PUT /api/auth/password-reset │ │
│ │ │ { token, password } │ │
│ │ │ │ │
│ │ │ Set password │ │
│ │ │ (Auto-verifies email) │ │
│ │ │ │ │
│ │ │──────────────────────────────>│ │
│ │ │ 200 OK - Account ready │ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Error Handling
### Validation Errors
| Error Key | Message | HTTP Status |
|-----------|---------|-------------|
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | 400 |
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | 400 |
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | 400 |
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | 400 |
| `auth.passwordReset.error` | "Email not recognized" | 400 |
### Graceful Degradation
When email is not configured (`EmailSender.isConfigured === false`):
1. **Signup:** Users are automatically marked as verified
2. **Signin:** Email verification check is bypassed
3. **Password Reset:** Endpoint returns success but no email is sent
```typescript
// backend/src/services/auth.ts
if (!EmailSender.isConfigured) {
user.emailVerified = true; // Auto-verify when email not configured
}
```
## File Structure
```
backend/src/
├── middlewares/
│ └── rateLimiter.ts # Centralized rate limiting middleware
└── services/
├── email/
│ ├── index.ts # EmailSender class
│ ├── list/
│ │ ├── addressVerification.ts # Email verification template
│ │ ├── invitation.ts # User invitation template
│ │ └── passwordReset.ts # Password reset template
│ └── htmlTemplates/
│ ├── addressVerification/
│ │ └── emailAddressVerification.html
│ ├── invitation/
│ │ └── invitationTemplate.html
│ └── passwordReset/
│ └── passwordResetEmail.html
└── notifications/
├── helpers.js # getNotification() function
├── list.js # Message catalog
└── errors/
├── forbidden.js # ForbiddenError class
└── validation.js # ValidationError class
```
## Known Considerations
1. **AWS SES Region:** The system is configured for `us-east-1`. For other regions, update `config.email.host`.
2. **Token Expiration:** All tokens expire after 24 hours. This was increased from the original 6 minutes to accommodate email delivery delays.
3. **Rate Limiting:** Uses centralized in-memory rate limiting in `backend/src/middlewares/rateLimiter.ts` (not distributed). Rate limits reset on server restart. Automatic cleanup of expired entries every 5 minutes.
4. **SES Configuration Set:** The `X-SES-CONFIGURATION-SET: flatlogic-app` header enables SES tracking features.
5. **Email Not Configured Mode:** The system gracefully handles missing email configuration by auto-verifying users, useful for development environments.
6. **Invitation vs Password Reset:** Invitations use the same token mechanism as password reset, with a different email template. The link includes `&invitation=true` to indicate the context.
7. **TLS Certificate Validation:** Can be disabled via `EMAIL_TLS_REJECT_UNAUTHORIZED=false` for development environments with self-signed certificates.

View File

@ -0,0 +1,209 @@
# Global UI Controls
## Overview
Global UI controls are system-owned presentation buttons for fullscreen, global
sound mute, and offline mode. They are not regular canvas elements and are not
stored in `tour_pages.ui_schema_json.elements`.
They behave like runtime chrome:
- button dimensions use canvas-relative percentages
- button positions are relative to the visible canvas using `xPercent/yPercent`
- constructor edit mode renders controls even when hidden
- controls can be disabled or hidden, but cannot be deleted
- each control can use custom default and active icons from Assets
## Implementation Files
Backend:
- `backend/src/db/models/global_ui_control_defaults.js`
- `backend/src/db/models/project_ui_control_settings.js`
- `backend/src/db/api/global_ui_control_defaults.ts`
- `backend/src/db/api/project_ui_control_settings.ts`
- `backend/src/routes/global_ui_control_defaults.ts`
- `backend/src/routes/project_ui_control_settings.ts`
- `backend/src/services/global_ui_control_defaults.ts`
- `backend/src/services/project_ui_control_settings.ts`
Frontend:
- `frontend/src/types/uiControls.ts`
- `frontend/src/components/UiControls/UiControlsSettingsForm.tsx`
- `frontend/src/components/Runtime/RuntimeControls.tsx`
- `frontend/src/components/Constructor/ElementEditorPanel.tsx`
- `frontend/src/stores/global_ui_control_defaults/globalUiControlDefaultsSlice.ts`
- `frontend/src/stores/project_ui_control_settings/projectUiControlSettingsSlice.ts`
- `frontend/src/components/RuntimePresentation.tsx`
- `frontend/src/pages/constructor.tsx`
- `frontend/src/pages/global-ui-control-defaults.tsx`
- `frontend/src/pages/global-ui-control-defaults/[controlType].tsx`
- `frontend/src/pages/project-ui-control-settings.tsx`
## Cascade
Settings resolve field-by-field:
```
global_ui_control_defaults
-> project_ui_control_settings (project + environment)
-> tour_pages.global_ui_controls_settings_json
```
Missing fields inherit from the previous cascade level: page settings override
project settings, project settings override global defaults, and frontend
hardcoded defaults are only a safety fallback.
## Data Shape
Each control (`fullscreen`, `sound`, `offline`) supports:
| Field | Purpose |
|-------|---------|
| `enabled` | Disable action while keeping the control selectable in edit mode |
| `hidden` | Hide in runtime/preview; still render as ghost in constructor edit mode |
| `xPercent`, `yPercent` | Canvas-relative position |
| `anchor` | Which button point is placed at the coordinate |
| `buttonSizePercent`, `iconSizePercent`, `borderRadiusPercent` | Canvas-width-relative dimensions |
| `defaultIconUrl`, `activeIconUrl` | Optional custom asset URL/storage key per state |
| `defaultBackgroundColor`, `activeBackgroundColor` | Button background color per state |
| `defaultBorderColor`, `activeBorderColor` | Button border color per state |
| `hoverBackgroundColor`, `color` | Shared hover background and icon color |
| `opacity`, `boxShadow`, `zIndex`, `order` | Presentation and initial ordering |
Active state means downloaded/offline for the offline button, muted for the
sound button, and fullscreen for the fullscreen button.
When runtime is embedded in an iframe, the fullscreen control first tries the
browser Fullscreen API for the presentation document, then tries to fullscreen
the embedding iframe when same-origin access is available. For cross-origin
wrappers it posts `tour-builder:request-fullscreen` to `window.parent`; exit
attempts post `tour-builder:exit-fullscreen`.
```html
<iframe id="tour-frame" src="https://example.com/p/project" allow="fullscreen" allowfullscreen></iframe>
<script>
window.addEventListener('message', async (event) => {
const frame = document.getElementById('tour-frame');
if (event.data?.type === 'tour-builder:request-fullscreen') {
await frame?.requestFullscreen?.();
}
if (event.data?.type === 'tour-builder:exit-fullscreen' && document.fullscreenElement) {
await document.exitFullscreen();
}
});
</script>
```
Dimensions are stored as percentages of canvas width. `buttonSizePercent` and
`iconSizePercent` resolve from the displayed canvas width. Vertical bounds use
the canvas aspect ratio, so buttons remain fully inside the visible canvas for
non-16:9 projects as well.
Default global values:
| Control | X | Y | Size | Icon | Radius | Order |
|---------|---|---|------|------|--------|-------|
| `offline` | `89.5` | `6` | `2.6` | `1.35` | `0.42` | `1` |
| `fullscreen` | `92.75` | `6` | `2.6` | `1.35` | `0.42` | `2` |
| `sound` | `96` | `6` | `2.6` | `1.35` | `0.42` | `3` |
## APIs
- `GET /api/global-ui-control-defaults`
- `PUT /api/global-ui-control-defaults/:id`
- `GET /api/project-ui-control-settings/project/:projectId/env/:environment`
- `PUT /api/project-ui-control-settings/project/:projectId/env/:environment`
- `DELETE /api/project-ui-control-settings/project/:projectId/env/:environment`
Production project settings reads follow the same public/private access rules
as runtime transition settings. Dev/stage and writes require JWT.
`GET /api/global-ui-control-defaults` is public-readable for runtime. Updates
require JWT and `UPDATE_PAGE_ELEMENTS`; these settings are authored with the
same permission family as element defaults. Project-level `production` reads
are public only for public production presentations; private production
presentations require JWT and presentation access.
Project override writes require `UPDATE_PAGE_ELEMENTS`. The project
environment reset endpoint is implemented as `DELETE` because it removes the
override row, but authorization treats it as an update operation: it also
requires `UPDATE_PAGE_ELEMENTS`, not `DELETE_PAGE_ELEMENTS`.
## Constructor Behavior
Constructor renders controls through `RuntimeControls` with `editMode=true`.
Selecting a system control opens the element editor in system-control mode.
System controls:
- can be dragged to update `xPercent/yPercent`
- can be edited with coordinate inputs
- can choose separate default and active custom icons
- can edit default/active background colors and border colors
- can edit hidden/disabled state, order, z-index, opacity, shadow, size, radius,
and anchor
- cannot be removed, copied, or pasted
Constructor system-control opacity is edited as a clamped percentage from `0`
to `100` and converted to the stored runtime opacity number from `0` to `1`.
Constructor edit mode blocks runtime actions. Clicking or dragging system
controls does not toggle fullscreen, sound, or offline mode. Hidden controls are
rendered as ghost controls so authors can reselect and unhide them.
## Admin Editing UI
Global defaults are listed at `/global-ui-control-defaults` and edited per
control:
- `/global-ui-control-defaults/offline`
- `/global-ui-control-defaults/fullscreen`
- `/global-ui-control-defaults/sound`
Project-level overrides are edited from
`/project-ui-control-settings?projectId=...`.
The per-control global pages and project page use `UiControlsSettingsForm`, a
typed editor with the same tab model used by element defaults:
- **General Settings**: enabled/hidden state, canvas-relative position, anchor,
order, and default/active icon URLs
- **CSS Styles**: button/icon sizing, radius, icon color, background colors,
and border colors
- **Effects**: opacity, z-index, and box shadow
Opacity inputs in the constructor/editor UI are percentage-based for authors;
stored `settings_json.opacity` remains a numeric CSS opacity value.
Project settings can be cleared with **Use Global Defaults**, which deletes the
project/environment override so the project inherits global defaults again.
## Publishing
`project_ui_control_settings` is environment-aware and is copied by the publish
workflow:
```
dev -> stage -> production
```
Page-level overrides live on `tour_pages` and are copied with page records.
Project clone copies project UI-control settings for each environment, and page
duplication copies page-level `global_ui_controls_settings_json`.
## Migrations
- `20260628000001-create-ui-control-settings.js` creates the two DB models,
adds `tour_pages.global_ui_controls_settings_json`, creates the project/env
unique index, and seeds the initial global defaults.
- `20260628000005-snapshot-existing-project-ui-controls.js` snapshots
project-level settings for existing projects that do not already have
project UI-control overrides.
## Existing Projects
Migration `20260628000005-snapshot-existing-project-ui-controls.js` snapshots
the previous runtime chrome layout into project-level settings for existing
projects in `dev`, `stage`, and `production` when no project UI-control override
already exists. The snapshot keeps the old top-right grouped placement offsets
and previous runtime z-index, but stores dimensions with the same
canvas-relative defaults used by new projects (`2.6%` button size, `1.35%` icon
size, `0.42%` radius).

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,572 @@
# Page Navigation
Documentation for the Tour Builder Platform's page navigation system using element-based navigation stored in `ui_schema_json`.
## Overview
The platform uses a simplified navigation system where navigation configuration is stored directly in `tour_pages.ui_schema_json` as part of element definitions. This approach:
- Eliminates ID remapping issues when publishing between environments
- Uses page slugs instead of UUIDs for cross-environment consistency
- Stores transition video URLs directly on navigation elements
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Page Navigation Architecture │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Navigation Source │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ tour_pages.ui_schema_json │ │ │
│ │ │ │ │ │
│ │ │ elements: [{ │ │ │
│ │ │ type: "navigation_next", │ │ │
│ │ │ targetPageSlug: "page-2", │ │ │
│ │ │ transitionVideoUrl: "assets/.../video.mp4", │ │ │
│ │ │ ... │ │ │
│ │ │ }] │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │ Navigation Resolution │ │ │
│ │ │ │ │ │
│ │ │ • Resolve slug to page │ │ │
│ │ │ • Determine direction │ │ │
│ │ │ • Get preloaded blob URL │ │ │
│ │ └───────────────┬───────────────┘ │ │
│ │ │ │ │
│ │ ┌───────────────┴───────────────┐ │ │
│ │ ▼ ▼ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ With Transition │ │ Direct Navigate │ │ │
│ │ │ │ │ │ │ │
│ │ │ Play video → │ │ Switch page → │ │ │
│ │ │ Switch page │ │ Update history │ │ │
│ │ └──────────────────┘ └──────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Data Model
### Navigation in ui_schema_json
Navigation is configured directly within element objects in the `tour_pages.ui_schema_json` field:
```typescript
// Element with navigation configuration
{
id: "element-uuid",
type: "navigation_next", // or "navigation_prev"
label: "Next Page Button",
// Position
xPercent: 90,
yPercent: 85,
// Navigation Configuration
navigationTargetMode: "target_page", // "target_page" or "external_url"
targetPageSlug: "page-2", // Slug-based navigation (consistent across environments)
externalUrl: undefined, // Used when navigationTargetMode is "external_url"
transitionVideoUrl: "assets/transitions/fade.mp4",
transitionDurationSec: 0.7,
transitionReverseMode: "auto_reverse", // or "separate_video"
reverseVideoUrl: undefined, // Only used with "separate_video" mode
// Element Styling
iconUrl: "assets/icons/arrow-right.png",
// ... extends ElementStyleProperties
}
```
### Key Fields
| Field | Type | Description |
|-------|------|-------------|
| `type` | string | `navigation_next` or `navigation_prev` determines direction |
| `navigationTargetMode` | `'target_page'` \| `'external_url'` | Destination mode for forward navigation buttons. Defaults to `target_page` |
| `targetPageSlug` | string | Target page slug (NOT UUID) for cross-environment consistency |
| `externalUrl` | string | External URL opened in a new tab when `navigationTargetMode` is `external_url` |
| `transitionVideoUrl` | string | URL to transition video (optional) |
| `transitionDurationSec` | number | Duration in seconds (optional) |
| `transitionReverseMode` | `'auto_reverse'` \| `'separate_video'` | Reverse playback mode (replaces deprecated `supportsReverse`) |
| `reverseVideoUrl` | string | Separate video URL for reverse playback (when mode is `separate_video`) |
### Why Slugs Instead of UUIDs
Previous versions used `targetPageId` (UUID) which caused issues:
- When pages are copied between environments (dev → stage → production), UUIDs change
- References to old UUIDs became invalid after publishing
The slug-based approach solves this:
- Slugs are unique within project+environment
- Slugs remain identical across environments (same page has same slug in dev/stage/prod)
- No ID remapping needed during publish
## Navigation Types
### Forward Navigation (`navigation_next`)
Navigates to a specified target page with optional transition.
```typescript
{
type: "navigation_next",
navigationTargetMode: "target_page",
targetPageSlug: "gallery",
transitionVideoUrl: "assets/transitions/zoom-in.mp4",
transitionReverseMode: "auto_reverse"
}
```
**Behavior:**
- Target page resolved by slug
- Transition video plays forward if specified
- Page added to navigation history
### External URL Navigation (`navigation_next`)
Forward navigation buttons can open an external URL instead of targeting a tour page:
```typescript
{
type: "navigation_next",
navType: "forward",
navigationTargetMode: "external_url",
externalUrl: "https://example.com"
}
```
**Behavior:**
- The button is treated as forward navigation
- Target page selection is disabled and `targetPageSlug` / `targetPageId` are cleared
- The URL opens in a new tab with `noopener,noreferrer`
- If the URL omits `http://` or `https://`, the runtime opens it with an `https://` prefix
- External URL buttons are not included in internal page-link extraction, neighbor preloading, or reversed transition video generation
### Back Navigation (`navigation_prev`)
Returns to previous page with optional reverse transition.
```typescript
{
type: "navigation_prev",
targetPageSlug: "home", // Optional - can use history
transitionVideoUrl: "assets/transitions/zoom-in.mp4",
transitionReverseMode: "auto_reverse" // Plays in reverse for back nav
}
```
**Alternative with separate reverse video:**
```typescript
{
type: "navigation_prev",
targetPageSlug: "home",
transitionVideoUrl: "assets/transitions/zoom-in.mp4",
transitionReverseMode: "separate_video",
reverseVideoUrl: "assets/transitions/zoom-out.mp4"
}
```
**Behavior:**
- Target page optional (uses page history)
- If `transitionReverseMode = 'auto_reverse'`, video plays in reverse
- If `transitionReverseMode = 'separate_video'`, uses `reverseVideoUrl` instead
- Page popped from navigation history
## Navigation Flow
### Runtime Resolution
**Files:**
- `frontend/src/components/RuntimePresentation.tsx`
- `frontend/src/lib/navigationHelpers.ts`
Navigation uses `resolveNavigationTarget` helper and `usePageSwitch` hook:
```typescript
import { resolveNavigationTarget, isTransitionBlocking } from '../lib/navigationHelpers';
// Extract navigation links from pages
const { pageLinks, preloadElements } = extractPageLinksAndElements(pages);
// Initialize preload orchestrator
const preloadOrchestrator = usePreloadOrchestrator({
pages,
pageLinks,
elements: preloadElements,
currentPageId: selectedPageId,
enabled: !isLoading,
});
// Initialize page switch with preload cache
const pageSwitch = usePageSwitch({
preloadCache: {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, // O(1) instant lookup
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
},
});
// Handle navigation element click
const handleElementClick = useCallback((element) => {
// Block navigation during active transitions
if (isTransitionBlocking(transitionPhase, isBuffering)) {
return;
}
// Resolve target page (supports both slug and legacy ID)
const navTarget = resolveNavigationTarget(element, pages);
if (!navTarget) return;
// Navigate with transition if configured
if (element.transitionVideoUrl) {
// Start transition playback, then switch page on completion
startTransition({
videoUrl: element.transitionVideoUrl,
storageKey: element.transitionVideoUrl, // Raw path for cache lookup
reverseMode: navTarget.isBack ? getReverseMode(element) : 'none',
reverseVideoUrl: element.reverseVideoUrl,
targetPageId: navTarget.pageId,
});
} else {
// Direct navigation (no transition)
pageSwitch.switchToPage(navTarget.page, () => {
setSelectedPageId(navTarget.pageId);
});
}
}, [pages, pageSwitch, transitionPhase, isBuffering]);
```
**Navigation Target Resolution (`navigationHelpers.ts`):**
```typescript
// Supports both targetPageSlug (preferred) and targetPageId (legacy)
export const resolveNavigationTarget = (element, pages) => {
let targetPage;
if (element.targetPageSlug) {
targetPage = pages.find(p => p.slug === element.targetPageSlug);
} else if (element.targetPageId) {
targetPage = pages.find(p => p.id === element.targetPageId);
}
if (!targetPage) return null;
const isBack = element.navType === 'back' || element.type === 'navigation_prev';
return {
page: targetPage,
pageId: targetPage.id,
transitionVideoUrl: element.transitionVideoUrl,
isBack,
};
};
```
### Page History Management
Page history is managed by the shared `usePageNavigation` hook (used by both RuntimePresentation and constructor):
```typescript
import { usePageNavigation } from '../hooks/usePageNavigation';
// Hook provides unified history management with browser-like behavior
const {
currentPageId: selectedPageId,
pageHistory,
previousPageId,
applyPageSelection,
getNavigationContext,
} = usePageNavigation({
pages,
defaultPageId: initialPageId,
trackHistory: true,
});
// applyPageSelection handles history automatically:
// - Forward (isBack=false): appends to history, trimmed to MAX_HISTORY_LENGTH=50
// - Back (isBack=true): pops from history if target matches previousPageId
applyPageSelection(targetPageId, isBack);
// getNavigationContext provides context for history-based back navigation
const navContext = getNavigationContext();
// Returns: { currentPageSlug, previousPageId }
const navTarget = resolveNavigationTarget(element, pages, navContext);
```
**History Behavior:**
```
Forward: A → B → C → history: [A, B, C]
Back to B (isBack=true): → history: [A, B] ✓ Pops C
Back to A (isBack=true): → history: [A] ✓ Pops B
Forward to D: → history: [A, D] ✓ Adds D
```
## Transition Playback
### With Video Transition
```typescript
// useTransitionPlayback handles video playback with preload cache integration
const { phase, isBuffering, isReversing, cancel, forceComplete } = useTransitionPlayback({
videoRef,
transition: transitionConfig, // { videoUrl, storageKey, reverseMode, reverseVideoUrl, targetPageId, isBack }
// onComplete receives isBack flag for proper history management
onComplete: (targetPageId, isBack) => {
// Transition finished, switch to target page
pageSwitch.switchToPage(targetPage, () => {
// usePageNavigation hook: pops history on back, appends on forward
applyPageSelection(targetPageId, isBack ?? false);
});
},
preload: {
preloadedUrls: preloadOrchestrator.preloadedUrls,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
},
});
```
**Storage Key Mapping:**
The `storageKey` (raw storage path like `assets/project/transition.mp4`) is preserved for cache lookup because presigned URL signatures change on each resolution. The lookup priority is:
1. `getReadyBlobUrl(storageKey)` → O(1) instant (same session)
2. `getCachedBlobUrl(storageKey)` → Cache API (~5ms, post-refresh)
3. Fallback to resolved URL lookup
### Reverse Playback
When navigating back with `transitionReverseMode = 'auto_reverse'`:
- Video plays from end to beginning using `useReversePlayback` hook
- Supports native `playbackRate = -1` or frame-stepping fallback
```typescript
const {
startReverse,
stopReverse,
isReversing,
isBuffering,
canUseNativeReverse,
} = useReversePlayback({
videoRef,
onComplete: finishOverlayTransition,
preloadedUrls,
videoUrl,
getCachedBlobUrl,
});
// For back navigation with auto_reverse mode
if (isBack && transitionReverseMode === 'auto_reverse') {
startReverse();
}
```
**Reverse Mode Options:**
| Mode | Behavior |
|------|----------|
| `none` | No transition on back navigation |
| `auto_reverse` | Same video plays in reverse |
| `separate_video` | Uses `reverseVideoUrl` for back navigation |
## Constructor Configuration
### Setting Up Navigation
**File:** `frontend/src/pages/constructor.tsx`
When creating/editing navigation elements:
1. Select element type (`navigation_next` or `navigation_prev`)
2. Choose target page from dropdown (shows page names/slugs)
3. Optionally select transition video from assets
4. Configure transition settings (duration, reverse support)
```typescript
// Saving navigation element
const elementData = {
type: selectedElementType,
targetPageSlug: targetPage?.slug, // Save slug, not ID
transitionVideoUrl: selectedTransitionVideo?.cdn_url,
transitionDurationSec: transitionDuration,
transitionReverseMode: supportsReverse ? 'auto_reverse' : undefined,
reverseVideoUrl: reverseMode === 'separate_video' ? reverseVideo?.cdn_url : undefined,
};
```
### Extracting Navigation Links for Preloading
**File:** `frontend/src/lib/extractPageLinks.ts`
The `extractPageLinksAndElements` utility extracts navigation targets and preloadable elements from pages:
```typescript
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
// Extract from all pages (filtered by environment)
const filteredPages = pages.filter(p => p.environment === environment);
const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages);
// pageLinks: Array of navigation connections
// [{ sourcePageId, targetPageSlug, transitionVideoUrl, supportsReverse }]
// preloadElements: Array of elements with preloadable assets
// [{ pageId, type, imageUrl, videoUrl, transitionVideoUrl, ... }]
```
### Building Neighbor Graph
**File:** `frontend/src/hooks/useNeighborGraph.ts`
The neighbor graph is built from pageLinks for preload prioritization:
```typescript
const neighborGraph = useNeighborGraph({
pages: filteredPages,
pageLinks, // From extractPageLinksAndElements
currentPageId,
maxDepth: 1, // Only immediate neighbors (reduced from 2)
});
// Returns pages reachable within maxDepth hops
const neighborsToPreload = neighborGraph.getNeighbors(currentPageId);
```
**Preload Priority for Navigation Assets:**
| Asset Type | Priority | Notes |
|------------|----------|-------|
| Transition video | +150 | Highest - needed immediately on click |
| Background image | +100 | Required for page display |
| Audio | +50 | Background audio tracks |
| Video | +30 | Can stream, lower priority |
## TypeScript Types
**File:** `frontend/src/types/constructor.ts`
```typescript
// Navigation-related fields in CanvasElement
interface CanvasElement extends BaseCanvasElement {
id: string;
type: CanvasElementType; // 'navigation_next' | 'navigation_prev' | ...
label: string;
// Position
xPercent: number;
yPercent: number;
// Navigation (for navigation_next, navigation_prev types)
navType?: NavigationButtonKind; // 'forward' | 'back'
navDisabled?: boolean;
/** @deprecated Use targetPageSlug instead */
targetPageId?: string;
targetPageSlug?: string;
transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string;
transitionDurationSec?: number;
// Styling
iconUrl?: string;
// ... extends ElementStyleProperties for CSS styling
}
// Navigation button direction type
type NavigationButtonKind = 'forward' | 'back';
```
**File:** `frontend/src/types/presentation.ts`
```typescript
// Navigation target resolved from element
interface NavigationTarget {
page: RuntimePage;
pageId: string;
transitionVideoUrl?: string;
isBack: boolean;
}
// Element with navigation properties (for click handling)
interface NavigableElement {
id: string;
type: string;
targetPageSlug?: string;
targetPageId?: string; // Legacy
transitionVideoUrl?: string;
navType?: 'forward' | 'back';
navDisabled?: boolean;
}
// Transition phase states
type TransitionPhase = 'idle' | 'preparing' | 'playing' | 'reversing' | 'completed';
```
## File References
| File | Purpose |
|------|---------|
| `frontend/src/pages/constructor.tsx` | Navigation element configuration |
| `frontend/src/components/RuntimePresentation.tsx` | Runtime navigation execution |
| `frontend/src/lib/extractPageLinks.ts` | Extract navigation links and preload elements from pages |
| `frontend/src/lib/navigationHelpers.ts` | Navigation target resolution and direction detection |
| `frontend/src/hooks/usePageSwitch.ts` | Page navigation with preloaded blob URLs |
| `frontend/src/hooks/usePreloadOrchestrator.ts` | Asset preloading with ready blob URL management |
| `frontend/src/hooks/usePageNavigation.ts` | History and page state management |
| `frontend/src/hooks/useNeighborGraph.ts` | Preload graph from navigation |
| `frontend/src/hooks/useReversePlayback.ts` | Reverse video playback (native or frame-stepping) |
| `frontend/src/hooks/useTransitionPlayback.ts` | Transition video playback with preloaded URLs |
| `frontend/src/types/constructor.ts` | Element type definitions |
| `frontend/src/types/presentation.ts` | Navigation target and element interfaces |
| `frontend/src/config/preload.config.ts` | Preload priority weights and settings |
`extractPageLinks.ts` also extracts nested Info Panel `target_page` destinations from `infoPanelSections`:
- section-level header/title/text click destinations
- span item click destinations
- image/card/video/360 item click destinations
External URL destinations are not added to the neighbor graph. Info Panel nested media URLs (`imageUrl`, `videoUrl`, `iconUrl`, and header images) are recursively extracted into preload elements.
## Environment Filtering
Pages are filtered by environment before navigation resolution:
```typescript
// Filter pages by current environment (dev, stage, production)
const filteredPages = pages.filter(p => p.environment === environment);
// Extract navigation links only from same-environment pages
const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages);
// Navigation only resolves within the same environment
const targetPage = filteredPages.find(p => p.slug === element.targetPageSlug);
```
**Environment Contexts:**
| Context | Environment | Notes |
|---------|-------------|-------|
| Constructor | `dev` | Editing/preview mode |
| Stage preview | `stage` | Pre-production review |
| Public runtime | `production` | Published tour playback |
Navigation links pointing to slugs that don't exist in the current environment will fail silently.
## Known Considerations
### 1. Slug Uniqueness
Slugs must be unique within project+environment. The system validates this on page creation/update.
### 2. Missing Target Pages
If `targetPageSlug` references a non-existent page in the current environment, navigation silently fails. UI should handle gracefully.
### 3. Transition Reverse Support
Not all transitions look good in reverse. Use `transitionReverseMode: 'separate_video'` with a dedicated `reverseVideoUrl` for directional animations, or omit reverse mode entirely.
### 4. Preloading
Navigation elements drive preloading. Transition videos have highest priority (+150) and are preloaded first. The neighbor graph uses `maxDepth: 1` (immediate neighbors only).
### 5. Instant Navigation with Preloaded Assets
When assets are preloaded, `usePageSwitch` uses `getReadyBlobUrl(storageKey)` for O(1) instant lookup of pre-decoded blob URLs, eliminating any delay or flash during navigation. Lookups prioritize storage keys (e.g., `assets/project/bg.jpg`) over resolved URLs because storage keys are canonical and don't change when presigned URLs are regenerated.

View File

@ -0,0 +1,941 @@
# Page Transitions Feature
Documentation for the Tour Builder Platform's page transition system using video-based animations stored directly on navigation elements.
## Overview
The platform implements video-based page transitions that are configured directly on navigation elements in `tour_pages.ui_schema_json`. The system supports forward and **server-side pre-generated reversed** playback with intelligent preloading.
```
┌─────────────────────────────────────────────────────────────────┐
│ Transition Architecture │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Data Layer │ │
│ │ tour_pages.ui_schema_json → elements[].transitionVideoUrl │ │
│ │ asset_variants.variant_type = 'reversed' │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Server Processing Layer │ │
│ │ TourPagesService → videoProcessing.ts (FFmpeg reversal) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Execution Layer │ │
│ │ Runtime Navigation │ Transition Overlay │ useTransitionPlayback│
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Preloading Layer │ │
│ │ usePreloadOrchestrator │ usePageSwitch │ useNeighborGraph │ │
│ │ getReadyBlobUrl │ S3 Presigned URLs │ Cache API │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Reverse Video Architecture
### Server-Side Pre-Generated Reversed Variants
The platform uses **server-side pre-computation** of reversed videos stored as separate asset variants. This approach was chosen over client-side frame-stepping for:
- **Instant playback** - No client-side processing needed
- **Device independence** - Works equally well on all devices
- **Professional quality** - FFmpeg ensures perfect audio/video synchronization
- **Reliability** - Pre-generated videos eliminate runtime failures
### Reverse Generation Flow
```
Page Save Event (create/update)
TourPagesService.update() / create()
processReversedVideosAndUpdateSchema()
└─ For each navigation element with transitionVideoUrl:
getOrGenerateReversedVariant()
├─ Check if reversed variant exists in asset_variants
└─ If not:
generateReversedVariant()
├─ Download original video (downloadToBuffer)
├─ Reverse with FFmpeg (videoProcessing.reverseVideo)
├─ Upload reversed variant (uploadBuffer)
└─ Create asset_variants record with type='reversed'
Update ui_schema_json with reverseVideoUrl
Save page with updated URLs
```
### Back Navigation Behavior
Back navigation **always uses history mode** - when the user clicks a back button, they return to the previous page using the original forward transition played in reverse.
**How it works:**
1. User navigates forward from Page A to Page B (forward transition plays)
2. User clicks back button on Page B
3. System finds the forward navigation element that brought the user from Page A
4. The reversed video from that forward element plays
5. User returns to Page A
**Benefits:**
- Simpler UX - back button works like browser back
- No configuration needed - back navigation is automatic
- Consistent behavior - same transition forward and back
## Data Model
### Transition Configuration in Elements
Transition settings are stored directly on navigation elements:
```typescript
// In tour_pages.ui_schema_json.elements
{
id: "nav-button-1",
type: "navigation_next",
label: "Next Page",
// Navigation target
targetPageSlug: "gallery-page",
// Transition configuration
transitionVideoUrl: "assets/project-xyz/transitions/zoom-in.mp4",
transitionDurationSec: 2.5,
transitionReverseMode: "auto_reverse", // or "separate_video"
reverseVideoUrl: undefined, // Auto-populated by server on save
// Position & styling
xPercent: 90,
yPercent: 85,
iconUrl: "assets/icons/arrow.png"
}
```
### Key Transition Fields
| Field | Type | Description |
|-------|------|-------------|
| `transitionVideoUrl` | string | URL to transition video asset |
| `transitionDurationSec` | number | Duration in seconds (auto-detected from video metadata) |
| `transitionReverseMode` | `'auto_reverse'` \| `'separate_video'` | How back navigation transitions should play |
| `reverseVideoUrl` | string | **Auto-generated** reversed video URL (or manually uploaded for `separate_video` mode) |
**Reverse Mode Options:**
| Mode | Behavior |
|------|----------|
| `auto_reverse` | Server generates reversed video on page save |
| `separate_video` | Uses manually uploaded `reverseVideoUrl` for back navigation |
| _(omitted)_ | No transition on back navigation |
### Database Schema
**asset_variants table** (stores reversed videos):
```sql
-- Variant types enum includes 'reversed'
ALTER TYPE enum_asset_variants_variant_type ADD VALUE 'reversed';
-- Columns for reversed variants
assetId: UUID -- Parent asset reference
variant_type: 'reversed'
cdn_url: TEXT -- Public URL (S3/GCloud/local)
storage_key: TEXT -- Private storage path
size_mb: DECIMAL -- File size
```
**Storage path pattern:** `assets/${assetId}/reversed.mp4`
### Benefits of Inline Storage
1. **Simpler data model** - No separate transitions table to manage
2. **No ID remapping** - Videos are referenced by URL, not foreign key
3. **Per-element transitions** - Different navigation buttons can have different transitions
4. **Easy publishing** - Transitions copy with the page, no extra steps needed
5. **Automatic reversal** - Server generates reversed videos on page save
## Server Requirements
### FFmpeg Runtime
FFmpeg is **required** for reversed video generation. Without it, back
navigation transitions won't have pre-generated reversed videos.
The project uses bundled FFmpeg binaries through the backend npm packages
`ffmpeg-static` and `ffprobe-static`. Manual OS-level installation is not
required for the standard setup.
**Verify bundled runtime from the backend context:**
```bash
cd backend
node -e "console.log(require('ffmpeg-static')); console.log(require('ffprobe-static').path)"
```
### VM Memory Risk
Reverse generation can be memory-heavy. On the standard VM, a June 2026
incident was caused by the kernel OOM-killing an `ffmpeg` child process that
used about 3.3 GiB RSS on a 3.8 GiB RAM VM. Because the child process belonged
to the PM2 systemd unit, PM2 stopped the frontend, backend, executor, and
telemetry processes, causing Apache to return `503 Service Unavailable`.
See [deployment-vm.md](deployment-vm.md) for the VM recovery runbook.
Implemented resource controls:
1. `videoProcessing.reverseVideo()` uses an in-process single-worker queue, so
only one FFmpeg reversal runs at a time. Additional requests wait for the
previous job to finish.
2. FFmpeg reversal uses `-threads 1` to reduce CPU and memory pressure.
3. FFmpeg reversal has a hard timeout (`FFMPEG_REVERSE_TIMEOUT_MS`, default
`600000`, exposed as `config.resilience.ffmpeg.reverseTimeoutMs`) and kills
the child process when the timeout is reached.
4. FFmpeg reversal is protected by an in-process circuit breaker
(`FFMPEG_BREAKER_FAILURE_THRESHOLD`, `FFMPEG_BREAKER_COOLDOWN_MS`,
`FFMPEG_BREAKER_SUCCESS_THRESHOLD`, exposed under
`config.resilience.ffmpeg.breaker`) so repeated failures stop new reversal
jobs during the cooldown window.
5. FFprobe metadata extraction has a timeout (`FFPROBE_TIMEOUT_MS`, default
`30000`, exposed as `config.resilience.ffmpeg.ffprobeTimeoutMs`), and
reverse-video logs include input/output byte sizes plus probed media
metadata.
6. External file storage calls used by reversal download/upload paths are
protected by the shared file-storage circuit breaker for S3/GCloud providers.
7. `TourPagesService` still deduplicates generation by transition video
`storageKey`, so repeated requests for the same source video share the same
generation promise.
8. Before enqueueing auto-reverse generation, `TourPagesService` validates the
source asset in one place. It rejects transition videos larger than `16 GiB`
unless a reversed variant already exists, and it also rejects videos whose
stored `width_px`, `height_px`, `duration_sec`, and `frame_rate` imply too
much decoded frame data for the VM. Asset `frame_rate` is now probed on the
backend with bundled `ffprobe-static` during asset create/update. For older
assets without persisted `frame_rate`, the validation path probes the stored
file on demand and only falls back to a conservative `30 FPS` estimate if
probing fails.
9. The page save returns a validation error so the constructor can show an
explicit user-facing notification instead of silently starting a risky
background job.
Additional hardening still recommended:
1. Reject or downscale very large transition videos before reversal.
2. Consider running media processing in a separate worker with memory limits.
## Backend Implementation
### Video Processing Service
**File:** `backend/src/services/videoProcessing.ts`
FFmpeg-based video reversal:
```typescript
import { isFFmpegAvailable, reverseVideo } from './videoProcessing.ts';
// Core function: reverseVideo(inputBuffer, filename) → reversedBuffer
// Processing pipeline:
// 1. Create temporary directory for FFmpeg operations
// 2. Write input buffer to temp file
// 3. Probe input media metadata with a timeout
// 4. Execute FFmpeg behind the single-worker queue and circuit breaker:
// - -vf reverse (video reversal)
// - -af areverse (audio reversal)
// - -c:v libx264 (H.264 encoding)
// - -preset fast (performance preset)
// - -crf 23 (compression quality)
// - -c:a aac (audio encoding)
// - -threads 1 (resource cap)
// - kill FFmpeg if FFMPEG_REVERSE_TIMEOUT_MS is exceeded
// 5. Probe output media metadata and log input/output sizes
// 6. Read output buffer
// 7. Clean up temporary files
```
### Tour Pages Service Integration
**File:** `backend/src/services/tour_pages.ts`
Key functions:
- `processReversedVideosAndUpdateSchema()` - Main orchestrator for reverse generation
- `getOrGenerateReversedVariant()` - Check/generate reversed variant
- `generateReversedVariant()` - Download → Reverse → Upload → Record
- `regenerateProjectReversedVideos()` - Project-wide generation for missing reversed videos
**Generation Pattern:**
- Reversed videos are always generated for all navigation elements with transitions
- Generated on-demand when page is saved (create/update)
- Checked before generation to avoid duplication
- Different transition videos are processed sequentially through the global
FFmpeg queue; the backend does not run multiple FFmpeg reversals in parallel
- Background processing keeps save requests fast
### File Service Integration
**File:** `backend/src/services/file.ts`
Key functions used for reverse generation:
- `downloadToBuffer(privateUrl)` - Downloads file from storage provider to Buffer
- `uploadBuffer(privateUrl, buffer, options)` - Uploads buffer to storage
## Frontend Types
**File:** `frontend/src/types/constructor.ts`
```typescript
interface CanvasElement {
id: string;
type: CanvasElementType; // 'navigation_next' | 'navigation_prev' | ...
label: string;
// Navigation
targetPageSlug?: string;
/** @deprecated Use targetPageSlug instead */
targetPageId?: string;
// Transition
transitionVideoUrl?: string;
transitionDurationSec?: number;
transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string; // Auto-generated or manually uploaded
// ... other fields
}
```
**File:** `frontend/src/types/presentation.ts`
```typescript
// Shared presentation types for RuntimePresentation and constructor.tsx
// Canvas element with navigation properties (for click handling)
interface NavigableElement {
id: string;
type: string;
targetPageSlug?: string;
targetPageId?: string;
transitionVideoUrl?: string;
reverseVideoUrl?: string;
navType?: 'forward' | 'back';
navDisabled?: boolean;
}
// Navigation target resolved from element click
interface NavigationTarget {
page: RuntimePage;
pageId: string;
transitionVideoUrl?: string;
reverseVideoUrl?: string;
isBack: boolean;
}
// Transition phase (exported for navigation helpers)
type TransitionPhase = 'idle' | 'preparing' | 'playing' | 'finishing' | 'completed';
```
**File:** `frontend/src/hooks/useTransitionPlayback.ts`
```typescript
// ReverseMode for transition playback (runtime)
type ReverseMode = 'none' | 'separate'; // 'reverse' removed - now uses pre-generated videos
interface TransitionConfig {
videoUrl: string;
storageKey?: string; // Raw storage path for cache lookup
reverseMode: ReverseMode;
reverseVideoUrl?: string; // Pre-generated reversed video URL
reverseStorageKey?: string; // Storage key for reversed video
durationSec?: number;
targetPageId?: string;
displayName?: string;
isBack?: boolean; // Track navigation direction
}
// Internal playback phases
type PlaybackPhase = 'idle' | 'preparing' | 'playing' | 'finishing' | 'completed';
```
**Mapping between constructor and runtime modes:**
| Constructor (`transitionReverseMode`) | Runtime (`ReverseMode`) |
|---------------------------------------|-------------------------|
| `'auto_reverse'` | `'separate'` (uses pre-generated `reverseVideoUrl`) |
| `'separate_video'` | `'separate'` |
| _(omitted)_ | `'none'` |
## Runtime Execution
### Navigation Flow
**File:** `frontend/src/components/RuntimePresentation.tsx`
```
┌──────────────────────────────────────────────────────────────┐
│ 1. User clicks navigation element │
│ └── handleElementClick(element) │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 2. Resolve target page from slug │
│ └── pages.find(p => p.slug === element.targetPageSlug) │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 3. Check for transition video │
│ └── element.transitionVideoUrl exists? │
└──────────────────────────────────────────────────────────────┘
┌───────────────────┴───────────────────┐
│ Yes │ No
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ 4a. Set transition │ │ 4b. Direct navigate │
│ state with correct │ │ to target page │
│ video URL │ │ │
│ │ │ setSelectedPageId() │
│ isBack? │ └──────────────────────┘
│ ├─ true: use │
│ │ reverseVideoUrl │
│ └─ false: use │
│ transitionVideoUrl│
└──────────┬───────────┘
┌──────────────────────────────────────────────────────────────┐
│ 5. Render full-screen video overlay │
│ │
│ Forward: video.play() with transitionVideoUrl │
│ Back: video.play() with reverseVideoUrl (pre-generated) │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 6. Video ends (or fallback timeout fires) │
│ └── finishOverlayTransition() │
│ └── applyPageSelection(targetPageId) │
│ └── Clear overlay state │
└──────────────────────────────────────────────────────────────┘
```
### Source URL Selection
**File:** `frontend/src/hooks/useTransitionPlayback.ts`
```typescript
const sourceUrl = useMemo(() => {
if (!transition) return '';
// Use reversed video if back navigation with separate reversed video
if (transition.isBack && transition.reverseVideoUrl) {
return transition.reverseVideoUrl;
}
return transition.videoUrl;
}, [transition]);
```
**Key Characteristics:**
- No frame-stepping computation needed
- Simple conditional: if back navigation AND reversed URL exists → use reversed video
- Direct playback from downloaded buffer
### Transition Preview Hook
**File:** `frontend/src/hooks/useTransitionPreview.ts`
```typescript
// Validation before preview
if (direction === 'back' && !element.reverseVideoUrl) {
onError?.('Reversed video not available. Save the page to generate it.');
return;
}
// Preview state structure
{
videoUrl: element.transitionVideoUrl,
reverseMode: direction === 'back' ? 'separate' : 'none',
reverseVideoUrl: element.reverseVideoUrl, // Pre-reversed video URL
reverseStorageKey: element.reverseVideoUrl, // Storage path for caching
isBack: direction === 'back' // Track navigation direction
}
```
### Overlay Rendering
**File:** `frontend/src/components/Constructor/TransitionPreviewOverlay.tsx`
The `TransitionPreviewOverlay` component renders transition videos within the letterboxed canvas bounds:
```tsx
<TransitionPreviewOverlay
videoRef={transitionVideoRef}
isActive={Boolean(transitionPreview)}
isBuffering={transitionPhase === 'preparing' || isBuffering}
letterboxStyles={letterboxStyles} // From useCanvasScale hook
opacity={1} // Always 1 - no fade-out for video transitions
/>
```
**Component Props:**
| Prop | Type | Description |
|------|------|-------------|
| `videoRef` | `RefObject<HTMLVideoElement>` | Reference managed by `useTransitionPlayback` |
| `isActive` | `boolean` | Whether overlay is visible |
| `isBuffering` | `boolean` | **Hides entire container** while buffering (prevents black flash) |
| `letterboxStyles` | `CSSProperties` | Position/size from `useCanvasScale` |
| `videoFit` | `'contain'` \| `'cover'` | Object-fit mode (default: `'contain'`) |
| `opacity` | `number` | Container opacity (default: 1) |
**Video Transition Flow (No Fades):**
```
1. Click → isBuffering=true → container opacity=0 (old page visible)
2. Video ready → isBuffering=false → container opacity=1 (video first frame)
3. Video plays → last frame = new page background
4. onComplete → setTransitionPreview(null) → instant overlay removal
```
**Key Design Decision:**
- Video transitions do NOT use fade effects
- Video itself IS the transition (first frame = old page, last frame = new page)
- Overlay removed instantly when new background is ready
- This prevents any visual discontinuity
### Navigation Helpers
**File:** `frontend/src/lib/navigationHelpers.ts`
Shared utilities for page navigation:
```typescript
import {
resolveNavigationTarget,
isBackNavigation,
getNavigationDirection,
isTransitionBlocking,
hasPlayableTransition,
isNavigationType,
} from '../lib/navigationHelpers';
```
| Function | Purpose |
|----------|---------|
| `resolveNavigationTarget(element, pages, context)` | Resolve target page from element. Back navigation always uses history mode |
| `isBackNavigation(element)` | Check if element navigates backwards |
| `getNavigationDirection(element)` | Get navigation direction as `'back'` or `'forward'` |
| `isTransitionBlocking(transitionPhase)` | Check if transition is blocking navigation |
| `hasPlayableTransition(element, direction)` | Check if element has playable transition |
| `isNavigationType(elementType)` | Check if element type is a navigation type |
| `resolveHistoryBackTarget(pages, currentSlug, previousPageId)` | Resolve back target from navigation history |
| `findIncomingNavigationElement(pages, fromPageId, toPageId)` | Find forward element that links pages |
**Playable Transition Check:**
```typescript
const hasPlayableTransition = (
element: {
transitionVideoUrl?: string;
transitionReverseMode?: string;
reverseVideoUrl?: string;
},
direction: 'back' | 'forward' = 'forward',
): boolean => {
if (!element.transitionVideoUrl) return false;
// For back navigation, need reverse video (auto-generated or separate)
if (direction === 'back' && !element.reverseVideoUrl) {
return false;
}
return true;
};
```
## Constructor Configuration
### Setting Up Transitions
**File:** `frontend/src/pages/constructor.tsx`
When editing a navigation element:
1. Select element type (`navigation_next` or `navigation_prev`)
2. Choose target page by slug
3. Select transition video from project assets
4. Duration is auto-detected from video metadata
5. Choose reverse mode for back navigation
6. **Save the page** to trigger reverse video generation
```typescript
// Saving navigation element with transition
const elementData = {
type: selectedElementType,
targetPageSlug: targetPage?.slug,
transitionVideoUrl: selectedTransitionVideo?.cdn_url,
transitionDurationSec: transitionDuration,
transitionReverseMode: reverseMode, // 'auto_reverse' | 'separate_video' | undefined
reverseVideoUrl: reverseMode === 'separate_video' ? reverseVideo?.cdn_url : undefined,
// Note: reverseVideoUrl is auto-populated for 'auto_reverse' mode on save
};
```
**Reverse Mode UI Options:**
| UI Option | `transitionReverseMode` | `reverseVideoUrl` |
|-----------|-------------------------|-------------------|
| "Auto Reverse" | `'auto_reverse'` | **Auto-generated on save** |
| "Separate Video" | `'separate_video'` | User-uploaded video |
| "No Reverse" | _(not set)_ | _(not set)_ |
### When to Enable Reverse
| Video Type | `transitionReverseMode` | Reason |
|------------|-------------------------|--------|
| Zoom in/out | `'auto_reverse'` | Symmetrical animation |
| Slide left/right | `'auto_reverse'` | Direction can reverse |
| Fade in/out | `'auto_reverse'` | Works both directions |
| Text animation | `'separate_video'` | Use dedicated reverse video |
| One-way motion | _(omit)_ | Only makes sense forward |
| Complex animation | `'separate_video'` | Pre-rendered reverse looks better |
## Preloading Integration
### Transition Video Preloading
**File:** `frontend/src/lib/extractPageLinks.ts`
Transition videos (including reversed) are extracted from navigation elements:
```typescript
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
// Extract navigation links (includes transition and reverse videos)
const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages);
// pageLinks contains transition information:
// { from_pageId, to_pageId, transition: { video_url, reverse_video_url } }
```
### Priority Calculation
Transition videos have the **highest priority** (+150):
```
Transition Priority = neighborBase + assetTypeBonus
neighborBase: 1000 (current page) or 500 (neighbor)
assetTypeBonus: 150 (transition - highest priority)
Examples:
- Current page transition: 1000 + 150 = 1150
- Neighbor (depth 1) transition: 500 + 150 = 650
```
**Asset Type Priorities:**
| Type | Priority | Reason |
|------|----------|--------|
| Transition | +150 | Needed immediately on click |
| Image | +100 | Required for page display |
| Audio | +50 | Background audio |
| Video | +30 | Can stream progressively |
### Sophisticated Source Resolution Pipeline
**File:** `frontend/src/hooks/useTransitionPlayback.ts`
```typescript
// Source resolution order:
1. Try storage key ready blob URL (pre-downloaded)
2. Try storage key cached blob URL (Cache API)
3. Reuse cached blob URL from previous playback
4. Try ready blob URL by resolved CDN URL
5. Try cached blob URL by resolved CDN URL
6. Fetch video as blob with presigned URL or proxy fallback
```
**Storage Key Usage:**
- `storageKey` and `reverseStorageKey` used for cache lookup
- Allows offline access via IndexedDB
- Distinguishes between original and reversed video caches
## Non-Transition Navigation (CSS Transitions)
When navigating without a video transition, the system uses CSS-based transitions with settings resolved through a cascade.
### Settings Cascade Resolution
Transition settings (type, duration, easing, overlay color) are resolved through a three-level cascade:
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Transition Settings Cascade │
│ │
│ 1. Element Level (highest priority) │
│ └── ui_schema_json.elements[].transitionSettings │
│ ↓ fallback │
│ 2. Project Level (per environment) │
│ └── project_transition_settings WHERE projectId AND environment │
│ ↓ fallback │
│ 3. Global Level (platform-wide defaults) │
│ └── global_transition_defaults (single record) │
│ ↓ fallback │
│ 4. Hardcoded Fallback │
│ └── type: 'fade', durationMs: 700, easing: 'ease-in-out' │
└─────────────────────────────────────────────────────────────────────────┘
```
**Hook:** `useTransitionSettings` resolves final settings:
```typescript
const transitionSettings = useTransitionSettings({
globalDefaults, // From global_transition_defaults table
projectSettings, // From project_transition_settings (environment-specific)
elementSettings, // From currentElementTransitionSettings state
});
// Returns: { type, durationMs, easing, overlayColor }
```
**Extracting element settings:** Use `extractElementTransitionSettings()` to convert element fields:
```typescript
import { extractElementTransitionSettings } from '../types/transition';
// Extract from clicked navigation element
const elementSettings = extractElementTransitionSettings(clickedElement);
// Returns: { transitionType?, transitionDurationMs?, transitionEasing?, transitionOverlayColor? }
```
The function only includes fields with actual values (not empty strings), allowing cascade fallthrough when element uses "Use Project Default".
**Environment-Aware Project Settings:**
Project transition settings are stored per-environment and copied during publishing:
- **Constructor** (dev): Edits `project_transition_settings WHERE environment='dev'`
- **Save to Stage**: Copies dev settings → stage
- **Publish**: Copies stage settings → production
See [project-transition-settings.md](./project-transition-settings.md) for full documentation.
### CSS Transition Implementation
**CSS Variables (main.css) - Single Source of Truth:**
```css
:root {
--crossfade-duration: 700ms;
/* Smooth easing: slow start, gentle acceleration, soft landing */
--crossfade-easing: cubic-bezier(0.4, 0, 0.2, 1);
}
```
**CSS Animations (main.css):**
```css
@keyframes page-crossfade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes page-crossfade-out {
from { opacity: 1; }
to { opacity: 0; }
}
.animate-crossfade-in {
animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
}
.animate-crossfade-out {
animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
}
```
**Easing Curve Characteristics:**
- `cubic-bezier(0.4, 0, 0.2, 1)` - Material Design standard easing
- Slow start - prevents abrupt appearance
- Gentle acceleration through middle
- Soft landing at end
### CSS Transition vs Video Transition
| Navigation Type | Effect | Duration Control | Settings Source |
|-----------------|--------|------------------|-----------------|
| With transition video | Video overlay plays, instant removal | Video duration | Element-level only |
| Without transition video | CSS fade animation | Cascade resolution | Element → Project → Global |
## File References
| File | Purpose |
|------|---------|
| `backend/src/services/videoProcessing.ts` | FFmpeg video reversal service |
| `backend/src/services/tour_pages.ts` | Reverse video generation orchestration |
| `backend/src/services/file.ts` | Download/upload buffer operations |
| `backend/src/services/project_transition_settings.ts` | Project transition settings service |
| `backend/src/db/api/asset_variants.ts` | Reversed variant database operations |
| `backend/src/db/api/project_transition_settings.ts` | Project transition settings API |
| `backend/src/db/models/asset_variants.js` | Asset variants model (includes 'reversed' type) |
| `backend/src/db/models/project_transition_settings.js` | Project transition settings model |
| `backend/src/db/migrations/20260413091125-add-reversed-variant-type.js` | Migration for reversed variant support |
| `backend/src/db/migrations/20260501000002-create-project-transition-settings.js` | Project transition settings table |
| `frontend/src/css/main.css` | CSS animation keyframes and classes |
| `frontend/src/pages/constructor.tsx` | Transition video selection UI |
| `frontend/src/components/RuntimePresentation.tsx` | Transition overlay playback and navigation |
| `frontend/src/components/TourFlowManager.tsx` | Project transition settings UI |
| `frontend/src/components/Constructor/TransitionPreviewOverlay.tsx` | Canvas-aware transition video overlay |
| `frontend/src/lib/extractPageLinks.ts` | Extract transition videos from navigation elements |
| `frontend/src/lib/navigationHelpers.ts` | Navigation target resolution, reverse detection |
| `frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts` | Redux store for transition settings |
| `frontend/src/hooks/usePreloadOrchestrator.ts` | Preloading with ready blob URLs |
| `frontend/src/hooks/usePageSwitch.ts` | Page navigation using preloaded transitions |
| `frontend/src/hooks/useTransitionPlayback.ts` | Transition video playback coordination |
| `frontend/src/hooks/useTransitionPreview.ts` | Transition preview with reverse validation |
| `frontend/src/hooks/useTransitionSettings.ts` | Cascade resolution for transition settings |
| `frontend/src/hooks/useBackgroundTransition.ts` | Background fade-out coordination |
| `frontend/src/hooks/useNeighborGraph.ts` | Navigation graph for preload prioritization |
| `frontend/src/types/constructor.ts` | Element type definitions |
| `frontend/src/types/transition.ts` | Transition settings types |
| `frontend/src/types/presentation.ts` | Runtime navigation types |
| `frontend/src/config/preload.config.ts` | Preload priority weights |
## Error Handling
### Video Load Failures
```typescript
videoRef.current.onerror = (e) => {
console.error('Transition video failed to load:', e);
// Fallback: complete navigation without video
finishOverlayTransition();
};
```
### Missing Reversed Video
```typescript
// In useTransitionPreview
if (direction === 'back' && !element.reverseVideoUrl) {
onError?.('Reversed video not available. Save the page to generate it.');
return;
}
```
### FFmpeg Unavailable
```typescript
// In videoProcessing.ts
if (!isFFmpegAvailable()) {
logger.warn('FFmpeg not available, skipping reverse video generation');
return null;
}
```
## Environment Handling
Transitions are environment-aware:
```typescript
// Pages are filtered by environment before extraction
const filteredPages = pages.filter(p => p.environment === environment);
// Transitions only work between pages in the same environment
const { pageLinks } = extractPageLinksAndElements(filteredPages);
```
| Environment | Context | Access |
|-------------|---------|--------|
| `dev` | Constructor editing | Authenticated |
| `stage` | Preview/review | Authenticated |
| `production` | Public runtime | Public |
**Publishing:** When pages are published (dev → stage → production), transitions and reversed videos are copied as part of `ui_schema_json`. Since transitions reference video URLs (not IDs), no remapping is needed.
## Performance Considerations
### 1. Server-Side Generation Benefits
| Aspect | Client-Side (Old) | Server-Side (New) |
|--------|-------------------|-------------------|
| Computation | During playback | At page save time |
| Device Performance | Device-dependent | Instant playback |
| Audio Sync | Manual filtering | FFmpeg perfect sync |
| Memory Usage | High during playback | None (pre-generated) |
### 2. Video Format
Use optimized video formats:
- MP4 with H.264 for broad compatibility
- WebM with VP9 for better compression
- Keep transitions short (0.5-3 seconds)
### 3. Caching Strategy
| Layer | Purpose |
|-------|---------|
| Cache API (< 5MB) | Fast asset storage |
| IndexedDB (≥ 5MB) | Large assets, offline data |
| Blob URLs | Pre-decoded for instant display |
## Troubleshooting
### Transition Doesn't Play
1. Check `transitionVideoUrl` is valid
2. Verify video is in supported format
3. Check browser console for CORS errors
4. Ensure video is preloaded (check Network tab)
### Reverse Video Not Available
1. **Save the page** - reverse videos are generated on page save
2. Check server logs for FFmpeg errors
3. Verify bundled FFmpeg paths resolve from the backend process
4. Check `asset_variants` table for `variant_type='reversed'` records
5. On the VM, check kernel logs for OOM-killed `ffmpeg` processes
### Back Navigation Has No Transition
1. Verify `transitionReverseMode` is set (`'auto_reverse'` or `'separate_video'`)
2. For `'auto_reverse'`: save the page to trigger generation
3. For `'separate_video'`: ensure `reverseVideoUrl` is set
4. Check `hasPlayableTransition(element, 'back')` returns true
### Navigation Gets Stuck
1. Check fallback timeout is firing
2. Verify `onEnded` event triggers
3. Look for JavaScript errors
4. Check transition state clears properly
### Element Settings Not Applied (Wrong Duration/Easing)
**Symptom:** Element-level transition settings (duration, easing, overlay color) are ignored; transition uses project or global defaults instead.
**Cause:** React's async state batching. When clicking a navigation button:
1. `setCurrentElementTransitionSettings()` schedules state update
2. `switchToPage()` starts transition immediately
3. `useTransitionSettings` resolves with OLD state (before React processes step 1)
**Solution:** Use `flushSync` from `react-dom` to force synchronous state updates:
```typescript
import { flushSync } from 'react-dom';
import { extractElementTransitionSettings } from '../types/transition';
// In handleElementClick:
const elementSettings = extractElementTransitionSettings(clickedElement);
// Force synchronous update BEFORE navigation
flushSync(() => {
setCurrentElementTransitionSettings(elementSettings);
});
// Now transition uses correct element settings
switchToPage(targetPageId, config);
```
**Files requiring this fix:**
- `frontend/src/pages/constructor.tsx`
- `frontend/src/components/RuntimePresentation.tsx`
**Debug tip:** Add console.log inside `useTransitionSettings` to verify element settings are received:
```typescript
console.log('[useTransitionSettings] element:', elementSettings);
```
If element shows as `null` when it shouldn't, the flushSync fix is missing.

View File

@ -0,0 +1,143 @@
# Private Production Presentations
Production presentations are public by default at `/p/[slug]`. Each project can
switch its production presentation to private with the project-level
`production_presentation_visibility` field.
## Project Visibility
**Storage:** `projects.production_presentation_visibility`
Values:
- `public` - default; anonymous users can open `/p/[slug]`
- `private` - anonymous users must log in; access is then checked by staff
permissions or customer grants
The project create/edit forms expose this as **Production Presentation
Visibility**. Existing projects stay public after migration for backward
compatibility.
## Customer Access Grants
**Storage:** `production_presentation_access`
This table grants a customer user access to a private production presentation:
- `projectId`
- `userId`
- unique active pair: `(projectId, userId)`
User lifecycle remains in the existing users system. Presentation access is
stored in DB rows, not config files.
## Runtime Access Flow
**Services:**
- `backend/src/services/runtime-presentation-access.ts` handles slug lookup and
private-presentation listing.
- `backend/src/services/access-policy.ts` owns the final access decision through
`canViewProductionPresentation(user, projectSlug)`.
The service determines:
- whether the project slug points to a private production presentation
- whether an authenticated user can use admin APIs (`Public` users are never
treated as staff, even if stale permissions exist)
- whether a customer user has a `production_presentation_access` row
- which private production slugs are allowed for the current customer user
For public production slugs, read-only runtime requests stay public.
For private production slugs:
- anonymous `GET` requests return `401`
- authenticated users with any RBAC permission can access all private
production presentations as platform staff
- authenticated users with `Public` role and no permissions must have an access
row for the project
- authenticated but unauthorized users receive `403`
- authorized users are marked as `req.isRuntimePublicRequest = true` so runtime
routes return only runtime-safe entity fields
Protected runtime data includes:
- `/api/projects`
- `/api/tour_pages`
- `/api/project_audio_tracks`
- `/api/project-transition-settings/project/:projectId/env/production`
## User Creation And Edit Flow
`Administrator`, `Platform Owner`, and `Account Manager` can create users and
edit existing users.
When the selected role is `Public`, the user form shows **Allowed
Private Production Presentations**. The selector lists only projects whose
production presentation visibility is `private`.
On create submit:
- the user is created normally
- if the selected role is `Public`, selected private project IDs create
`production_presentation_access` rows
- if the selected role is not `Public`, selected project IDs are ignored
On edit submit:
- existing `production_presentation_access` rows for that user are replaced by
the selected private project IDs
- switching a user away from `Public` clears their private presentation grants
- loading a Public user edit form preselects the user's current private
presentation grants
Do not grant customer viewer users broad app permissions such as
`READ_PROJECTS` or `READ_TOUR_PAGES`; any RBAC permission makes the user
platform staff for private presentation access.
Backend hardening also rejects custom permissions for users whose role is
`Public`, rejects permissions assigned to the `Public` role through the Roles
service, and ignores `Public` user or role-fallback permissions in
`AccessPolicy` for admin APIs.
## Audit And Cleanup
Use the backend audit command to detect stale access records from older data:
```bash
cd backend
npm run check:public-access
```
It reports:
- RBAC permissions assigned to roles named `Public`
- custom permissions assigned to Public users
- private production presentation grants assigned to non-Public users
To clean those stale records after reviewing the output:
```bash
npm run fix:public-access
```
The fix removes only the stale Public-role permissions, Public-user custom
permissions, and non-Public private presentation grants.
## Frontend Behavior
If runtime data loading receives `401`, `/p/[slug]` redirects to:
```text
/login?next=/p/[slug]
```
After login, `login.tsx` redirects to `next` when it is a safe local path. If a
Public customer user reaches authenticated application pages,
`LayoutAuthenticated` redirects to the first private production slug returned by
`/api/auth/me`; if the Public user has no private presentation grants, it
redirects to `/`.
Public production presentations remain accessible to anonymous users, platform
staff, and logged-in customer users.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,182 @@
# TODO по улучшению проекта
Дата исследования: 2026-06-28
Цель: строгий, но не enterprise-heavy backlog для небольшого production-проекта. Система должна стать более предсказуемой: явные boundaries, единые contracts, единая модель доступа, единый подход к server state и API validation. Улучшения выполняются маленькими PR без большого rewrite.
## Принципы
- Сначала фиксируем текущее поведение тестами/чеклистами, потом рефакторим.
- Для нового кода целевые правила обязательны; старый код приводится к ним постепенно.
- Не добавляем тяжёлую инфраструктуру "на будущее", но добавляем строгие boundaries там, где уже есть production-risk.
- Для risky changes используем маленькие PR и manual verification.
- Документацию в `documentation/` обновляем только когда реально меняется API, schema, workflow или deployment.
## Целевые правила архитектуры
Backend:
- Route/controller layer: только auth/context, validation, вызов service, response mapping.
- Service/domain layer: business logic, permissions decisions, transactions.
- DB API/repository layer: только data access, filters, includes, pagination.
- Policy layer: вся логика доступа в одном месте, не в отдельных routes.
- Validation layer: все внешние body/query/params проходят schema validation.
Frontend:
- Redux: только client/app state.
- TanStack Query: server state, lists/details/mutations/cache invalidation.
- Feature code: новая feature-specific логика живёт рядом с feature, а не размазывается по generic folders.
- `any`, type assertions/casts, disabled hook rules и direct axios calls в feature components не являются целевым стандартом.
## P1 - Frontend
### Разделить самые большие файлы по строгим boundaries
Boundaries должны быть строгими.
TODO:
- `constructor.tsx`: выносить pure helpers, feature hooks и state reducers в отдельные файлы.
- `RuntimePresentation.tsx`: выносить navigation/media/preload helpers и state logic в отдельные files/hooks.
- Feature-specific файлы класть рядом с feature, а не в generic `components`.
### Redux и TanStack Query
Redux не нужно удалять полностью.
Оставить Redux для:
- auth/session UI;
- theme/style;
- layout/sidebar;
- constructor UI state;
- app preferences.
Целевое правило: server data не хранится в Redux. Existing entity Redux slices считаются legacy и заменяются на TanStack Query по одному flow.
TODO:
- Зафиксировать правило: Redux для client/app state, TanStack Query для server state.
- Не создавать новые entity CRUD Redux slices.
- Пилотно мигрировать один простой entity flow (`roles` или `permissions`) на TanStack Query.
- Не удалять старый Redux slice, пока все consumers entity не мигрированы.
- API reads/mutations делать через TanStack Query hooks, не через Redux thunks.
### Frontend TypeScript strictness
Цель: включить strict TypeScript как обязательный стандарт для frontend и закрыть legacy errors без сохранения JS/CommonJS как допустимого направления. Новый и изменённый код должен быть strict-compatible.
TODO:
- Посчитать текущий frontend typecheck baseline: сколько ошибок даёт `strict: true`.
- Включать strictness по шагам: сначала отдельный strict config для selected folders/new code, затем общий `strict: true`, когда baseline закрыт.
- Для новых/изменённых файлов запрещать новый `any` без явной причины.
- Запретить новые type assertions/casts (`as`, angle-bracket assertions, non-null `!`) в feature code. Исключения: validated external boundaries, DOM/library interop, discriminated unions после guard; исключение должно быть локальным и объяснённым.
- Вернуть `no-unused-vars`/unused imports как warning, затем поднять до error после cleanup.
- Добавить frontend `typecheck` script и включить его в minimal checks, когда baseline проходит.
- `react-hooks/exhaustive-deps` включать file-by-file после исправления конкретных hooks.
### Auth storage
Сейчас token пишется и в `sessionStorage`, и в `localStorage`.
TODO:
- Решить, нужен ли persistent login.
- Если persistent login не нужен, оставить только `sessionStorage`.
- Если нужен, оставить `localStorage`, но осознанно и с коротким TTL/refresh policy.
- Заменить frontend `jsonwebtoken` decode на `jwt-decode` или `/auth/me`.
### Минимальные regression checks
Начать с smoke tests/checklist:
- login;
- constructor load/save;
- save to stage;
- publish;
- public production runtime;
- private production runtime grant;
- page navigation;
- rest.
## P1 - Build и зависимости
### Package manager
Сейчас есть смешение lockfiles/scripts.
TODO:
- Выбрать один package manager для frontend/backend.
- Удалить лишний lockfile после проверки install/build.
- Обновить docs/deployment commands.
### Dependencies LTS alignment и obsolete package replacement
Зависимости нужно обновлять осознанно, чтобы не закреплять старые toolchain limitations и устаревшие библиотеки.
TODO:
- Проверить текущие frontend/backend dependencies и devDependencies на совместимость с выбранным Node LTS.
- Обновить TypeScript, ESLint, `@typescript-eslint/*`, Next.js/React tooling и backend runtime tooling до актуальных LTS/stable versions.
- Найти deprecated, unsupported и obsolete packages через package manager audit/outdated/deprecation warnings, npm metadata, release notes и project documentation.
- Для deprecated/unsupported/obsolete packages выбрать actively maintained mainstream alternatives с широкой adoption, свежими releases, нормальной TypeScript/ESM support и понятным migration path.
- Для каждой замены зафиксировать причину: что именно deprecated/unsupported/obsolete, какой replacement выбран, какие breaking changes ожидаются.
- Проверить DB scripts, migrations/seeders, test runner и production start после dependency updates.
- Не делать blind major upgrades без changelog/release notes и rollback plan.
### Node version
TODO:
- Зафиксировать выбранный Node LTS через `backend/package.json` `engines.node`.
- Согласовать `engines.node`.
- Обновить `@types/node` во frontend, когда будет удобно.
### CI/minimal checks
Для небольшого проекта достаточно минимального gate:
- frontend build;
- frontend lint/typecheck, если проходит;
- backend lint;
- smoke checklist перед deploy.
Audit/outdated/license checks делать периодически, не обязательно blocking на каждый PR.
## P2 - Security hardening
### CSP
Полный CSP может быть сложным из-за embeds/media. Не вводить сразу.
TODO:
- Сначала собрать список нужных domains для assets/embeds.
- Если будет время, включить report-only CSP на stage.
- Enforced CSP делать только после проверки runtime presentations.
## P2 - Документация
TODO:
- Обновить docs только для реально изменённых workflows/API/schema.
## Рекомендуемый порядок
1. Добавить validation для самых рискованных endpoints и запретить новые routes без validation.
2. Проверить DB slow queries и добавить только реально нужные индексы.
3. Выбрать package manager и Node version.
4. Добавить минимальные smoke checks.
5. Постепенно выносить helpers из больших frontend/backend файлов при изменениях.
6. Пилотно перевести один entity flow с Redux на TanStack Query.
7. Делать cleanup/dependency upgrades небольшими отдельными PR.
## Definition of Done
- Production workflows проверены вручную или тестом.
- Нет большого rewrite без явной выгоды.
- DB changes имеют backup/rollback plan.
- Документация обновлена только там, где изменилось поведение.

View File

@ -0,0 +1,429 @@
# Project Memberships
Complete documentation for the Tour Builder Platform's Project Memberships system including team collaboration, member roles, and access control per project.
## Overview
The Project Memberships system enables team collaboration by managing user access to specific projects. It operates as a separate access layer from the application-level RBAC system, allowing granular per-project permissions.
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Project Memberships Architecture │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Application-Level RBAC (roles table) │ │
│ │ Controls which users can manage project memberships │ │
│ │ │ │
│ │ • PlatformOwner, Administrator: Full CRUD on memberships │ │
│ │ • AccountManager: Create, Read, Update (no delete) │ │
│ │ • TourDesigner, ContentReviewer, AnalyticsViewer: Read only │ │
│ └─────────────────────────────┬──────────────────────────────────────────┘ │
│ │ │
│ (permission to manage) │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Project-Level Access (project_memberships) │ │
│ │ Controls what users can do within a specific project │ │
│ │ │ │
│ │ Project A Project B │ │
│ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │
│ │ │ User1: owner │ │ User2: owner │ │ │
│ │ │ User2: editor │ │ User3: viewer │ │ │
│ │ │ User3: reviewer │ │ User4: editor │ │ │
│ │ └─────────────────────┘ └─────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Data Model
### Database Schema
**Table:** `project_memberships`
| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Primary key (auto-generated) |
| `access_level` | ENUM | Project role: `owner`, `editor`, `reviewer`, `viewer` |
| `is_active` | BOOLEAN | Whether membership is active (default: false) |
| `invited_at` | DATE | When invitation was sent |
| `accepted_at` | DATE | When user accepted invitation |
| `projectId` | UUID | Foreign key to `projects` table |
| `userId` | UUID | Foreign key to `users` table |
| `createdAt` | TIMESTAMP | Record creation time |
| `updatedAt` | TIMESTAMP | Record update time |
| `deletedAt` | TIMESTAMP | Soft delete timestamp (paranoid mode) |
| `importHash` | STRING(255) | Unique hash for CSV imports |
**Source:** `backend/src/db/models/project_memberships.js`
### Database Indexes
```javascript
indexes: [
{ fields: ['projectId'] }, // Fast project lookups
{ fields: ['userId'] }, // Fast user lookups
{ fields: ['projectId', 'userId'], unique: true }, // One membership per user-project
{ fields: ['is_active'] }, // Filter by active status
{ fields: ['deletedAt'] }, // Soft delete queries
]
```
### Relationships
```javascript
// Each membership belongs to one project
db.project_memberships.belongsTo(db.projects, {
as: 'project',
foreignKey: 'projectId',
onDelete: 'CASCADE', // Delete membership when project is deleted
onUpdate: 'CASCADE',
});
// Each membership belongs to one user
db.project_memberships.belongsTo(db.users, {
as: 'user',
foreignKey: 'userId',
onDelete: 'CASCADE', // Delete membership when user is deleted
onUpdate: 'CASCADE',
});
// Audit trail associations
db.project_memberships.belongsTo(db.users, { as: 'createdBy' });
db.project_memberships.belongsTo(db.users, { as: 'updatedBy' });
```
## Project Access Levels
### ENUM Values
| Level | Description | Typical Permissions |
|-------|-------------|---------------------|
| `owner` | Full project control | All operations, can delete project, manage team |
| `editor` | Content editing | Edit pages, elements, assets, transitions |
| `reviewer` | Review only | View all content, add comments/feedback |
| `viewer` | Read-only | View published content only |
**Default:** `viewer` (when not specified)
### Access Level Hierarchy
```
owner → editor → reviewer → viewer
│ │ │ │
│ │ │ └── View published content
│ │ └── View all content + provide feedback
│ └── Edit content + reviewer permissions
└── Full control + editor permissions + manage team
```
## Invitation Workflow
### Membership States
```
┌─────────────────────────────────────────────────────────────────────┐
│ Invitation Lifecycle │
│ │
│ 1. Membership Created │
│ ├── invited_at: timestamp │
│ ├── accepted_at: null │
│ └── is_active: false │
│ │
│ ↓ │
│ │
│ 2. User Accepts Invitation │
│ ├── invited_at: original timestamp │
│ ├── accepted_at: timestamp │
│ └── is_active: true │
│ │
│ ↓ │
│ │
│ 3. Membership Active │
│ └── User can access project with assigned access_level │
│ │
│ ↓ (optional) │
│ │
│ 4. Membership Revoked (soft delete) │
│ └── deletedAt: timestamp (paranoid mode) │
└─────────────────────────────────────────────────────────────────────┘
```
### Timestamp Fields
| Field | Set When | Purpose |
|-------|----------|---------|
| `invited_at` | Membership created | Track when invitation was sent |
| `accepted_at` | User accepts | Track when user joined project |
| `is_active` | User accepts | Quick filter for active members |
## API Reference
### Endpoint
**Base URL:** `/api/project_memberships`
**Source:** `backend/src/routes/project_memberships.ts`
### Standard Operations
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/project_memberships` | List all memberships |
| `GET` | `/api/project_memberships/:id` | Get single membership |
| `POST` | `/api/project_memberships` | Create membership (invite user) |
| `PUT` | `/api/project_memberships/:id` | Update membership |
| `DELETE` | `/api/project_memberships/:id` | Delete membership (soft delete) |
### Request/Response Format
**Create Membership:**
```json
POST /api/project_memberships
{
"data": {
"project": "project-uuid",
"user": "user-uuid",
"access_level": "editor",
"invited_at": "2024-01-15T10:00:00Z"
}
}
```
**Query with Filters:**
```
GET /api/project_memberships?project=<uuid>&access_level=editor&is_active=true
```
### Filtering Options
| Parameter | Type | Description |
|-----------|------|-------------|
| `project` | UUID/name | Filter by project ID or name (iLike search) |
| `user` | UUID/firstName | Filter by user ID or firstName (iLike search) |
| `access_level` | ENUM | Filter by access level |
| `is_active` | boolean | Filter by active status |
| `invited_atRange` | [start, end] | Filter by invitation date range |
| `accepted_atRange` | [start, end] | Filter by acceptance date range |
| `createdAtRange` | [start, end] | Filter by record creation date range |
**Source:** `backend/src/db/api/project_memberships.ts`
## Application-Level Permissions
### Permission Names
The RBAC system uses these permissions to control who can manage memberships:
| Permission | Description |
|------------|-------------|
| `CREATE_PROJECT_MEMBERSHIPS` | Invite users to projects |
| `READ_PROJECT_MEMBERSHIPS` | View project team members |
| `UPDATE_PROJECT_MEMBERSHIPS` | Change user access levels |
| `DELETE_PROJECT_MEMBERSHIPS` | Remove users from projects |
### Role-Permission Matrix
| Role | CREATE | READ | UPDATE | DELETE |
|------|--------|------|--------|--------|
| PlatformOwner | ✓ | ✓ | ✓ | ✓ |
| Administrator | ✓ | ✓ | ✓ | ✓ |
| AccountManager | ✓ | ✓ | ✓ | ✗ |
| TourDesigner | ✗ | ✓ | ✗ | ✗ |
| ContentReviewer | ✗ | ✓ | ✗ | ✗ |
| AnalyticsViewer | ✗ | ✓ | ✗ | ✗ |
**Source:** `backend/src/db/seeders/20200430130760-user-roles.js`
## Frontend Integration
### Redux State Management
**Slice:** `frontend/src/stores/project_memberships/project_membershipsSlice.ts`
```typescript
import { createEntitySlice } from '../createEntitySlice';
import type { ProjectMembership } from '../../types/entities';
const { slice, actions, reducer } = createEntitySlice<ProjectMembership>({
name: 'project_memberships',
endpoint: 'project_memberships',
singularName: 'Project Membership',
});
export const {
fetch,
create,
update,
deleteItem,
deleteItemsByIds,
uploadCsv,
setRefetch,
} = actions;
```
### TypeScript Interface
**Source:** `frontend/src/types/entities.ts`
```typescript
export interface ProjectMembership extends BaseEntity {
user?: User | string | null;
project?: Project | string | null;
access_level?: 'owner' | 'editor' | 'reviewer' | 'viewer';
is_active?: boolean;
invited_at?: string | Date | null;
accepted_at?: string | Date | null;
// Legacy field
role?: 'owner' | 'editor' | 'viewer';
}
```
### Admin Pages
**Location:** `frontend/src/pages/project_memberships/`
| Page | Path | Description |
|------|------|-------------|
| List | `/project_memberships/project_memberships-list` | Paginated table with filters (factory-generated) |
| Table | `/project_memberships/project_memberships-table` | Alternative table view (manual implementation) |
| New | `/project_memberships/project_memberships-new` | Create membership form |
| Edit (query) | `/project_memberships/project_memberships-edit?id=<uuid>` | Edit membership form (query param) |
| Edit (path) | `/project_memberships/<uuid>` | Edit membership form (path param) |
| View | `/project_memberships/project_memberships-view?id=<uuid>` | Read-only details |
**Note:** Two edit routes exist - query-based (`?id=`) and dynamic path-based (`[project_membershipsId].tsx`).
**Table Columns:** project, user, access_level, is_active, invited_at, accepted_at, actions
**List Filters:** access_level (enum dropdown with owner/editor/reviewer/viewer options), project (text search), user (text search), invited_at (date range), accepted_at (date range)
> **Note:** `is_active` filter is supported by the API but not exposed in the admin list UI.
**Components:** `frontend/src/components/Project_memberships/`
- `TableProject_memberships.tsx` - Data grid component (23 LOC)
- `CardProject_memberships.tsx` - Card view component (163 LOC)
- `ListProject_memberships.tsx` - List view component (125 LOC)
- `configureProject_membershipsCols.tsx` - Column definitions with permission-based editability (51 LOC)
### Usage Example
```typescript
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { fetch, create, update, deleteItem } from '../stores/project_memberships/project_membershipsSlice';
// Fetch project members
dispatch(fetch({ query: `?project=${projectId}&is_active=true` }));
// Invite user to project
dispatch(create({
project: projectId,
user: userId,
access_level: 'editor',
invited_at: new Date().toISOString(),
}));
// Update access level
dispatch(update({
id: membershipId,
access_level: 'reviewer',
}));
// Remove from project
dispatch(deleteItem(membershipId));
```
## Unique Constraint
The composite unique index on `(projectId, userId)` ensures:
- Each user can only have one membership per project
- Attempting to invite the same user twice will fail
- To change a user's role, update the existing membership
```sql
-- This constraint prevents duplicate memberships
CREATE UNIQUE INDEX ON project_memberships (projectId, userId);
```
## Soft Delete Behavior
The model uses Sequelize's paranoid mode:
- `DELETE` operations set `deletedAt` timestamp instead of removing record
- Default queries exclude soft-deleted records
- Membership history is preserved for audit purposes
```javascript
{
timestamps: true,
paranoid: true, // Enables soft delete
}
```
## Known Considerations
1. **Two-Tier Access System**: Application-level roles control who can manage memberships; project-level access controls what users can do within projects.
2. **Invitation Flow**: Memberships start with `is_active: false`. The system tracks `invited_at` and `accepted_at` separately to support invitation workflows.
3. **Legacy Field**: The TypeScript interface includes a `role` field for backward compatibility, but the database uses `access_level` ENUM.
4. **Cascade Deletes**: Memberships are automatically deleted when their associated project or user is deleted.
5. **Dedicated Admin Pages**: Standard CRUD pages exist at `/project_memberships/*`:
- `project_memberships-list.tsx` - List with filters using factory pattern (35 LOC)
- `project_memberships-table.tsx` - Alternative table view (185 LOC, manual implementation)
- `project_memberships-new.tsx` - Create new membership (149 LOC)
- `project_memberships-edit.tsx` - Edit via query param (200 LOC)
- `[project_membershipsId].tsx` - Edit via path param (221 LOC)
- `project_memberships-view.tsx` - View membership details (163 LOC)
- Components: `TableProject_memberships`, `CardProject_memberships`, `ListProject_memberships`
6. **Default Access Level**: New memberships default to `viewer` access, the most restrictive level.
7. **Autocomplete Field**: The DB API uses `access_level` for autocomplete searches, enabling dropdown-style user interfaces.
## Data Flow Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Team Collaboration Flow │
│ │
│ Project Owner Backend API Database │
│ │ │ │ │
│ │ POST /project_memberships │ │ │
│ │ {user, project, editor} │ │ │
│ │──────────────────────────────>│ │ │
│ │ │ │ │
│ │ │ Check CREATE_PROJECT_ │ │
│ │ │ MEMBERSHIPS permission │ │
│ │ │ │ │
│ │ │ Validate unique constraint │ │
│ │ │──────────────────────────────>│ │
│ │ │ │ │
│ │ │ Create membership record │ │
│ │ │ invited_at: now │ │
│ │ │ is_active: false │ │
│ │ │<──────────────────────────────│ │
│ │ │ │ │
│ │<──────────────────────────────│ │ │
│ │ 200 OK {membership} │ │ │
│ │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ │
│ Invited User │
│ │ PUT /project_memberships/:id │
│ │ {is_active: true} │ │ │
│ │──────────────────────────────>│ │ │
│ │ │ │ │
│ │ │ Update membership │ │
│ │ │ accepted_at: now │ │
│ │ │ is_active: true │ │
│ │ │──────────────────────────────>│ │
│ │ │<──────────────────────────────│ │
│ │<──────────────────────────────│ │ │
│ │ │ │ │
│ │ User can now access project │ │ │
│ │ with 'editor' permissions │ │ │
└─────────────────────────────────────────────────────────────────────────────┘
```

View File

@ -0,0 +1,585 @@
# Project Transition Settings
Documentation for the environment-aware project-level transition settings system that cascades with global and element-level settings.
## Overview
Project transition settings control the default CSS-based transition behavior for page navigation when no video transition is configured on an element. Settings are stored per-project and per-environment, following the same publishing pattern as `tour_pages` and `project_audio_tracks`.
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Transition Settings Cascade │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐│
│ │ Element Level (highest priority) ││
│ │ └── ui_schema_json.elements[].transitionSettings ││
│ └─────────────────────────────────────────────────────────────────────┘│
│ ↓ fallback │
│ ┌─────────────────────────────────────────────────────────────────────┐│
│ │ Project Level (per environment) ││
│ │ └── project_transition_settings WHERE projectId AND environment ││
│ └─────────────────────────────────────────────────────────────────────┘│
│ ↓ fallback │
│ ┌─────────────────────────────────────────────────────────────────────┐│
│ │ Global Level (platform-wide defaults) ││
│ │ └── global_transition_defaults (single record) ││
│ └─────────────────────────────────────────────────────────────────────┘│
│ ↓ fallback │
│ ┌─────────────────────────────────────────────────────────────────────┐│
│ │ Hardcoded Fallback ││
│ │ └── type: 'fade', durationMs: 700, easing: 'ease-in-out' ││
│ └─────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────┘
```
## Environment-Aware Architecture
Project transition settings follow the same environment isolation pattern as tour pages:
```
Publishing Flow:
┌─────────────────────────────────────────────────────────────────┐
│ Constructor (dev) │
│ └── project_transition_settings WHERE environment='dev' │
│ ↓ │
│ [Save to Stage] │
│ ↓ │
│ Stage (Preview) │
│ └── project_transition_settings WHERE environment='stage' │
│ ↓ │
│ [Publish] │
│ ↓ │
│ Production (Public) │
│ └── project_transition_settings WHERE environment='production' │
└─────────────────────────────────────────────────────────────────┘
```
## Database Schema
### Table: `project_transition_settings`
| Column | Type | Nullable | Default | Description |
|--------|------|----------|---------|-------------|
| id | UUID | No | UUIDV4 | Primary key |
| projectId | UUID | No | - | FK to projects (CASCADE delete) |
| environment | ENUM | No | - | 'dev', 'stage', 'production' |
| source_key | TEXT | Yes | null | Tracks source record during publishing |
| transition_type | TEXT | No | 'fade' | CSS transition type ('fade', 'none') |
| duration_ms | INTEGER | No | 700 | Transition duration in milliseconds |
| easing | TEXT | No | 'ease-in-out' | CSS easing function |
| overlay_color | TEXT | No | '#000000' | Color of transition overlay |
| createdById | UUID | Yes | - | FK to users |
| updatedById | UUID | Yes | - | FK to users |
| createdAt | TIMESTAMP | No | NOW() | Creation timestamp |
| updatedAt | TIMESTAMP | No | NOW() | Last update timestamp |
| deletedAt | TIMESTAMP | Yes | null | Soft delete timestamp |
**Indexes:**
- `project_transition_settings_project_env_unique` - UNIQUE on (projectId, environment) WHERE deletedAt IS NULL
**Constraints:**
- Only one active record per project/environment combination
- Soft delete (paranoid: true)
## API Endpoints
### Authentication Model
The API uses **URL-path-based public access** for runtime presentations:
| Endpoint | Method | Environment | Auth Required |
|----------|--------|-------------|---------------|
| `/project/:id/env/production` | GET | production | **No** (public) |
| `/project/:id/env/dev` | GET | dev | Yes |
| `/project/:id/env/stage` | GET | stage | Yes |
| `/project/:id/env/*` | PUT/DELETE | any | Yes |
| Standard CRUD (`/`, `/:id`) | all | n/a | Yes |
This allows public presentations (`/p/[slug]`) to fetch production transition settings without authentication, while protecting dev/stage environments and write operations.
Write operations use the `PAGE_ELEMENTS` permission family because transition
defaults are authored as part of the tour page/element editing surface. Global
defaults and project environment upserts require `UPDATE_PAGE_ELEMENTS`.
Environment-level reset uses `DELETE` to remove the project override row, but
authorization treats it as an update and also requires `UPDATE_PAGE_ELEMENTS`
rather than `DELETE_PAGE_ELEMENTS`.
### Get Settings by Project and Environment
```http
GET /api/project-transition-settings/project/:projectId/env/:environment
# Production: No auth required (public)
# Dev/Stage: Authorization: Bearer {token}
```
**Response (200):**
```json
{
"id": "uuid",
"projectId": "project-uuid",
"environment": "dev",
"transition_type": "fade",
"duration_ms": 700,
"easing": "ease-in-out",
"overlay_color": "#000000",
"createdAt": "2026-05-01T12:00:00Z",
"updatedAt": "2026-05-01T12:00:00Z"
}
```
**Response (200) when no settings exist:** `null` (use global defaults)
**Response (401):** Authentication required (for dev/stage without JWT)
### Create or Update Settings
```http
PUT /api/project-transition-settings/project/:projectId/env/:environment
Authorization: Bearer {token}
Content-Type: application/json
{
"transition_type": "fade",
"duration_ms": 1000,
"easing": "ease-out",
"overlay_color": "#1a1a1a"
}
```
**Response (200):** Created or updated settings object
### Delete Settings
```http
DELETE /api/project-transition-settings/project/:projectId/env/:environment
Authorization: Bearer {token}
```
**Response (200):** `{ "success": true }`
### Standard CRUD Endpoints (All Require Auth)
```
GET /api/project-transition-settings - List all
GET /api/project-transition-settings/:id - Get by ID
POST /api/project-transition-settings - Create
PUT /api/project-transition-settings/:id - Update
DELETE /api/project-transition-settings/:id - Delete
```
## Publishing Integration
### Copy During Save to Stage / Publish
Project transition settings are copied along with tour pages and audio tracks:
```javascript
// In PublishService.copyEnvironment()
const [sourcePages, sourceAudioTracks, sourceTransitionSettings] = await Promise.all([
db.tour_pages.findAll({ where: { projectId, environment: fromEnv }, transaction }),
db.project_audio_tracks.findAll({ where: { projectId, environment: fromEnv }, transaction }),
db.project_transition_settings.findOne({ where: { projectId, environment: fromEnv }, transaction }),
]);
// Clean up target environment
await Promise.all([
db.tour_pages.destroy({ where: { projectId, environment: toEnv }, transaction, force: true }),
db.project_audio_tracks.destroy({ where: { projectId, environment: toEnv }, transaction, force: true }),
db.project_transition_settings.destroy({ where: { projectId, environment: toEnv }, transaction, force: true }),
]);
// Copy transition settings if exists
if (sourceTransitionSettings) {
await db.project_transition_settings.create({
...sanitizedData,
projectId,
environment: toEnv,
source_key: sourceTransitionSettings.id,
createdById: actorId,
updatedById: actorId,
}, { transaction });
}
```
### Source Key Tracking
The `source_key` field tracks the lineage of copied records:
- When copying dev → stage: stage record's `source_key` = dev record's `id`
- When copying stage → production: production record's `source_key` = stage record's `id`
## Frontend Integration
### Redux Store
**File:** `frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts`
```typescript
interface ProjectTransitionSettingsState {
byProjectEnv: Record<string, ProjectTransitionSettingsEntity | null>;
loading: boolean;
loadingKeys: Record<string, boolean>;
}
// Actions - no special headers needed, backend determines public access from URL path
fetchByProjectAndEnv({ projectId, environment })
upsertByProjectAndEnv({ projectId, environment, data })
deleteByProjectAndEnv({ projectId, environment })
// Selectors
selectByProjectAndEnv(state, projectId, environment)
selectTransitionSettingsLoading(state, projectId, environment)
```
**Global Transition Defaults Store**
**File:** `frontend/src/stores/global_transition_defaults/globalTransitionDefaultsSlice.ts`
```typescript
// Simple fetch - backend GET is always public
export const fetch = createAsyncThunk<GlobalTransitionDefaults, void>(
'global_transition_defaults/fetch',
async () => {
const result = await axios.get('global-transition-defaults');
return result.data;
}
);
```
### Type Definitions
**File:** `frontend/src/types/transition.ts`
```typescript
// Database entity (snake_case)
interface ProjectTransitionSettingsEntity extends BaseEntity {
projectId: string;
environment: 'dev' | 'stage' | 'production';
source_key?: string;
transition_type: TransitionType;
duration_ms: number;
easing: EasingFunction;
overlay_color: string;
}
// Cascade resolution format (camelCase)
interface ProjectTransitionSettings {
transitionType?: TransitionType;
durationMs?: number;
easing?: EasingFunction;
overlayColor?: string;
}
// Convert entity to cascade format
function entityToProjectSettings(entity): ProjectTransitionSettings | null
```
### Hook: useTransitionSettings
**File:** `frontend/src/hooks/useTransitionSettings.ts`
Resolves final transition settings through the cascade:
```typescript
const transitionSettings = useTransitionSettings({
globalDefaults, // From global_transition_defaults table
projectSettings, // From project_transition_settings (converted via entityToProjectSettings)
elementSettings, // From element's ui_schema_json
});
// Returns: ResolvedTransitionSettings
// { type, durationMs, easing, overlayColor, videoUrl?, reverseVideoUrl? }
```
### Constructor Usage
**File:** `frontend/src/pages/constructor.tsx`
```typescript
// Fetch dev environment settings
useEffect(() => {
if (projectId) {
dispatch(fetchProjectTransitionSettings({ projectId, environment: 'dev' }));
}
}, [dispatch, projectId]);
// Select from store
const projectTransitionSettingsEntity = useAppSelector((state) =>
projectId ? selectProjectTransitionSettings(state, projectId, 'dev') : undefined
);
// Convert for cascade resolution
const projectTransitionSettings = useMemo(
() => entityToProjectSettings(projectTransitionSettingsEntity),
[projectTransitionSettingsEntity]
);
```
### Runtime Presentation Usage
**File:** `frontend/src/components/RuntimePresentation.tsx`
```typescript
// Fetch environment-specific settings
useEffect(() => {
if (project?.id) {
dispatch(fetchProjectTransitionSettings({ projectId: project.id, environment }));
}
}, [dispatch, project?.id, environment]);
// Use in transition settings resolution
const transitionSettings = useTransitionSettings({
globalDefaults,
projectSettings, // Environment-specific
elementSettings: currentElementTransitionSettings, // From React state
});
```
### Element Settings State Tracking
Both `constructor.tsx` and `RuntimePresentation.tsx` track element-level transition settings via React state:
```typescript
// State for current element's transition settings
const [currentElementTransitionSettings, setCurrentElementTransitionSettings] =
useState<ElementTransitionSettings | null>(null);
```
**Extracting settings from clicked elements:**
```typescript
import { extractElementTransitionSettings } from '../types/transition';
// In click handler, extract settings from the navigation element
const elementSettings = extractElementTransitionSettings(clickedElement);
```
The `extractElementTransitionSettings()` function converts element fields to `ElementTransitionSettings` format:
- Only includes fields with actual values (not empty strings)
- Allows cascade to fall through when element uses "Use Project Default"
- Handles type coercion for `transitionType` and `transitionEasing`
### Critical: React Timing Fix with flushSync
**Problem:** React's async state batching can cause transitions to start with OLD settings values.
When a user clicks a navigation button, this sequence happens:
1. `setCurrentElementTransitionSettings(elementSettings)` is called
2. `switchToPage()` starts the transition
3. `useTransitionSettings` hook resolves final settings
Without synchronization, step 2 can execute before React processes step 1, causing the transition to use stale settings.
**Solution:** Use `flushSync` from `react-dom` to force synchronous state updates:
```typescript
import { flushSync } from 'react-dom';
// In handleElementClick:
const elementSettings = extractElementTransitionSettings(clickedElement);
// Force synchronous state update BEFORE navigation starts
flushSync(() => {
setCurrentElementTransitionSettings(elementSettings);
});
// Now transition will use the correct settings
switchToPage(targetPageId, transitionConfig);
```
**Files affected:**
- `frontend/src/pages/constructor.tsx`
- `frontend/src/components/RuntimePresentation.tsx`
### TourFlowManager (Settings UI)
**File:** `frontend/src/components/TourFlowManager.tsx`
The TourFlowManager component provides a UI for editing project transition settings in the dev environment:
```typescript
// Fetch and display current settings
const projectTransitionSettingsEntity = useAppSelector((state) =>
selectedProjectId ? selectByProjectAndEnv(state, selectedProjectId, 'dev') : undefined
);
// Save changes
const handleSaveTransitionSettings = async () => {
await dispatch(upsertByProjectAndEnv({
projectId: selectedProjectId,
environment: 'dev',
data: {
transition_type: localTransitionType,
duration_ms: localDurationMs,
easing: localEasing,
overlay_color: localOverlayColor,
},
}));
};
```
## Transition Types
| Type | CSS Behavior | Description |
|------|--------------|-------------|
| `fade` | opacity 0→1 | Smooth crossfade between pages |
| `none` | No animation | Instant page switch |
## Easing Functions
| Value | CSS equivalent | Description |
|-------|----------------|-------------|
| `linear` | linear | Constant speed |
| `ease` | ease | Standard easing |
| `ease-in` | ease-in | Slow start |
| `ease-out` | ease-out | Slow end |
| `ease-in-out` | ease-in-out | Slow start and end |
## Video Transition Priority
When an element has a `transitionVideoUrl`, the video transition **always takes precedence** over CSS-based transitions:
```typescript
if (elementSettings?.transitionVideoUrl) {
return {
type: 'video',
durationMs: elementSettings.transitionDurationMs ?? FALLBACK_DEFAULTS.durationMs,
easing: elementSettings.transitionEasing ?? FALLBACK_DEFAULTS.easing,
overlayColor: /* cascade through element → project → global */,
videoUrl: elementSettings.transitionVideoUrl,
reverseVideoUrl: elementSettings.reverseVideoUrl,
};
}
```
## File References
| File | Purpose |
|------|---------|
| `backend/src/db/migrations/20260501000002-create-project-transition-settings.js` | Database migration |
| `backend/src/db/models/project_transition_settings.js` | Sequelize model |
| `backend/src/db/api/project_transition_settings.ts` | Database API layer |
| `backend/src/services/project_transition_settings.ts` | Service layer |
| `backend/src/routes/project_transition_settings.ts` | REST endpoints |
| `backend/src/services/publish.ts` | Publishing integration |
| `frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts` | Redux store |
| `frontend/src/types/transition.ts` | TypeScript definitions |
| `frontend/src/hooks/useTransitionSettings.ts` | Cascade resolution hook |
| `frontend/src/components/TourFlowManager.tsx` | Settings UI |
| `frontend/src/pages/constructor.tsx` | Constructor integration |
| `frontend/src/components/RuntimePresentation.tsx` | Runtime integration |
## Troubleshooting
### Settings Not Applying
1. Verify settings exist: `GET /api/project-transition-settings/project/:id/env/:environment`
2. Check cascade order - element settings override project settings
3. Ensure correct environment is being queried (dev/stage/production)
### Settings Not Publishing
1. Check publish event completed successfully
2. Verify source environment has settings before publish
3. Check `source_key` in target environment record
### Missing Settings After Project Creation
New projects don't automatically get project-level settings. They use:
1. Global defaults (if configured)
2. Hardcoded fallback (fade, 700ms, ease-in-out)
To set project defaults, use TourFlowManager or API.
---
## Slide Transitions (Gallery/Carousel)
Slide transitions for Gallery and Carousel elements cascade from page transition settings, with optional element-level overrides.
### Cascade Flow
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Slide Transition Cascade │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Element Override (highest priority) │ │
│ │ └── gallerySlideTransition* / carouselSlideTransition* fields │ │
│ └────────────────────────────┬─────────────────────────────────────┘ │
│ ↓ fallback │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Page Transition Settings (resolved via useTransitionSettings) │ │
│ │ └── Global → Project cascade (see above) │ │
│ └────────────────────────────┬─────────────────────────────────────┘ │
│ ↓ fallback │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Hardcoded Fallback │ │
│ │ └── type: 'fade', durationMs: 700, easing: 'ease-in-out', │ │
│ │ overlayColor: '#000000' │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
### Element Override Fields
Stored in `tour_pages.ui_schema_json.elements[]`:
**Carousel:**
| Field | Type | Description |
|-------|------|-------------|
| `carouselSlideTransitionType` | `'fade' \| 'none' \| ''` | Transition type ('' = use default) |
| `carouselSlideTransitionDurationMs` | `number \| ''` | Duration in ms |
| `carouselSlideTransitionEasing` | `EasingFunction \| ''` | CSS easing |
| `carouselSlideTransitionOverlayColor` | `string` | Overlay color (hex) |
**Gallery:**
| Field | Type | Description |
|-------|------|-------------|
| `gallerySlideTransitionType` | `'fade' \| 'none' \| ''` | Transition type ('' = use default) |
| `gallerySlideTransitionDurationMs` | `number \| ''` | Duration in ms |
| `gallerySlideTransitionEasing` | `EasingFunction \| ''` | CSS easing |
| `gallerySlideTransitionOverlayColor` | `string` | Overlay color (hex) |
### Slide vs Page Transition Differences
| Aspect | Page Transitions | Slide Transitions |
|--------|------------------|-------------------|
| Video type | Supported | Maps to 'fade' (no video between slides) |
| Overlay color | Yes (fade through overlay) | Yes (same behavior) |
| Duration | Configurable | Inherits from page or element override |
| Element override | Navigation element | Gallery/Carousel element |
### Implementation Files
| File | Purpose |
|------|---------|
| `frontend/src/lib/resolveSlideTransition.ts` | Cascade resolver + extractors |
| `frontend/src/hooks/useSlideTransition.ts` | Animation state management hook |
| `frontend/src/components/UiElements/elements/CarouselElement.tsx` | Carousel with crossfade |
| `frontend/src/components/UiElements/GalleryCarouselOverlay.tsx` | Gallery/Info Panel fullscreen media overlay with crossfade |
| `frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx` | Element editor UI |
### Animation Mechanism
The `useSlideTransition` hook implements fade-through-overlay animation:
```
Phase 1: Fade Out (half duration)
├── overlayOpacity: 0 → 1
└── slideOpacity: 1 → 0
Phase 2: Swap (at midpoint)
└── displayIndex switches to new slide
Phase 3: Fade In (half duration)
├── overlayOpacity: 1 → 0
└── slideOpacity: 0 → 1
```
### Backwards Compatibility
Existing elements without slide transition fields automatically cascade to page transition settings or hardcoded fallbacks. No database migration required - fields are stored in JSON column.
**Behavior for existing projects:**
- Elements with page transitions configured → inherits those settings
- Elements with no page transitions → uses hardcoded fallback (fade, 700ms)

View File

@ -0,0 +1,996 @@
# Publishing Workflow
Complete documentation for the Tour Builder Platform's publishing system including the three-tier environment model (dev → stage → production), publish events, and transaction locking.
## Overview
The platform implements a **three-tier environment publishing system**:
- **Dev** - Active editing environment (constructor always edits here)
- **Stage** - Preview/testing environment for stakeholder review
- **Production** - Live environment for public access
The publishing workflow has two steps:
1. **Save to Stage**: Copy dev content to stage for preview
2. **Publish to Production**: Copy stage content to production
Projects maintain environment-specific data (pages, audio tracks) that can be independently edited and published. The system uses database transactions with project-level locks to prevent concurrent publishing conflicts. Page elements and transitions are stored directly in `tour_pages.ui_schema_json`.
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Three-Tier Publishing Flow │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Dev │ │ Stage │ │ Production │ │
│ │ Environment │───▶│ Environment │───▶│ Environment │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ │ Edit in │ Preview │ Public Access │
│ │ Constructor │ │ │
│ ▼ ▼ ▼ │
│ /constructor /p/[slug]/stage /p/[slug] │
│ │
│ [Save to Stage]──────────▶ │
│ [Publish to Production]──────────▶ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Database Structure │
│ │
│ Projects ─────────┬─────────────────┬─────────────────┐ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ tour_pages audio_tracks transition_settings publish_events │
│ (env field) (env field) (env field) (history) │
│ │
│ environment: 'dev' | 'stage' | 'production' │
│ │
│ Note: Elements, links, and transitions are stored in │
│ tour_pages.ui_schema_json (no separate tables) │
└─────────────────────────────────────────────────────────────────────────┘
```
## Stage vs Production Environments
### Environment-Specific Entities
Each of these entities maintains an `environment` field:
| Entity | Environment Field | Description |
|--------|------------------|-------------|
| `tour_pages` | Yes | Pages with backgrounds, content, and `ui_schema_json` |
| `project_audio_tracks` | Yes | Project audio files |
| `project_transition_settings` | Yes | CSS transition settings (type, duration, easing, overlay color) |
**Note:** Page elements, navigation links, and video transitions are stored directly in `tour_pages.ui_schema_json` and are copied with the page when publishing. Project-level CSS transition settings are stored separately in `project_transition_settings` and also copied during publish.
### URL Patterns
**Stage Environment:**
```
/p/[projectSlug]/stage
```
- Route: `frontend/src/pages/p/[projectSlug]/stage.tsx`
- Loads pages with `environment='stage'` only
**Production Environment:**
```
/p/[projectSlug]
```
- Route: `frontend/src/pages/p/[projectSlug]/index.tsx`
- Loads pages with `environment='production'` only
## Environment Isolation (Security)
**CRITICAL:** Strict environment filtering prevents data leaks between environments.
### Defense in Depth
The system uses **two layers** of environment protection:
| Layer | File | Protection |
|-------|------|------------|
| **Frontend** | `RuntimePresentation.tsx` | Strict filter: `p.environment === environment` |
| **Backend** | `db/api/runtime-context.ts` | Filters by `X-Runtime-Environment` header |
### Frontend Filtering
Environment filtering happens in the `usePageDataLoader` hook, which is used by `RuntimePresentation.tsx`:
```typescript
// hooks/usePageDataLoader.ts - STRICT environment match only
// For runtime mode, filter by environment client-side
if (projectSlug) {
pageRows = pageRows.filter((p) => p.environment === environment);
}
// Sort by sort_order
pageRows.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
```
- Production mode (`/p/cardiff`): Only shows `environment='production'` pages
- Stage mode (`/p/cardiff/stage`): Only shows `environment='stage'` pages
- Dev mode (Constructor): Only shows `environment='dev'` pages
- **No fallbacks** - missing environment data shows empty, never leaks from other environments
### Backend Filtering
The backend also filters based on the `X-Runtime-Environment` header:
```javascript
// db/api/runtime-context.ts
// Only 'production' and 'stage' allowed from header
// 'dev' is BLOCKED to prevent unauthorized access
if (runtimeContext.headerEnvironment === 'production') return 'production';
if (runtimeContext.headerEnvironment === 'stage') return 'stage';
// 'dev' header returns null → no backend filter (constructor handles separately)
```
### Database Constraints
Environment columns are enforced at the database level:
```sql
-- tour_pages.environment: NOT NULL, default 'dev'
-- project_audio_tracks.environment: NOT NULL, default 'dev'
-- project_transition_settings.environment: NOT NULL
```
This prevents NULL values from bypassing environment filters.
**Constructor (Dev Environment):**
```
/constructor?projectId=[id]
```
- Route: `frontend/src/pages/constructor.tsx`
- Always loads and edits pages with `environment='dev'`
- "Save to Stage" button copies dev → stage
### Runtime Mode Detection
**Primary Method: Route-Based Access**
The platform uses **route-based environment access**, not subdomains:
| Route | Environment | Component |
|-------|-------------|-----------|
| `/p/[slug]` | production | `pages/p/[projectSlug]/index.tsx` |
| `/p/[slug]/stage` | stage | `pages/p/[projectSlug]/stage.tsx` |
| `/constructor?projectId=` | dev | `pages/constructor.tsx` |
**Frontend Headers for API Calls:**
```javascript
// RuntimePresentation sends these headers
headers: {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment // 'production' | 'stage' | 'dev'
}
```
**Backend Middleware** (`backend/src/middlewares/runtime-context.ts`):
The middleware reads environment and project slug from headers for route-based access:
```javascript
// req.runtimeContext structure
{
mode: 'admin', // default mode
projectSlug: null, // not used in current implementation
headerEnvironment: string | null, // from X-Runtime-Environment header ('production', 'stage', 'dev')
headerProjectSlug: string | null, // from X-Runtime-Project-Slug header
}
```
**Environment Resolution** (`backend/src/db/api/runtime-context.ts`):
The `getRuntimeEnvironment()` function resolves environment in order:
1. Hostname-based detection (stage/production subdomains)
2. Header-based fallback (`X-Runtime-Environment` header)
```javascript
// Only 'production' and 'stage' are allowed from headers
// 'dev' is blocked to prevent unauthorized access to dev data
if (runtimeContext.headerEnvironment === 'production') return 'production';
if (runtimeContext.headerEnvironment === 'stage') return 'stage';
```
### Project Slug
The `slug` field on projects determines public URLs:
| Property | Value |
|----------|-------|
| Pattern | `/^[a-z0-9_-]+$/i` |
| Length | 1-255 characters |
| Uniqueness | Must be unique across all projects |
| Mutability | Should not change (breaks public URLs) |
**Example:**
```
slug: 'cardiff'
Stage URL: /p/cardiff/stage
Production URL: /p/cardiff
```
## Publish Events
### Database Schema
**File:** `backend/src/db/models/publish_events.js`
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | UUID | Yes | Primary key |
| title | STRING(255) | No* | User-provided event name (*DB allows null, service validates, max 255 chars) |
| description | TEXT | No* | User-provided details (*DB allows null, service validates, max 5000 chars) |
| from_environment | ENUM | Yes | Source: 'dev', 'stage', 'production' |
| to_environment | ENUM | Yes | Target: 'dev', 'stage', 'production' |
| status | ENUM | Yes | 'queued', 'running', 'success', 'failed' |
| started_at | DATETIME | No | When publish began |
| finished_at | DATETIME | No | When publish completed |
| error_message | TEXT | No | Failure reason if applicable |
| pages_copied | INTEGER | No | Number of pages published |
| transitions_copied | INTEGER | No | Number of transitions published (legacy field, not currently populated) |
| audios_copied | INTEGER | No | Number of audio tracks published |
| projectId | UUID | Yes | FK to projects (CASCADE) |
| userId | UUID | Yes | FK to users who initiated |
### Status Lifecycle
```
queued → running → success
↘ failed
```
| Status | Meaning | Fields Set |
|--------|---------|------------|
| `queued` | Event created, waiting to process | title, description, environments |
| `running` | Publish in progress | started_at |
| `success` | Completed successfully | finished_at, *_copied counts |
| `failed` | Encountered error | finished_at, error_message |
### API Endpoints
**Save to Stage (Dev → Stage):**
```http
POST /api/publish/save-to-stage
Content-Type: application/json
Authorization: Bearer {token}
{
"projectId": "uuid"
}
```
**Response (Success):**
```json
{
"success": true,
"publishEventId": "uuid"
}
```
**Note:** Save to Stage is **non-blocking** - the API returns immediately after creating the publish event, and the actual copy operation continues in the background. Check the `publish_events` table for final status (`success` or `failed`).
**Publish to Production (Stage → Production):**
*Note: Both `/api/publish` and `/api/publish/publish` route to the same handler.*
```http
POST /api/publish
Content-Type: application/json
Authorization: Bearer {token}
{
"projectId": "uuid",
"title": "Release 1.0.3",
"description": "Added new tour pages and fixed navigation"
}
```
**Response (Success):**
```json
{
"success": true,
"publishEventId": "uuid",
"summary": {
"pages_copied": 5,
"audios_copied": 2
}
}
```
**Publish Events CRUD:**
```
GET /api/publish_events - List with pagination
GET /api/publish_events/:id - Get details
PUT /api/publish_events/:id - Update
DELETE /api/publish_events/:id - Delete
GET /api/publish_events?project=id - Filter by project
```
## Publishing Process
### Blocking vs Non-Blocking Operations
| Operation | Blocking | Behavior |
|-----------|----------|----------|
| **Save to Stage** | No | Returns immediately, copy runs in background via `setImmediate()` |
| **Publish to Production** | Yes | Waits for entire copy operation before returning |
**Save to Stage** uses background processing because it's a frequent operation during development and shouldn't block the UI. **Publish to Production** remains blocking because it's a deliberate action that users expect to complete before seeing results.
### Complete Flow (Publish to Production)
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Phase 1: Validation & Event Creation │
├─────────────────────────────────────────────────────────────────────────┤
│ POST /api/publish │
│ ↓ │
│ PublishService.publishToProduction() │
│ ├─ Validate projectId exists │
│ ├─ Validate title is non-empty │
│ ├─ Validate description is non-empty │
│ └─ CREATE publish_events record with status='queued' │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Phase 2: Acquire Lock & Begin Transaction │
├─────────────────────────────────────────────────────────────────────────┤
│ PublishService.withProjectPublishLock(projectId, callback) │
│ ├─ BEGIN TRANSACTION │
│ ├─ SELECT * FROM projects WHERE id={id} FOR UPDATE │
│ ├─ SELECT * FROM publish_events WHERE status='running' FOR UPDATE │
│ └─ If running publish exists → ERROR 400 │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Phase 3: Copy Stage → Production │
├─────────────────────────────────────────────────────────────────────────┤
│ 1. FETCH all stage data: │
│ ├─ tour_pages WHERE environment='stage' │
│ ├─ project_audio_tracks WHERE environment='stage' │
│ └─ project_transition_settings WHERE environment='stage' │
│ │
│ 2. PURGE existing production data: │
│ ├─ DELETE tour_pages WHERE environment='production' │
│ ├─ DELETE project_audio_tracks WHERE environment='production' │
│ └─ DELETE project_transition_settings WHERE environment='production'│
│ │
│ 3. CREATE production records via bulkCreate: │
│ ├─ tour_pages (with environment='production', source_key tracking) │
│ ├─ project_audio_tracks │
│ └─ project_transition_settings (CSS transition defaults) │
│ │
│ Note: Video transitions are stored in tour_pages.ui_schema_json and │
│ copied with the page. CSS transition settings (fade, duration, │
│ easing) are stored in project_transition_settings per environment.│
│ Slugs are used for navigation (not IDs), so no remapping needed. │
│ │
│ Element defaults (element_type_defaults → project_element_defaults) │
│ are NOT environment-specific. Settings are embedded in elements when │
│ created, so ui_schema_json contains complete elements with settings. │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Phase 4: Mark Success or Failure │
├─────────────────────────────────────────────────────────────────────────┤
│ Success: │
│ UPDATE publish_events SET status='success', finished_at=NOW(), │
│ pages_copied=N, audios_copied=N │
│ │
│ Failure: │
│ UPDATE publish_events SET status='failed', finished_at=NOW(), │
│ error_message=error.message │
│ ROLLBACK TRANSACTION │
└─────────────────────────────────────────────────────────────────────────┘
```
### Data Copied During Publish
**Entities Copied:**
| Entity | Copied | Notes |
|--------|--------|-------|
| Tour Pages | ✅ | All stage pages → production (includes `sort_order`, `ui_schema_json` with elements, navigation, video transitions, and page media fields) |
| Project Audio | ✅ | All stage audio → production |
| Project Transition Settings | ✅ | CSS transition settings (type, duration, easing, overlay color) |
**Entities NOT Copied:**
| Entity | Reason |
|--------|--------|
| Assets | Shared across environments |
| Project metadata | Name, slug, description unchanged |
| User permissions | Independent of environment |
| PWA caches | Generated separately |
| Element type defaults | Global platform-wide (not project-specific) |
| Project element defaults | Project-wide (not environment-specific) - settings are already embedded in elements within `ui_schema_json` when elements are created |
### Page Order Propagation
Page order is stored on `tour_pages.sort_order`. Runtime loaders sort pages by
this field and use the first sorted page as the presentation entry page.
The constructor can change the dev page set and order directly:
- Reorder pages via `POST /api/tour_pages/reorder`. This updates only
`tour_pages.sort_order`.
- Duplicate the active dev page via `POST /api/tour_pages/:id/duplicate`. This
creates a new independent dev page at the end of the order, copies page
settings and `ui_schema_json`, and regenerates inline element IDs.
- Delete the active dev page via `DELETE /api/tour_pages/:id` after constructor
confirmation.
Stage and production are intentionally read-only for direct constructor page
writes:
1. Reorder, duplicate, or delete pages in the constructor
(`environment='dev'`).
2. Click Save to Stage to copy dev pages, including `sort_order`, to
`environment='stage'`.
3. Publish to Production to copy stage pages, including `sort_order`, to
`environment='production'`.
This means stage preview keeps the previous page set/order until Save to Stage
finishes, and the public production presentation keeps the previous page set/order
until Publish finishes.
### Data Sanitization
Before copying, records are sanitized:
```javascript
sanitizeRecordForClone(modelInstance) {
const data = modelInstance.toJSON();
// Delete auto-generated fields
delete data.id; // Gets new UUID
delete data.createdAt; // New timestamp
delete data.updatedAt; // New timestamp
delete data.deletedAt; // Not paranoid for production
delete data.deletedBy; // Clear soft-delete actor
delete data.importHash; // Unique field
// Ensure JSON fields are objects, not strings (avoid double-encoding)
if (data.ui_schema_json && typeof data.ui_schema_json === 'string') {
try {
data.ui_schema_json = JSON.parse(data.ui_schema_json);
} catch {
// Keep as-is if parsing fails
}
}
return data;
}
```
## Transaction Locking
### Concurrency Control
The system prevents concurrent publishing using row-level locks:
```javascript
// Acquire exclusive lock on project row
const project = await db.projects.findByPk(projectId, {
transaction,
lock: transaction.LOCK.UPDATE // SELECT FOR UPDATE
});
// Check for running publish (also locked)
const runningEvent = await db.publish_events.findOne({
where: { projectId, status: 'running' },
transaction,
lock: transaction.LOCK.UPDATE
});
if (runningEvent) {
throw Error('Publish already running for this project');
}
```
### Lock Properties
| Property | Value |
|----------|-------|
| **Isolation Level** | READ COMMITTED (Postgres default) |
| **Lock Duration** | Transaction start → commit/rollback |
| **Lock Scope** | Project row + publish_events rows |
| **Deadlock Handling** | Auto-rollback on deadlock |
| **Lock Timeout** | 5-30 seconds (DB config) |
### Lock Workflow
```
1. Transaction Starts
└─ BEGIN TRANSACTION
2. Acquire Project Lock
└─ SELECT * FROM projects WHERE id=? FOR UPDATE
3. Check Running Publishes
└─ SELECT * FROM publish_events WHERE status='running' FOR UPDATE
└─ If exists → ROLLBACK + ERROR 400
4. Execute Publish Operations
└─ All operations protected by transaction
└─ Concurrent requests block until lock released
5. Commit or Rollback
└─ Success → COMMIT (locks released)
└─ Error → ROLLBACK (locks released)
```
### Error Handling
**Pre-Lock Validation:**
```javascript
if (!projectId || !title || !description) {
throw new ValidationError('Missing required fields');
// No transaction started
}
```
**Lock Conflict:**
```javascript
if (runningEvent) {
throw new ValidationError('Publish is already running for this project');
// HTTP 400 returned
}
```
**Data Errors:**
```javascript
try {
await copyStageToProduction(projectId, userId, transaction);
} catch (error) {
// Transaction automatically rolls back
// Error captured in publish_events.error_message
throw error;
}
```
## Frontend Integration
### Save to Stage Button (Constructor)
**Files:**
- `frontend/src/pages/constructor.tsx` - Uses the hook
- `frontend/src/hooks/useConstructorPageActions.ts` - Contains the `saveToStage` implementation
The Constructor uses the `useConstructorPageActions` hook which provides the `saveToStage` function:
```typescript
// hooks/useConstructorPageActions.ts
const saveToStage = useCallback(async () => {
if (!projectId) {
onError?.('Project ID is required to save to stage.');
return;
}
// First save current state, then copy to stage
await saveConstructor();
try {
setIsSavingToStage(true);
// Note: axios baseURL adds '/api' prefix automatically
// Non-blocking: returns immediately, copy runs in background
await axios.post('/publish/save-to-stage', { projectId });
onSuccess?.('Saved to stage.');
} catch (error: any) {
onError?.(error?.response?.data?.message || 'Failed to save to stage');
} finally {
setIsSavingToStage(false);
}
}, [projectId, saveConstructor, onError, onSuccess]);
```
**Note:** The Save to Stage operation is non-blocking - the button returns to normal immediately while the actual copy operation continues in the background. The user sees a brief "Saved to stage" confirmation.
// constructor.tsx - Hook usage
const {
isSavingToStage,
saveToStage,
// ... other actions
} = useConstructorPageActions({
projectId,
pages,
// ... other options
});
// UI passes saveToStage to ConstructorMenu component
<ConstructorMenu
onSaveToStage={saveToStage}
// ...
/>
```
### Publish Status Visibility
The platform displays timestamps showing when content was last saved or published, providing users with visual feedback on content freshness.
#### usePublishStatus Hook
**File:** `frontend/src/hooks/usePublishStatus.ts`
Fetches the last successful publish events for a project:
```typescript
interface UsePublishStatusResult {
lastSavedToStage: string | null; // Last dev → stage timestamp
lastPublishedToProduction: string | null; // Last stage → production timestamp
isLoading: boolean;
refresh: () => Promise<void>; // Refresh after new publish
}
const { lastPublishedToProduction, refresh } = usePublishStatus({ projectId });
```
#### Timestamp Display
Timestamps are displayed inside buttons using the `subtitle` prop of `BaseButton`:
**Project Dashboard (Publish to Production):**
```typescript
<BaseButton
label={isPublishing ? 'Publishing...' : 'Publish to Production'}
subtitle={lastPublishedToProduction
? `Last: ${dataFormatter.relativeTimestamp(lastPublishedToProduction)}`
: undefined}
color='success'
onClick={() => setIsPublishModalActive(true)}
/>
// Shows: "Publish to Production" with "Last: 5 min ago" below
```
**Constructor Menu (Save / Save to Stage):**
```typescript
// Project-level save timestamp: most recent updatedAt across all pages
const lastProjectSaveAt = useMemo(() => {
if (!pages.length) return null;
return pages.reduce((latest, page) => {
if (!page.updatedAt) return latest;
if (!latest) return page.updatedAt;
return new Date(page.updatedAt) > new Date(latest) ? page.updatedAt : latest;
}, null as string | null);
}, [pages]);
<BaseButton
label={isSaving ? 'Saving...' : 'Save'}
subtitle={lastProjectSaveAt ? dataFormatter.relativeTimestamp(lastProjectSaveAt) : undefined}
onClick={onSave}
/>
// Shows: "Save" with project-level timestamp (same on all pages)
```
#### Relative Timestamp Format
The `dataFormatter.relativeTimestamp()` method formats dates as human-readable relative times:
| Time Difference | Display Format |
|-----------------|----------------|
| < 2 minutes | "Just now" |
| < 60 minutes | "5 min ago" |
| < 24 hours | "2 hours ago" |
| Same day | "Today at 14:30" |
| Yesterday | "Yesterday at 09:15" |
| Older | "Apr 28 at 16:45" |
### Publish Button (Project Dashboard)
**File:** `frontend/src/pages/projects/[projectsId].tsx`
```typescript
const [isPublishing, setIsPublishing] = useState(false);
const [isPublishModalActive, setIsPublishModalActive] = useState(false);
const [publishTitle, setPublishTitle] = useState('');
const [publishDescription, setPublishDescription] = useState('');
// Publish status for timestamp display
const { lastPublishedToProduction, refresh: refreshPublishStatus } = usePublishStatus({
projectId,
});
<BaseButton
label={isPublishing ? 'Publishing...' : 'Publish to Production'}
subtitle={lastPublishedToProduction
? `Last: ${dataFormatter.relativeTimestamp(lastPublishedToProduction)}`
: undefined}
color='success'
onClick={() => setIsPublishModalActive(true)}
disabled={isPublishing || !projectId}
/>
```
### Publish Modal
```typescript
<CardBoxModal
title='Publish to production'
buttonColor='success'
buttonLabel={isPublishing ? 'Publishing...' : 'Confirm publish'}
isConfirmDisabled={
isPublishing || !publishTitle.trim() || !publishDescription.trim()
}
isActive={isPublishModalActive}
onConfirm={handlePublish}
onCancel={() => { if (!isPublishing) setIsPublishModalActive(false); }}
>
<FormField label='Event title'>
<input
placeholder='e.g. Release 1.0.3'
value={publishTitle}
onChange={(e) => setPublishTitle(e.target.value)}
/>
</FormField>
<FormField label='Event description'>
<textarea
placeholder='Describe what was published'
value={publishDescription}
onChange={(e) => setPublishDescription(e.target.value)}
/>
</FormField>
</CardBoxModal>
```
### Publish Handler
```typescript
const handlePublish = async () => {
if (!projectId) {
toast('Project is required', { type: 'warning', position: 'bottom-center' });
return;
}
const title = publishTitle.trim();
const description = publishDescription.trim();
if (!title || !description) {
toast('Title and description are required', { type: 'warning', position: 'bottom-center' });
return;
}
setIsPublishing(true);
try {
// Note: axios baseURL adds '/api' prefix automatically
const response = await axios.post('/publish', {
projectId,
title,
description
});
const summary = response?.data?.summary;
toast(
summary
? `Published: ${summary.pages_copied} pages`
: 'Publish completed successfully',
{ type: 'success', position: 'bottom-center' }
);
setIsPublishModalActive(false);
setPublishTitle('');
setPublishDescription('');
} catch (error: any) {
const message = error?.response?.data || error?.message || 'Publish failed';
toast(typeof message === 'string' ? message : 'Publish failed', { type: 'error', position: 'bottom-center' });
} finally {
setIsPublishing(false);
}
};
```
### Presentation Links
```typescript
const presentationLinks = useMemo(() => {
const projectSlug = project?.slug?.trim();
if (!projectSlug) return { production: '', stage: '' };
return {
production: `/p/${projectSlug}`,
stage: `/p/${projectSlug}/stage`,
};
}, [project?.slug]);
// UI buttons to open presentations (uses openPresentation helper)
<BaseButton
label='To Production Presentation'
color='info'
onClick={() => openPresentation(presentationLinks.production, 'production presentation')}
disabled={!projectId || !project?.slug}
/>
<BaseButton
label='To Stage Presentation'
color='lightDark'
onClick={() => openPresentation(presentationLinks.stage, 'stage presentation')}
disabled={!projectId || !project?.slug}
/>
```
## PWA Integration
### Manifest Generation
PWA manifests are generated separately from publishing:
```http
GET /api/projects/:id/offline-manifest?deviceType=desktop
```
**Response:**
```json
{
"version": "v1679856000000",
"projectId": "uuid",
"assets": [
{
"id": "asset-uuid",
"url": "/uploads/file.mp4",
"filename": "background.mp4",
"variantType": "mp4_high",
"assetType": "video",
"mimeType": "video/mp4",
"sizeBytes": 15000000,
"pageIds": ["page-uuid-1", "page-uuid-2"]
}
],
"totalSizeBytes": 150000000,
"generatedAt": 1679856000000
}
```
### PWA Cache Model
**File:** `backend/src/db/models/pwa_caches.js`
| Field | Type | Description |
|-------|------|-------------|
| id | UUID | Primary key |
| environment | ENUM | 'dev', 'stage', 'production' |
| cache_version | TEXT | Version identifier |
| manifest_json | JSON | Full manifest data |
| asset_list_json | JSON | Asset URLs for caching |
| generated_at | DATETIME | Generation timestamp |
| is_active | BOOLEAN | Currently active cache |
| projectId | UUID | FK to projects |
### Relationship to Publishing
- PWA manifests are **NOT** automatically regenerated on publish
- Publishing copies data to production environment
- PWA caches must be explicitly regenerated via separate API
## Asset Preloading & Environment Filtering
### How Preloading Respects Environments
When viewing a tour in any environment, assets are preloaded only from pages in that environment. The filtering happens in the `usePageDataLoader` hook, and `RuntimePresentation` receives already-filtered pages:
```typescript
// usePageDataLoader.ts - Environment filtering during data load
if (projectSlug) {
pageRows = pageRows.filter((p) => p.environment === environment);
}
pageRows.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
// RuntimePresentation.tsx - Uses filtered pages from hook
const { project, pages, isLoading, error, initialPageId } = usePageDataLoader({
projectSlug,
environment,
apiHeaders: {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment,
},
});
// Extract navigation links only from same-environment pages
const { pageLinks, preloadElements } = extractPageLinksAndElements(pages);
// Preload orchestrator uses filtered pages
const preloadOrchestrator = usePreloadOrchestrator({
pages, // Already filtered by environment
pageLinks, // Navigation links with transitionVideoUrl
elements: preloadElements,
currentPageId: selectedPageId,
pageHistory,
enabled: !isLoading && !error,
});
```
### S3 Presigned URLs for Assets
Assets are downloaded directly from S3 using presigned URLs:
```
POST /api/file/presign
{ urls: ["assets/project-x/image.jpg", ...] }
→ Returns presigned URLs (1-hour expiry, max 50 per request)
```
This works identically across all environments since assets are project-scoped, not environment-scoped.
### Instant Navigation with Preloaded Assets
Navigation between pages uses pre-decoded blob URLs for instant display:
```typescript
const pageSwitch = usePageSwitch({
preloadCache: {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, // O(1) instant lookup
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
},
});
```
This ensures smooth transitions regardless of environment (dev preview, stage, or production).
**Manual Regeneration Pattern:**
1. Admin publishes via "Publish to Production"
2. Admin generates PWA manifest via separate action
3. New manifest stored with `environment='production'`
4. Previous manifest marked inactive
## File Reference
| File | Purpose |
|------|---------|
| `backend/src/routes/publish.ts` | Publish API endpoints (save-to-stage, publish) |
| `backend/src/services/publish.ts` | Publishing business logic |
| `backend/src/db/models/publish_events.js` | Publish events model |
| `backend/src/db/api/publish_events.ts` | Publish events DB operations |
| `backend/src/db/api/runtime-context.ts` | Environment resolution from headers |
| `backend/src/middlewares/runtime-context.ts` | Runtime environment detection |
| `backend/src/services/pwa_manifest.js` | PWA manifest generation |
| `frontend/src/pages/p/[projectSlug]/index.tsx` | Production presentation |
| `frontend/src/pages/p/[projectSlug]/stage.tsx` | Stage presentation |
| `frontend/src/pages/constructor.tsx` | Constructor with Save to Stage button |
| `frontend/src/pages/projects/[projectsId].tsx` | Project dashboard with publish UI |
| `frontend/src/pages/publish_events/*` | Publish events list/view pages |
| `frontend/src/components/RuntimePresentation.tsx` | Runtime presentation component |
| `frontend/src/hooks/usePageDataLoader.ts` | Data loading with environment filtering |
| `frontend/src/hooks/useConstructorPageActions.ts` | Constructor actions (saveToStage) |
| `frontend/src/hooks/usePublishStatus.ts` | Publish status timestamps |
| `frontend/src/lib/extractPageLinks.ts` | Extract navigation links from pages |
| `frontend/src/hooks/usePreloadOrchestrator.ts` | Asset preloading with ready blob URLs |
| `frontend/src/hooks/usePageSwitch.ts` | Page navigation using preloaded assets |
## Summary Table
| Aspect | Dev | Stage | Production |
|--------|-----|-------|------------|
| **URL** | `/constructor?projectId=` | `/p/[slug]/stage` | `/p/[slug]` |
| **Purpose** | Active editing | Preview/testing | Public access |
| **Data Source** | `environment='dev'` | `environment='stage'` | `environment='production'` |
| **Editing** | Full editing | Read-only | Read-only |
| **Publish Action** | "Save to Stage" (non-blocking) → | "Publish to Production" (blocking) → | Final destination |
| **PWA Cache** | Not applicable | Can be generated | Primary target |
| **Visibility** | Constructor only | Stage URL | Public URL |
## Troubleshooting
### "Publish already running" Error
1. Check `publish_events` table for `status='running'` records
2. If stuck, manually update to `status='failed'`
3. Check for database connection issues causing uncommitted transactions
### Publishing Takes Too Long
1. Check number of pages/elements being copied
2. Review database performance
3. Check for lock contention from concurrent operations
### Production Data Not Updating
1. Verify publish event completed with `status='success'`
2. Check `pages_copied` count is non-zero
3. Clear browser cache and reload presentation
4. Verify correct project slug in URL
### Stage/Production Mismatch
1. Confirm stage data exists (`tour_pages WHERE environment='stage'`)
2. Check publish event `error_message` for failures
3. Review transaction logs for rollback issues

View File

@ -0,0 +1,803 @@
# Role-Based Access Control (RBAC) System
Complete documentation for the Tour Builder Platform's RBAC system including roles, permissions, custom permissions, and entity-level access control.
## Overview
The platform implements a comprehensive RBAC system with:
- **7 Default Roles** - From Administrator to Public (seeded in database)
- **54 Seeded Permissions** - CRUD operations for 13 entities + 2 special permissions
- **Custom Permissions** - Per-user permissions independent of assigned role
- **Middleware-based Enforcement** - Automatic permission checking on all routes
> **Note:** The frontend TypeScript enum may define additional permissions not seeded in the backend. The seeder also assigns role-permissions for legacy entities (PAGE_ELEMENTS, PAGE_LINKS, TRANSITIONS) that were previously stored in separate tables but are now embedded in `tour_pages.ui_schema_json`.
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Request Flow │
│ │
│ Request → JWT Auth → Permission Middleware → Route Handler → Response │
│ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ Permission Resolution │ │
│ │ │ │
│ │ 1. AccessPolicy │ │
│ │ 2. Custom permissions │ │
│ │ 3. Role permissions │ │
│ │ 4. Public hardening │ │
│ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Database Schema │
│ │
│ ┌─────────┐ ┌──────────────────────────┐ ┌───────────┐ │
│ │ Users │────────▶│ usersCustom_permissions │◀────────│Permissions│ │
│ └────┬────┘ │ Permissions │ └─────┬─────┘ │
│ │ └──────────────────────────┘ │ │
│ │ app_roleId │ │
│ ▼ │ │
│ ┌─────────┐ ┌──────────────────────────┐ │ │
│ │ Roles │────────▶│ rolesPermissions │◀──────────────┘ │
│ └─────────┘ │ Permissions │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
## Database Models
### Roles Model
**File:** `backend/src/db/models/roles.js`
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| id | UUID | Yes | Primary key, auto-generated |
| name | TEXT | Yes | Max 100 chars, role identifier |
| role_customization | TEXT | No | JSON for custom role metadata |
| importHash | STRING(255) | No | Unique, for CSV bulk imports |
**Associations:**
- `belongsToMany permissions` via `rolesPermissionsPermissions` junction table
- `hasMany users` as `users_app_role` (FK: `app_roleId`)
### Permissions Model
**File:** `backend/src/db/models/permissions.js`
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| id | UUID | Yes | Primary key, auto-generated |
| name | TEXT | Yes | Unique, max 100 chars, e.g., `READ_PROJECTS` |
| importHash | STRING(255) | No | Unique, for CSV bulk imports |
### Junction Tables
**rolesPermissionsPermissions:**
| Field | Type | Notes |
|-------|------|-------|
| roles_permissionsId | UUID | FK to roles, part of composite PK |
| permissionId | UUID | FK to permissions, part of composite PK |
**usersCustom_permissionsPermissions:**
| Field | Type | Notes |
|-------|------|-------|
| users_custom_permissionsId | UUID | FK to users, part of composite PK |
| permissionId | UUID | FK to permissions, part of composite PK |
## Default Roles
**File:** `backend/src/db/seeders/20200430130760-user-roles.js`
| Role | Description | Typical Permissions |
|------|-------------|---------------------|
| **Administrator** | Full system access | All 54 seeded permissions + 12 legacy entity role assignments |
| **Platform Owner** | Full CRUD on all entities | All entity CRUD operations |
| **Account Manager** | Manages projects, users, assets | Create/Read/Update on most entities, including user creation and project page element defaults |
| **Tour Designer** | Builds and designs tours | Full access to tour-related entities |
| **Content Reviewer** | Reviews content | Read/Update on content entities |
| **Analytics Viewer** | Views analytics and logs | Read-only access |
| **Public** | Unauthenticated fallback and authenticated customer viewers | No seeded permissions; authenticated Public users can receive private production presentation grants |
> **Note:** The seeder creates 7 roles. The Public role has no seeded permissions. Anonymous public runtime access uses the `RUNTIME_PUBLIC_READ_ENTITIES` bypass, while authenticated Public users can receive explicit private production presentation grants through `production_presentation_access`. Self-registration is disabled; new users are created by authorized staff through the Users flow. The "User" role is not seeded by default but can be created manually.
## Permission Types
### Seeded Entity Permissions (52 total)
Each of the 13 entities has 4 CRUD permissions seeded in the database:
```
CREATE_{ENTITY} - Create new records
READ_{ENTITY} - View/list records
UPDATE_{ENTITY} - Modify existing records
DELETE_{ENTITY} - Remove records
```
**Entities with CRUD permissions (seeded):**
| Category | Entities |
|----------|----------|
| **System** | users, roles, permissions |
| **Projects** | projects, project_memberships |
| **Assets** | assets, asset_variants |
| **Tours** | tour_pages (elements, navigation, transitions stored in ui_schema_json) |
| **Media** | project_audio_tracks |
| **Publishing** | publish_events, pwa_caches |
| **Access** | presigned_url_requests, access_logs |
> **Note:** `element_type_defaults` and `project_element_defaults` permissions are **not seeded** but can be created manually if needed. The frontend enum and documentation may reference these for completeness.
### Legacy Entity Permissions (assigned to roles but not seeded as permission records)
The seeder assigns role-permissions for these legacy entities, which were previously stored in separate tables:
| Entity | Current Status |
|--------|----------------|
| page_elements | Now stored in `tour_pages.ui_schema_json` |
| page_links | Now stored in `tour_pages.ui_schema_json` |
| transitions | Now stored on navigation elements in `ui_schema_json` |
These permission names (e.g., `READ_PAGE_ELEMENTS`) are referenced in role assignments and the `RUNTIME_PUBLIC_READ_ENTITIES` set for backward compatibility. Project element defaults use `PAGE_ELEMENTS` permissions:
| Role | PAGE_ELEMENTS Permissions |
|------|---------------------------|
| Administrator | Create, Read, Update, Delete |
| Platform Owner | Create, Read, Update, Delete |
| Account Manager | Create, Read, Update |
| Tour Designer | Create, Read, Update |
| Content Reviewer | Read, Update |
| Analytics Viewer | Read |
| Public | None |
### Special Permissions (2 seeded)
| Permission | Purpose | Seeded |
|------------|---------|--------|
| `READ_API_DOCS` | Access Swagger API documentation | ✅ Yes |
| `CREATE_SEARCH` | Execute global search queries | ✅ Yes |
**Total seeded permissions:** 13 entities × 4 CRUD + 2 special = **54 permissions**
### Frontend Permission Enum
**File:** `frontend/src/types/permissions.ts`
The frontend enum defines all permissions used in UI permission checks. Note that some permissions in this enum are **not seeded** in the backend but are included for potential future use or manual creation:
```typescript
enum Permission {
// Users
CREATE_USERS = 'CREATE_USERS',
READ_USERS = 'READ_USERS',
UPDATE_USERS = 'UPDATE_USERS',
DELETE_USERS = 'DELETE_USERS',
// Projects
CREATE_PROJECTS = 'CREATE_PROJECTS',
READ_PROJECTS = 'READ_PROJECTS',
UPDATE_PROJECTS = 'UPDATE_PROJECTS',
DELETE_PROJECTS = 'DELETE_PROJECTS',
// ... similar pattern for all seeded entities (13 entities × 4 = 52 permissions)
// Legacy entities (stored in ui_schema_json, permissions assigned to roles)
READ_PAGE_ELEMENTS = 'READ_PAGE_ELEMENTS',
CREATE_PAGE_ELEMENTS = 'CREATE_PAGE_ELEMENTS',
// ... similar for PAGE_LINKS, TRANSITIONS
// UI Elements (NOT seeded in backend)
READ_UI_ELEMENTS = 'READ_UI_ELEMENTS',
CREATE_UI_ELEMENTS = 'CREATE_UI_ELEMENTS',
UPDATE_UI_ELEMENTS = 'UPDATE_UI_ELEMENTS',
DELETE_UI_ELEMENTS = 'DELETE_UI_ELEMENTS',
}
// Helper to get CRUD permissions for an entity
export const getEntityPermissions = (entityName: string) => {
const uppercased = entityName.toUpperCase();
return {
read: `READ_${uppercased}`,
create: `CREATE_${uppercased}`,
update: `UPDATE_${uppercased}`,
delete: `DELETE_${uppercased}`,
};
};
```
**Frontend vs Backend Permissions:**
| Permission Type | Frontend Enum | Backend Seeded |
|-----------------|---------------|----------------|
| 13 core entities | ✅ | ✅ |
| PAGE_ELEMENTS, PAGE_LINKS, TRANSITIONS | ✅ | Role assignments only |
| UI_ELEMENTS | ✅ | ❌ Not seeded |
| READ_API_DOCS, CREATE_SEARCH | ❌ | ✅ |
> **Note:** The frontend `hasPermission` helper bypasses permission checks for the Administrator role (single permission checks only).
## Custom Permissions
Users can have permissions beyond their assigned role through custom permissions.
### User-Permission Association
**File:** `backend/src/db/models/users.js`
```javascript
db.users.belongsToMany(db.permissions, {
as: 'custom_permissions',
foreignKey: { name: 'users_custom_permissionsId' },
through: 'usersCustom_permissionsPermissions',
onDelete: 'CASCADE',
});
```
### Setting Custom Permissions
**Create User with Custom Permissions:**
```javascript
await UsersDBApi.create({
data: {
email: 'user@example.com',
app_role: roleId,
custom_permissions: [permissionId1, permissionId2]
}
}, options);
```
**Update User's Custom Permissions:**
```javascript
await UsersDBApi.update({
id: userId,
data: {
custom_permissions: [permissionId1, permissionId2, permissionId3],
},
...options,
});
```
### Effective Permissions
A user's effective permissions are the **union** of:
1. Permissions from their assigned role (`app_role.permissions`)
2. Their custom permissions (`custom_permissions`)
```
Effective Permissions = Role Permissions Custom Permissions
```
`Public` access is an explicit exception for admin API access: even if stale
role/custom permissions exist in the database, `AccessPolicy.hasPermission`
returns `false` for authenticated `Public` users and
`AccessPolicy.getRolePermissionNames()` ignores permissions on the `Public`
role fallback. Private presentation access for customer viewers is stored
separately in `production_presentation_access`.
## Access Policy
**File:** `backend/src/services/access-policy.ts`
Centralized access helper used by permission middleware and runtime
presentation access:
| Method | Purpose |
|--------|---------|
| `hasPermission(user, permission)` | Checks effective RBAC permission for non-Public users |
| `isPublicUser(user)` | Detects authenticated customer viewer users |
| `isInternalUser(user)` | Detects authenticated non-Public users |
| `isPlatformWideRole(user)` | Checks Administrator, Platform Owner, Account Manager |
| `canUseAdminApi(user)` | Allows only non-Public users with at least one RBAC permission |
| `canViewProductionPresentation(user, projectSlug)` | Allows public presentations, internal staff, or explicit private grants |
### Public Access Hardening Audit
**Service:** `backend/src/services/access-policy-audit.ts`
**CLI:**
```bash
cd backend
npm run check:public-access # read-only audit, exits 1 when violations exist
npm run fix:public-access # removes stale Public grants
```
The audit checks:
- permissions assigned to any role named `Public`
- custom permissions assigned to users whose role is `Public`
- `production_presentation_access` rows assigned to non-Public users
The cleanup command removes only those stale grants. Runtime access remains
controlled by `AccessPolicy` even before cleanup, so stale Public permissions do
not authorize admin API access.
## Permission Middleware
**File:** `backend/src/middlewares/check-permissions.ts`
### Permission Resolution Order
```javascript
const checkPermissions = (permission) => async (req, res, next) => {
const currentUser = req.currentUser;
if (await AccessPolicy.hasPermission(currentUser, permission)) {
return next();
}
// Public role fallback for unauthenticated/no-role reads
const effectiveRole = currentUser?.app_role || publicRoleCache;
const rolePermissionNames = await AccessPolicy.getRolePermissionNames(effectiveRole);
if (rolePermissionNames.has(permission)) {
return next();
}
throw new ForbiddenError();
};
```
Self-access bypass is intentionally narrow: only authenticated users can access
their own `/api/users/:id` for `GET`, `PUT`, and `PATCH`. Other entities always
use normal permission checks, and route params are the canonical id.
### Tests
```bash
cd backend
npm run test
npm run test:integration
```
Unit tests cover effective permission merging, Public admin API denial,
internal admin API access, and platform-wide role detection. Integration tests
use a rollback transaction against a real PostgreSQL database when available;
they cover guest runtime access, granted Public private presentation access,
internal private presentation access, and audit cleanup.
### CRUD Permission Mapping
```javascript
const METHOD_MAP = {
POST: 'CREATE',
GET: 'READ',
PUT: 'UPDATE',
PATCH: 'UPDATE',
DELETE: 'DELETE',
};
// Example: PUT /api/projects/123 → 'UPDATE_PROJECTS'
const permissionName = `${METHOD_MAP[req.method]}_${entityName.toUpperCase()}`;
```
### Public Runtime Access
Certain entities are readable without authentication for published tours:
```javascript
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PROJECTS',
'TOUR_PAGES',
'PAGE_ELEMENTS',
'PAGE_LINKS',
'TRANSITIONS',
'PROJECT_AUDIO_TRACKS'
]);
```
When `req.isRuntimePublicRequest === true` and method is `GET`, these bypass permission checks.
Private production presentations still use `req.isRuntimePublicRequest` after
the user passes staff permission or `production_presentation_access` checks.
This lets Public-role customer users read runtime-safe data without granting
broad permissions such as `READ_PROJECTS` or `READ_TOUR_PAGES`. See
[private-production-presentations.md](./private-production-presentations.md).
## Router Factory Integration
**File:** `backend/src/factories/router.factory.js`
All entity routes automatically apply permission checking:
```javascript
function createEntityRouter(entityName, Service, DBApi, options = {}) {
const router = express.Router();
// Permission entity can be customized
const permissionEntity = options.permissionEntity || entityName;
// Apply permission middleware to all routes
router.use(checkCrudPermissions(permissionEntity));
// CRUD routes automatically protected
router.post('/', ...); // Requires CREATE_ENTITY
router.get('/', ...); // Requires READ_ENTITY
router.get('/:id', ...); // Requires READ_ENTITY
router.put('/:id', ...); // Requires UPDATE_ENTITY
router.delete('/:id', ...); // Requires DELETE_ENTITY
return router;
}
```
## API Endpoints
### Roles Routes
| Method | Endpoint | Permission | Description |
|--------|----------|------------|-------------|
| POST | `/api/roles` | CREATE_ROLES | Create new role |
| GET | `/api/roles` | READ_ROLES | List roles with pagination |
| GET | `/api/roles/:id` | READ_ROLES | Get role by ID |
| PUT | `/api/roles/:id` | UPDATE_ROLES | Update role |
| DELETE | `/api/roles/:id` | DELETE_ROLES | Delete role |
| POST | `/api/roles/deleteByIds` | DELETE_ROLES | Bulk delete |
| POST | `/api/roles/bulk-import` | CREATE_ROLES | CSV import |
| GET | `/api/roles/count` | READ_ROLES | Total count |
| GET | `/api/roles/autocomplete` | READ_ROLES | Autocomplete |
**Create/Update Request:**
```json
{
"data": {
"name": "Custom Role",
"role_customization": "{}",
"permissions": ["permission-uuid-1", "permission-uuid-2"]
}
}
```
**Response:**
```json
{
"id": "uuid",
"name": "Custom Role",
"role_customization": "{}",
"permissions": [
{ "id": "uuid", "name": "CREATE_PROJECTS" },
{ "id": "uuid", "name": "READ_PROJECTS" }
],
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-01-01T00:00:00Z"
}
```
### Permissions Routes
| Method | Endpoint | Permission | Description |
|--------|----------|------------|-------------|
| POST | `/api/permissions` | CREATE_PERMISSIONS | Create permission |
| GET | `/api/permissions` | READ_PERMISSIONS | List permissions |
| GET | `/api/permissions/:id` | READ_PERMISSIONS | Get by ID |
| PUT | `/api/permissions/:id` | UPDATE_PERMISSIONS | Update |
| DELETE | `/api/permissions/:id` | DELETE_PERMISSIONS | Delete |
| POST | `/api/permissions/deleteByIds` | DELETE_PERMISSIONS | Bulk delete |
| POST | `/api/permissions/bulk-import` | CREATE_PERMISSIONS | CSV import |
| GET | `/api/permissions/autocomplete` | READ_PERMISSIONS | Autocomplete |
### User Permission Management
**Assign Role and Custom Permissions (via Users API):**
```http
PUT /api/users/:id
Authorization: Bearer {token}
Content-Type: application/json
{
"data": {
"app_role": "role-uuid",
"custom_permissions": ["perm-uuid-1", "perm-uuid-2"]
}
}
```
**Get Current User with Permissions:**
```http
GET /api/auth/me
Authorization: Bearer {token}
```
**Response:**
```json
{
"id": "user-uuid",
"email": "admin@flatlogic.com",
"firstName": "Admin",
"app_role": {
"id": "role-uuid",
"name": "Administrator",
"permissions": [
{ "id": "perm-uuid", "name": "CREATE_USERS" },
{ "id": "perm-uuid", "name": "READ_USERS" }
]
},
"custom_permissions": [
{ "id": "perm-uuid", "name": "CREATE_SEARCH" }
]
}
```
## Frontend Implementation
### Permission Helper
**File:** `frontend/src/helpers/userPermissions.ts`
```typescript
export function hasPermission(
user: UserWithPermissions,
permission_name: string | string[]
): boolean {
if (!user?.app_role?.name) return false;
if (!permission_name) return true;
// Combine custom permissions and role permissions
const permissions = new Set<string>([
...(user?.custom_permissions ?? []).map((p) => p.name),
...(user?.app_role?.permissions ?? []).map((p) => p.name),
]);
if (typeof permission_name === 'string') {
// Single permission: check permission OR Administrator role
return (
permissions.has(permission_name) || user.app_role.name === 'Administrator'
);
} else {
// Multiple permissions: OR logic (Administrator bypass NOT applied here!)
return permission_name.some((p) => permissions.has(p));
}
}
```
> **Important:** The Administrator bypass only applies to **single permission** checks. When checking an array of permissions, the Administrator role does NOT automatically pass - the user must have at least one of the specified permissions.
### Usage Examples
**Single Permission Check:**
```typescript
import { hasPermission } from '@/helpers/userPermissions';
function EditButton({ user, projectId }) {
if (!hasPermission(user, 'UPDATE_PROJECTS')) {
return null;
}
return <button>Edit Project</button>;
}
```
**Multiple Permissions (OR logic):**
```typescript
function AdminPanel({ user }) {
if (!hasPermission(user, ['UPDATE_USERS', 'DELETE_USERS'])) {
return <AccessDenied />;
}
return <UserManagement />;
}
```
**Conditional Rendering:**
```typescript
function ProjectActions({ user, project }) {
return (
<div>
{hasPermission(user, 'READ_PROJECTS') && (
<ViewButton project={project} />
)}
{hasPermission(user, 'UPDATE_PROJECTS') && (
<EditButton project={project} />
)}
{hasPermission(user, 'DELETE_PROJECTS') && (
<DeleteButton project={project} />
)}
</div>
);
}
```
### Redux Integration
**File:** `frontend/src/stores/permissions/permissionsSlice.ts`
```typescript
// Fetch all permissions
dispatch(permissionsActions.fetch({ query: '' }));
// Create new permission
dispatch(permissionsActions.create({
data: { name: 'READ_CUSTOM_ENTITY' }
}));
// Delete permission
dispatch(permissionsActions.deleteItem(permissionId));
```
## Seeding Process
**File:** `backend/src/db/seeders/20200430130760-user-roles.js`
### Step 1: Create Roles
```javascript
await queryInterface.bulkInsert("roles", [
{ id: getId("Administrator"), name: "Administrator" },
{ id: getId("PlatformOwner"), name: "Platform Owner" },
{ id: getId("AccountManager"), name: "Account Manager" },
{ id: getId("TourDesigner"), name: "Tour Designer" },
{ id: getId("ContentReviewer"), name: "Content Reviewer" },
{ id: getId("AnalyticsViewer"), name: "Analytics Viewer" },
{ id: getId("Public"), name: "Public" },
]);
```
### Step 2: Create Permissions
```javascript
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"
];
// Generate CRUD permissions for each entity (13 × 4 = 52 permissions)
const permissions = entities.flatMap(name => [
{ name: `CREATE_${name.toUpperCase()}` },
{ name: `READ_${name.toUpperCase()}` },
{ name: `UPDATE_${name.toUpperCase()}` },
{ name: `DELETE_${name.toUpperCase()}` }
]);
// Add special permissions (+2 = 54 total)
permissions.push(
{ name: 'READ_API_DOCS' },
{ name: 'CREATE_SEARCH' }
);
```
### Step 3: Assign Permissions to Roles
```javascript
// Administrator gets all permissions
// Other roles get specific subsets
const rolePermissionMap = {
"PlatformOwner": ["CREATE_*", "READ_*", "UPDATE_*", "DELETE_*"],
"AccountManager": ["READ_USERS", "UPDATE_USERS", "CREATE_PROJECTS", ...],
"TourDesigner": ["READ_PROJECTS", "UPDATE_TOUR_PAGES", ...],
"ContentReviewer": ["READ_PROJECTS", "UPDATE_PAGE_ELEMENTS", ...],
"AnalyticsViewer": ["READ_*"],
"Public": ["READ_PROJECTS", "READ_TOUR_PAGES", ...],
};
```
## Complete Permission Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ PUT /api/projects/:id │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Passport JWT Authentication │
│ - Extract Bearer token from Authorization header │
│ - Verify JWT signature and expiry │
│ - Load user from DB with app_role and custom_permissions │
│ - Set req.currentUser │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ checkCrudPermissions('projects') │
│ - Map PUT method → 'UPDATE' │
│ - Generate permission: 'UPDATE_PROJECTS' │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ checkPermissions('UPDATE_PROJECTS') │
│ │
│ Step 1: Self-access check │
│ └── currentUser.id === req.params.id? → PASS │
│ │
│ Step 2: Custom permissions check │
│ └── custom_permissions.includes('UPDATE_PROJECTS')? → PASS │
│ │
│ Step 3: Role permissions check │
│ └── app_role.permissions.includes('UPDATE_PROJECTS')? → PASS │
│ │
│ Step 4: Deny │
│ └── Throw ValidationError('auth.forbidden') → 403 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Route Handler │
│ - Execute ProjectsService.update() │
│ - Return updated project │
└─────────────────────────────────────────────────────────────────┘
```
## File Reference
| File | Purpose |
|------|---------|
| `backend/src/db/models/roles.js` | Role model definition |
| `backend/src/db/models/permissions.js` | Permission model definition |
| `backend/src/db/models/users.js` | User-role and user-permission associations |
| `backend/src/db/api/roles.ts` | Roles database operations |
| `backend/src/db/api/permissions.ts` | Permissions database operations |
| `backend/src/db/seeders/20200430130760-user-roles.js` | Default roles/permissions seeder |
| `backend/src/middlewares/check-permissions.ts` | Permission enforcement middleware |
| `backend/src/factories/router.factory.js` | Auto-applies permissions to routes |
| `backend/src/routes/roles.ts` | Roles API routes |
| `backend/src/routes/permissions.ts` | Permissions API routes |
| `frontend/src/helpers/userPermissions.ts` | Client-side permission helper |
| `frontend/src/types/permissions.ts` | TypeScript Permission enum |
| `frontend/src/stores/permissions/permissionsSlice.ts` | Redux state for permissions |
## Best Practices
### Creating New Permissions
1. **Add to seeder** for new installations
2. **Create migration** for existing installations
3. **Update TypeScript enum** in frontend
4. **Assign to appropriate roles**
### Checking Permissions in Code
**Backend:**
```javascript
// Automatic via router factory - no manual check needed
// For custom endpoints:
router.get('/custom', checkPermissions('READ_CUSTOM'), handler);
```
**Frontend:**
```typescript
// Always use hasPermission helper
if (hasPermission(user, 'UPDATE_PROJECTS')) {
// Render UI element
}
```
### Administrator Role
The Administrator role has special handling in the `hasPermission` helper:
```typescript
// Single permission check - Administrator bypass applied
hasPermission(adminUser, 'UPDATE_PROJECTS') // → true (always)
// Multiple permission check - Administrator bypass NOT applied
hasPermission(adminUser, ['UPDATE_USERS', 'DELETE_USERS']) // → depends on actual permissions
```
> **Note:** The Administrator bypass only works for single permission (string) checks. For array checks, Administrator users must have at least one of the permissions in their role or custom permissions.
## Troubleshooting
### "auth.forbidden" Error
1. Check user has required permission via `/api/auth/me`
2. Verify role has permission assigned
3. Check custom_permissions array
4. Ensure JWT token is valid and not expired
### Permission Not Working After Adding
1. Re-run seeder or create migration
2. Clear any caches
3. Re-fetch user data (`dispatch(findMe())`)
4. Verify permission name matches exactly (case-sensitive)
### Public Access Issues
1. Verify entity is in `RUNTIME_PUBLIC_READ_ENTITIES`
2. Check `isRuntimePublicRequest` is set correctly
3. Ensure Public role has required permissions

View File

@ -0,0 +1,509 @@
# Search System
Complete documentation for the Tour Builder Platform's global full-text search system with permission-based filtering.
## Overview
The platform implements a global search service that performs full-text search across multiple database tables simultaneously. Results are filtered based on the current user's permissions, ensuring users only see data they have access to.
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Search System Architecture │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Search Request │ │
│ │ │ │
│ │ POST /api/search │ │
│ │ { searchQuery: "vacation" } │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Permission Check (checkCrudPermissions) │ │ │
│ │ │ │ │ │
│ │ │ READ_SEARCH permission required │ │ │
│ │ └──────────────────────────┬──────────────────────────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ SearchService.search() │ │ │
│ │ │ │ │ │
│ │ │ For each table: │ │ │
│ │ │ 1. Check READ_<TABLE> permission │ │ │
│ │ │ 2. If allowed, search text + numeric columns │ │ │
│ │ │ 3. Return up to 50 matching records │ │ │
│ │ └──────────────────────────┬──────────────────────────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Search Results │ │ │
│ │ │ │ │ │
│ │ │ [{ id, tableName, matchAttribute, ...record }] │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## API Reference
### Endpoint
**URL:** `POST /api/search`
**Authentication:** Required (JWT)
**Permission:** `READ_SEARCH`
**Source:** `backend/src/routes/search.ts`
### Request Format
```json
POST /api/search
Content-Type: application/json
Authorization: Bearer <jwt-token>
{
"searchQuery": "vacation tour"
}
```
### Response Format
```json
[
{
"id": "uuid-1",
"name": "Vacation Paradise Tour",
"slug": "vacation-paradise",
"tableName": "projects",
"matchAttribute": ["name", "slug"]
},
{
"id": "uuid-2",
"name": "Beach Vacation Page",
"slug": "beach-vacation",
"tableName": "tour_pages",
"matchAttribute": ["name"]
}
]
```
### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Record's unique identifier |
| `tableName` | string | Source table name |
| `matchAttribute` | string[] | Array of field names that matched the query |
| `...record` | object | All searchable fields from the record |
### Error Responses
| Status | Error | Description |
|--------|-------|-------------|
| 400 | "Please enter a search query" | Missing searchQuery in request body |
| 401 | Unauthorized | Missing or invalid JWT token |
| 403 | Forbidden | User lacks READ_SEARCH permission |
| 500 | "Internal Server Error" | Database or server error |
## Search Service
**Source:** `backend/src/services/search.ts`
### Core Logic
```typescript
export default class SearchService {
static async search(searchQuery: string, currentUser: CurrentUser | undefined): Promise<SearchResult> {
// 1. Validate search query
if (!searchQuery) {
throw new ValidationError('iam.errors.searchQueryRequired');
}
// 2. Get user's permissions
const permissionSet = await getUserPermissions(currentUser);
// 3. Search each table in parallel
const searchTasks = searchTables.map(async ({ tableName, textColumns }) => {
// Skip if user lacks READ permission for this table
if (!hasPermission(permissionSet, `READ_${tableName.toUpperCase()}`)) {
return [];
}
// Perform case-insensitive search
const model = db[tableName];
if (!isSearchModel(model)) {
return [];
}
const foundRecords = await model.findAll({
where: { [Op.or]: [...searchConditions] },
limit: 50,
});
return foundRecords.map(record => ({
...record.get(),
matchAttribute,
tableName,
}));
});
return (await Promise.all(searchTasks)).flat();
}
}
```
### Search Configuration
```javascript
const SEARCH_LIMIT_PER_TABLE = 50; // Max results per table
```
## Searchable Tables
### Text Columns (String Search)
| Table | Searchable Columns |
|-------|-------------------|
| `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 |
| `asset_variants` | cdn_url |
| `presigned_url_requests` | requested_key, mime_type, status |
| `tour_pages` | source_key, name, slug, background_image_url, background_video_url, background_audio_url, ui_schema_json |
| `project_audio_tracks` | source_key, name, slug, url |
| `publish_events` | error_message |
| `pwa_caches` | cache_version, manifest_json, asset_list_json |
| `access_logs` | path, ip_address, user_agent |
### Numeric Columns (Cast to String)
| Table | Searchable Columns |
|-------|-------------------|
| `assets` | size_mb, width_px, height_px, duration_sec |
| `asset_variants` | width_px, height_px, size_mb |
| `presigned_url_requests` | requested_size_mb |
| `tour_pages` | sort_order |
| `project_audio_tracks` | volume, sort_order |
| `publish_events` | pages_copied, transitions_copied, audios_copied |
## Permission-Based Filtering
### How It Works
The search system implements two levels of permission checking:
1. **Route-Level:** `READ_SEARCH` permission required to access the endpoint
2. **Table-Level:** `READ_<TABLE>` permission required for each table
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Permission-Based Search Filtering │
│ │
│ User with permissions: [READ_SEARCH, READ_PROJECTS, READ_TOUR_PAGES] │
│ │
│ Search Query: "beach" │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Table │ Permission Required │ Searched? │ Results │ │
│ ├──────────────────────────────────────────────────────────────────────┤ │
│ │ users │ READ_USERS │ ✗ Skipped │ - │ │
│ │ projects │ READ_PROJECTS │ ✓ Yes │ 3 matches │ │
│ │ assets │ READ_ASSETS │ ✗ Skipped │ - │ │
│ │ tour_pages │ READ_TOUR_PAGES │ ✓ Yes │ 5 matches │ │
│ │ project_audio_tracks│ READ_PROJECT_AUDIO_TRACKS│ ✗ Skipped │ - │ │
│ │ ... │ ... │ ... │ ... │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ Total Results: 8 (only from projects + tour_pages) │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Permission Resolution
**Source:** `backend/src/services/search.ts`
```javascript
async function getUserPermissions(currentUser) {
if (!currentUser) {
throw new ValidationError('auth.unauthorized');
}
const permissions = new Set(
(currentUser.custom_permissions || [])
.map((cp) => cp.name)
.filter(Boolean),
);
if (!currentUser.app_role) {
throw new ValidationError('auth.forbidden');
}
// Add role-based permissions
const rolePermissions = await currentUser.app_role.getPermissions();
for (const permission of rolePermissions) {
if (permission?.name) {
permissions.add(permission.name);
}
}
return permissions;
}
```
### Permission Sources
| Source | Description |
|--------|-------------|
| `currentUser.custom_permissions` | User-specific permission overrides |
| `currentUser.app_role.getPermissions()` | Permissions inherited from assigned role |
## Search Algorithm
### Case-Insensitive Matching
The search uses PostgreSQL's `ILIKE` operator for case-insensitive partial matching:
```javascript
// Text columns: direct ILIKE
{
[attribute]: {
[Op.iLike]: `%${searchQuery}%`,
},
}
// Numeric columns: cast to varchar then ILIKE
Sequelize.where(
Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'),
{ [Op.iLike]: `%${searchQuery}%` },
)
```
### Match Detection
After finding records, the service identifies which attributes matched:
```javascript
const matchAttribute = [];
// Check text columns
for (const attribute of attributesToSearch) {
if (record[attribute]?.toLowerCase()?.includes(normalizedSearchQuery)) {
matchAttribute.push(attribute);
}
}
// Check numeric columns (cast to string)
for (const attribute of attributesIntToSearch) {
const castedValue = String(record[attribute]);
if (castedValue && castedValue.toLowerCase().includes(normalizedSearchQuery)) {
matchAttribute.push(attribute);
}
}
```
## Middleware Chain
### Route Configuration
```typescript
// backend/src/routes/search.ts
import { checkCrudPermissions } from '../middlewares/check-permissions.ts';
router.use(checkCrudPermissions('search'));
router.post('/', async (req, res) => {
const foundMatches = await SearchService.search(searchQuery, req.currentUser);
res.json(foundMatches);
});
```
### Middleware Flow
```
Request → JWT Auth → checkCrudPermissions('search') → SearchService.search()
Checks READ_SEARCH permission
using currentUser.app_role
```
## Role Requirements
### Minimum Permissions for Search
| Permission | Required For |
|------------|--------------|
| `READ_SEARCH` | Access to search endpoint |
| `READ_<TABLE>` | See results from specific table |
### Example Role Configuration
```javascript
// Role: "ContentEditor" with search access to projects and pages
{
name: "ContentEditor",
permissions: [
"READ_SEARCH",
"READ_PROJECTS",
"READ_TOUR_PAGES",
"READ_PAGE_ELEMENTS",
"READ_ASSETS"
]
}
```
## Performance Characteristics
### Parallel Execution
All table searches execute in parallel using `Promise.all`:
```javascript
const searchTasks = Object.keys(tableColumns).map(async (tableName) => {
// Each table search runs concurrently
});
const resultsByTable = await Promise.all(searchTasks);
return resultsByTable.flat();
```
### Limits
| Limit | Value | Description |
|-------|-------|-------------|
| Results per table | 50 | Maximum records returned from each table |
| Total tables | 10 | Number of searchable tables |
| Max total results | 500 | Theoretical maximum (50 × 10 tables) |
## File Structure
```
backend/src/
├── routes/
│ └── search.ts # Search route with permission check
├── services/
│ └── search.ts # SearchService with table definitions
└── middlewares/
└── check-permissions.ts # Permission checking middleware
frontend/src/
├── components/
│ ├── Search.tsx # Global search input in navbar
│ └── SearchResults.tsx # Search results display component
├── pages/
│ └── search.tsx # Search results page
└── layouts/
└── Authenticated.tsx # Includes Search component in navbar
```
## Frontend Integration
### Global Search Input
**Source:** `frontend/src/components/Search.tsx`
The navbar includes a global search input that:
- Validates minimum 2 characters
- Redirects to `/search?query=<searchQuery>`
```tsx
<Formik
onSubmit={(values) => {
router.push(`/search?query=${values.search}`);
}}
>
<Field name='search' placeholder='Search' validate={validateSearch} />
</Formik>
```
### Search Results Page
**Source:** `frontend/src/pages/search.tsx`
**URL:** `/search?query=<searchQuery>`
**Permission:** `CREATE_SEARCH` (via LayoutAuthenticated wrapper)
> **Note:** The page uses `CREATE_SEARCH` permission while the API requires `READ_SEARCH`.
The search results page:
- Extracts query from URL parameters
- Calls `POST /search` with searchQuery
- Groups results by tableName
- Displays results using SearchResults component
### Search Results Component
**Source:** `frontend/src/components/SearchResults.tsx`
Displays search results grouped by table:
- Shows table name as section header (humanized)
- Renders results in a table with all searchable columns
- Clickable rows navigate to `/<tableName>/<tableName>-view/?id=<id>`
```tsx
// Results grouped by table
{Object.keys(searchResults).map((tableName) => (
<CardBox>
<table>
{/* Headers from result keys */}
{/* Clickable rows linking to entity view */}
</table>
</CardBox>
))}
```
## Known Considerations
1. **No Pagination:** The search returns all results up to the per-table limit. For large result sets, consider implementing pagination.
2. **JSON Column Search:** Searching JSON columns (ui_schema_json, manifest_json, asset_list_json) searches the raw JSON text, not parsed values.
3. **Permission Inconsistency:** The search results page uses `CREATE_SEARCH` permission wrapper, but the API endpoint checks `READ_SEARCH`. Users need both permissions to use frontend search.
4. **Numeric Search:** Numbers are cast to strings for matching. Searching "10" will match "100", "210", etc.
5. **Permission Caching:** User permissions are fetched fresh for each search request. No caching is implemented at the service level.
6. **No Role Error:** If user has no app_role, the service throws `ValidationError('auth.forbidden')` - there is no fallback to a public role.
7. **Search Query Required:** Empty or missing search queries return a 400 error, not empty results.
8. **Minimum Query Length:** Frontend validates minimum 2 characters, but API accepts any non-empty string.
9. **Excluded Tables:** The following tables are intentionally not included in global search:
- `permissions` - System configuration, not user content
- `roles` - System configuration, not user content
- `project_memberships` - Access control data, not searchable content
- `element_type_defaults` - System configuration for default element settings
- `project_element_defaults` - Project-specific element settings (managed separately)
## Usage Examples
### cURL Example
```bash
curl -X POST http://localhost:3000/api/search \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <jwt-token>" \
-d '{"searchQuery": "vacation"}'
```
### JavaScript Example
```javascript
const response = await fetch('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ searchQuery: 'vacation' }),
});
const results = await response.json();
// Group results by table
const byTable = results.reduce((acc, result) => {
if (!acc[result.tableName]) acc[result.tableName] = [];
acc[result.tableName].push(result);
return acc;
}, {});
```

2748
documentation/ui-elements.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,843 @@
# User Management
Complete documentation for the Tour Builder Platform's user management system including CRUD operations, invitations, bulk CSV import, and user profiles.
## Overview
The platform implements a comprehensive user management system with:
- **CRUD Operations** - Create, read, update, delete users with role-based permissions
- **Invitations** - Email-based invitation system with password setup tokens
- **Bulk CSV Import** - Mass user creation from CSV files
- **User Profiles** - Profile management with avatar support
- **Password Management** - Password updates, resets, and email verification
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ User Management Flow │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Frontend │ │ Backend │ │ Database │ │
│ │ Pages │───▶│ Service │───▶│ Models │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ │ Redux Store │ UsersService │ users table │
│ ▼ ▼ ▼ │
│ usersSlice.ts users.js users.js model │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Invitation Flow │
│ │
│ Create User ──▶ Generate Token ──▶ Send Email ──▶ User Sets Password │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ UsersService passwordResetToken EmailSender /password-reset │
└─────────────────────────────────────────────────────────────────────────┘
```
## User Database Model
### Schema
**File:** `backend/src/db/models/users.js`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| id | UUID | Yes | Auto | Primary key (UUIDv4) |
| firstName | TEXT | No | null | User's first name |
| lastName | TEXT | No | null | User's last name |
| phoneNumber | TEXT | No | null | Phone number |
| email | TEXT | Yes | - | Email (unique, validated) |
| password | TEXT | Yes | - | bcrypt hashed password |
| disabled | BOOLEAN | Yes | false | Account disabled status |
| emailVerified | BOOLEAN | Yes | false | Email verification status |
| emailVerificationToken | TEXT | No | null | Token for email verification |
| emailVerificationTokenExpiresAt | DATE | No | null | Token expiration |
| passwordResetToken | TEXT | No | null | Token for password reset |
| passwordResetTokenExpiresAt | DATE | No | null | Token expiration |
| provider | TEXT | Yes | 'local' | Auth provider (local, google, microsoft) |
| importHash | VARCHAR(255) | No | null | Unique hash for bulk imports |
| app_roleId | UUID | No | null | FK to roles table |
| createdById | UUID | No | null | Audit: creator user |
| updatedById | UUID | No | null | Audit: last updater |
| createdAt | TIMESTAMP | Yes | Auto | Creation timestamp |
| updatedAt | TIMESTAMP | Yes | Auto | Update timestamp |
| deletedAt | TIMESTAMP | No | null | Soft delete timestamp |
**Indexes:**
- `email` - Unique index
- `app_roleId` - FK index
- `deletedAt` - Soft delete queries
**Unique Constraints:**
- `importHash` - Unique constraint on column (for bulk import deduplication)
### Relationships
```
users
├── belongsTo roles (as app_role) ─────────────────── SET NULL on delete
├── belongsToMany permissions (as custom_permissions) ── CASCADE on delete
├── hasMany file (as avatar) ──────────────────────── CASCADE on delete
├── hasMany project_memberships ───────────────────── CASCADE on delete
├── hasMany presigned_url_requests ────────────────── CASCADE on delete
├── hasMany publish_events ────────────────────────── SET NULL on delete
├── hasMany access_logs ───────────────────────────── SET NULL on delete
├── belongsTo users (as createdBy) ────────────────── Audit trail
└── belongsTo users (as updatedBy) ────────────────── Audit trail
```
### Model Hooks
**beforeCreate:**
```javascript
// Trim string fields
users.email = users.email.trim();
users.firstName = users.firstName?.trim() || null;
users.lastName = users.lastName?.trim() || null;
// For OAuth users (non-LOCAL provider) that are valid providers:
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
users.emailVerified = true; // Auto-verify OAuth emails
if (!users.password) {
// Generate random password if not provided
const password = crypto.randomBytes(20).toString('hex');
users.password = bcrypt.hashSync(password, config.bcrypt.saltRounds);
}
}
```
**Note:** Provider values are lowercase strings: `'local'`, `'google'`, `'microsoft'` (stored as `providers.LOCAL`, etc. from config).
## CRUD Operations
### Create User
**Endpoint:** `POST /api/users`
**Permission:** `CREATE_USERS`
**Request Body:**
```json
{
"data": {
"email": "user@example.com",
"firstName": "John",
"lastName": "Doe",
"phoneNumber": "+1-555-1234",
"disabled": false,
"password": "optional-initial-password",
"app_role": "role-uuid",
"custom_permissions": ["perm-uuid-1", "perm-uuid-2"],
"avatar": [{ "id": "file-uuid" }]
}
}
```
**Response:** `true` on success
**Service Logic:**
```javascript
// 1. Check if email already exists
let user = await db.users.findOne({ where: { email }, paranoid: false, transaction });
if (user && !user.deletedAt) {
throw new ValidationError('iam.errors.userAlreadyExists');
}
// 2. Create or restore user record
if (user?.deletedAt) {
await user.restore({ transaction });
await UsersDBApi.update({ id: user.id, data, currentUser, transaction });
} else {
await UsersDBApi.create({ data, currentUser, transaction });
}
// 3. Replace private production presentation grants for Public users
// 4. Send invitation email (if enabled)
AuthService.sendPasswordResetEmail(email, 'invitation', host);
```
If a user was soft-deleted, PostgreSQL still enforces the unique `users.email`
constraint. Creating a user with that email restores and updates the soft-deleted
record instead of inserting a duplicate row.
**Default Values Applied:**
- `disabled`: false
- `emailVerified`: true (for admin-created users)
- `app_role`: "User" role if not specified
- `provider`: `local`
- `password`: generated temporary password when not supplied, stored as bcrypt
hash; invitation flow then sends a password-reset link
### Read Users
**List Users:**
```http
GET /api/users?page=1&limit=10&field=createdAt&sort=desc
Authorization: Bearer {token}
```
**Filters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| firstName | string | Case-insensitive search |
| lastName | string | Case-insensitive search |
| phoneNumber | string | Case-insensitive search |
| email | string | Case-insensitive search |
| disabled | boolean | Exact match |
| emailVerified | boolean | Exact match |
| provider | string | Case-insensitive search |
| app_role | string | Role ID or name (pipe-separated for multiple) |
| custom_permissions | string | Permission ID or name (pipe-separated) |
| createdAtRange | array | `[startDate, endDate]` |
**Response:**
```json
{
"rows": [
{
"id": "uuid",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"disabled": false,
"emailVerified": true,
"provider": "LOCAL",
"app_role": { "id": "uuid", "name": "User" },
"custom_permissions": [],
"avatar": [{ "id": "uuid", "publicUrl": "..." }],
"createdAt": "2025-01-01T00:00:00Z"
}
],
"count": 100
}
```
**Single User:**
```http
GET /api/users/{id}
Authorization: Bearer {token}
```
**Note:** Password field is explicitly removed from response.
**Autocomplete:**
```http
GET /api/users/autocomplete?query=john&limit=10&offset=0
Authorization: Bearer {token}
```
Returns `[{ id: "uuid", label: "John" }]` format.
**Count:**
```http
GET /api/users/count
Authorization: Bearer {token}
```
**CSV Export:**
```http
GET /api/users?filetype=csv
Authorization: Bearer {token}
```
Returns CSV with columns: `id`, `firstName`, `lastName`, `phoneNumber`, `email`
### Update User
**Endpoint:** `PUT /api/users/{id}`
**Permission:** `UPDATE_USERS`
**Request Body:**
```json
{
"id": "user-uuid",
"data": {
"firstName": "Updated Name",
"password": "new-password",
"app_role": "new-role-uuid",
"custom_permissions": ["perm-uuid"],
"avatar": [{ "id": "file-uuid" }]
}
}
```
**Password Handling:**
- If password provided: Hashed with bcrypt before storing
- If not provided: Existing password preserved
**emailVerified Behavior:**
- If not specified in update: Defaults to `true`
- Can be explicitly set to `false` if needed
### Delete User
**Single Delete:**
```http
DELETE /api/users/{id}
Authorization: Bearer {token}
```
**Validations:**
- Cannot delete yourself: `iam.errors.deletingHimself`
- Must have Administrator role: `errors.forbidden.message`
**Bulk Delete:**
```http
POST /api/users/deleteByIds
Authorization: Bearer {token}
{
"data": ["user-uuid-1", "user-uuid-2"]
}
```
**Note:** Uses soft delete (sets `deletedAt` timestamp).
**Bulk delete:** Handled by the factory-generated `UsersService.deleteByIds({ ids, currentUser, runtimeContext })` method and delegated to `UsersDBApi.deleteByIds({ ids, currentUser, transaction, runtimeContext })`.
## Invitation System
### How Invitations Work
1. Admin creates a user via UI or API
2. System generates a password reset token
3. Invitation email sent with setup link
4. User clicks link and sets their password
5. Account is activated
### Token Generation
**File:** `backend/src/db/api/users.js`
```javascript
static async generatePasswordResetToken(email, options) {
const token = crypto.randomBytes(20).toString('hex'); // 40-char hex
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const tokenExpiresAt = Date.now() + TOKEN_EXPIRY_MS;
await users.update({
passwordResetToken: token,
passwordResetTokenExpiresAt: tokenExpiresAt,
});
return token;
}
```
**Token Properties:**
| Property | Value |
|----------|-------|
| Format | 40-character hexadecimal |
| Expiration | 24 hours |
| Storage | `passwordResetToken` field |
| Reusable | Yes (token NOT cleared after use - see Known Issues) |
### Invitation Email
**Email Service:** `backend/src/services/email/list/invitation.js`
**Template:** `backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html`
**Email Content:**
- Subject: Invitation to join the platform
- Body: Welcome message with "Set up account" button
- Link: `{host}/password-reset?token={token}&invitation=true`
**Template Variables:**
| Variable | Description |
|----------|-------------|
| `{appTitle}` | Application name |
| `{signupUrl}` | Password reset URL with token |
| `{to}` | Recipient email address |
### Sending Invitations
```javascript
// Called from UsersService.create()
AuthService.sendPasswordResetEmail(email, 'invitation', host);
```
**Email Service Configuration:**
```javascript
// backend/src/config.ts
email: {
from: 'Tour Builder Platform <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
}
}
```
**Note:** If email is not configured (`EmailSender.isConfigured` returns false), invitations are silently skipped.
## Bulk CSV Import
### Endpoint
```http
POST /api/users/bulk-import
Content-Type: multipart/form-data
Authorization: Bearer {token}
```
**Permission:** `CREATE_USERS`
### CSV Format
**Required Fields:**
- `email` - Must be present in every row
**Optional Fields:**
- `firstName`
- `lastName`
- `phoneNumber`
- `disabled` (true/false)
- `password`
- `provider`
**Example CSV:**
```csv
firstName,lastName,email,phoneNumber,disabled
John,Doe,john@example.com,555-1234,false
Jane,Smith,jane@example.com,555-5678,false
Alice,Johnson,alice@example.com,555-9012,false
```
### Import Process
**File:** `backend/src/services/users.ts`
```javascript
static async bulkImport(req, res, sendInvitationEmails = true, host) {
// 1. Parse CSV file
await processFile(req, res);
const bufferStream = new stream.PassThrough();
bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
const results = [];
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', () => resolve())
.on('error', (error) => reject(error));
});
// 2. Validate all rows have email
const hasAllEmails = results.every((result) => result.email);
if (!hasAllEmails) {
throw new ValidationError('importer.errors.userEmailMissing');
}
// 3. Bulk create users
await UsersDBApi.bulkImport(results, { transaction, currentUser });
// 4. Send invitation emails
emailsToInvite.forEach((email) => {
AuthService.sendPasswordResetEmail(email, 'invitation', host);
});
}
```
### Bulk Import Behavior
| Aspect | Behavior |
|--------|----------|
| Default Role | "User" role assigned to all imported users |
| emailVerified | `false` (unlike single create which defaults to `true`) |
| Duplicate Handling | `ignoreDuplicates: true` - skips existing emails |
| Transaction | All-or-nothing: rollback on any error |
| Invitation Emails | Sent to all imported email addresses |
**Known Issue:** The invitation email logic has inverted condition:
```javascript
// BUG: Emails only sent when sendInvitationEmails is FALSE
if (emailsToInvite && emailsToInvite.length && !sendInvitationEmails) {
emailsToInvite.forEach((email) => {
AuthService.sendPasswordResetEmail(email, 'invitation', host);
});
}
```
### Frontend CSV Import
**Component:** Uses `DragDropFilePicker` in list page
**Redux Action:**
```typescript
dispatch(uploadCsv(file));
dispatch(setRefetch(true)); // Refresh table after import
```
**Download Existing Users as CSV:**
```typescript
// Button click handler
window.open('/api/users?filetype=csv', '_blank');
```
## User Profiles
### Profile Update Endpoint
```http
PUT /api/auth/profile
Authorization: Bearer {token}
{
"firstName": "John",
"lastName": "Doe",
"phoneNumber": "+1-555-1234",
"avatar": [{ "id": "file-uuid" }]
}
```
**Service:** `AuthService.updateProfile(profile, currentUser)`
### Updatable Profile Fields
| Field | Type | Notes |
|-------|------|-------|
| firstName | string | Trimmed on save |
| lastName | string | Trimmed on save |
| phoneNumber | string | No validation |
| email | string | Can be changed |
| avatar | file[] | Via FileDBApi |
### Avatar Handling
**Storage Configuration:**
```javascript
{
belongsTo: 'users',
belongsToColumn: 'avatar',
belongsToId: users.id
}
```
**File Upload:**
- Uses chunked upload system (see Asset Upload documentation)
- Path scoped to `users/avatar`
- Stored in `file` table with FK to user
**Frontend Component:** `FormImagePicker` with `path='users/avatar'`
## Password Management
### Password Update (Authenticated)
```http
PUT /api/auth/password-update
Authorization: Bearer {token}
{
"currentPassword": "old-password",
"newPassword": "new-password"
}
```
**Validations:**
- Current password must match stored hash
- New password cannot be same as current password
**Errors:**
| Error Code | Description |
|------------|-------------|
| `auth.wrongPassword` | Current password incorrect |
| `auth.passwordUpdate.samePassword` | New password same as current |
### Password Reset (Forgot Password)
**Step 1: Request Reset Email**
```http
POST /api/auth/send-password-reset-email
{ "email": "user@example.com" }
```
**Rate Limit:** 5 requests per hour per IP
**Step 2: Reset Password**
```http
PUT /api/auth/password-reset
{
"token": "40-char-hex-token",
"password": "new-password"
}
```
**Token Validation:**
- Must exist in database
- Must not be expired (`passwordResetTokenExpiresAt > Date.now()`)
**Error:** `auth.passwordReset.invalidToken`
### Password Hashing
**Algorithm:** bcrypt
**Configuration:**
```javascript
// backend/src/config.ts
bcrypt: {
saltRounds: 12
}
```
**Applied In:**
- User model `beforeCreate` hook
- `UsersDBApi.update()` when password field present
- `UsersDBApi.updatePassword()`
- Password reset/setup flow
## Email Verification
### Generate Verification Token
```javascript
UsersDBApi.generateEmailVerificationToken(email);
```
Same format as password reset token: 40-char hex, 24-hour expiration.
### Verify Email
```http
PUT /api/auth/verify-email
{ "token": "verification-token" }
```
**Process:**
1. Find user by `emailVerificationToken`
2. Check `emailVerificationTokenExpiresAt > Date.now()`
3. Set `emailVerified = true`
4. Clear verification token
### Request Verification Email
```http
POST /api/auth/send-email-address-verification-email
Authorization: Bearer {token}
```
Sends email with verification link to current user's email.
## Role & Permission Assignment
### Default Role Assignment
**Single User Creation:**
```javascript
if (!data.app_role) {
const role = await db.roles.findOne({ where: { name: 'User' } });
if (role) {
await users.setApp_role(role);
}
} else {
await users.setApp_role(data.app_role);
}
```
**Bulk Import:**
All users receive "User" role (no per-user role in CSV).
**Self-Registration:**
Self-registration is disabled. New users are created by Administrator, Platform
Owner, or Account Manager through the Users flow, with the role selected in the
user form.
### Custom Permissions
Users can have additional permissions beyond their role:
```javascript
// Assign custom permissions
await users.setCustom_permissions(permissionIds);
```
**Junction Table:** `usersCustom_permissionsPermissions`
**Permission Resolution:**
```javascript
// User's effective permissions = role.permissions + custom_permissions
const permissions = new Set([
...(user.app_role?.permissions || []),
...(user.custom_permissions || [])
]);
```
## API Reference
### Endpoints Summary
| Method | Endpoint | Permission | Description |
|--------|----------|------------|-------------|
| POST | `/api/users` | CREATE_USERS | Create user |
| POST | `/api/users/bulk-import` | CREATE_USERS | Bulk import CSV |
| GET | `/api/users` | READ_USERS | List users |
| GET | `/api/users/count` | READ_USERS | Count users |
| GET | `/api/users/autocomplete` | READ_USERS | Autocomplete search |
| GET | `/api/users/{id}` | READ_USERS | Get single user |
| PUT | `/api/users/{id}` | UPDATE_USERS | Update user |
| DELETE | `/api/users/{id}` | DELETE_USERS + Admin role | Delete user (see note) |
| POST | `/api/users/deleteByIds` | CREATE_USERS | Bulk delete (POST → CREATE) |
| PUT | `/api/auth/profile` | Authenticated | Update own profile |
| PUT | `/api/auth/password-update` | Authenticated | Change password |
| POST | `/api/auth/send-password-reset-email` | Public (rate limited) | Request reset |
| PUT | `/api/auth/password-reset` | Public | Reset password |
| PUT | `/api/auth/verify-email` | Public | Verify email |
**Note on DELETE:** Single user delete requires BOTH:
1. `DELETE_USERS` permission (via `checkCrudPermissions` middleware)
2. `Administrator` role name check (in `UsersService.remove()`)
**Self-Access Bypass:** Users can access their own resources via GET/PUT without specific permissions if their user ID matches the resource ID.
### Permission Middleware
All `/api/users` routes use:
```javascript
router.use(checkCrudPermissions('users'));
```
This applies:
- `CREATE_USERS` for POST
- `READ_USERS` for GET
- `UPDATE_USERS` for PUT
- `DELETE_USERS` for DELETE
## Frontend Implementation
### Redux Store
**File:** `frontend/src/stores/users/usersSlice.ts`
**Actions:**
| Action | Description |
|--------|-------------|
| `fetch({ id, query })` | Get single user or list |
| `create(data)` | Create new user |
| `update({ id, data })` | Update user |
| `deleteItem(id)` | Delete single user |
| `deleteItemsByIds(ids)` | Bulk delete |
| `uploadCsv(file)` | Import CSV |
| `setRefetch(true)` | Trigger table refresh |
### Pages
**Location:** `frontend/src/pages/users/`
| Page | File | Description |
|------|------|-------------|
| List | `users-list.tsx` (29 LOC) | User list with search, CSV import (factory-generated) |
| Table | `users-table.tsx` (168 LOC) | Alternative table view (manual implementation) |
| New | `users-new.tsx` (155 LOC) | Create user form |
| Edit (query) | `users-edit.tsx` (168 LOC) | Edit user form (query param), including Public user private presentation grants |
| Edit (path) | `[usersId].tsx` (197 LOC) | Edit user form (path param) |
| View | `users-view.tsx` (524 LOC) | Read-only user details |
**Note:** Two edit routes exist - query-based (`?id=`) and dynamic path-based (`[usersId].tsx`).
### Table Configuration
**Components:** `frontend/src/components/Users/`
- `TableUsers.tsx` - Data grid component (23 LOC)
- `CardUsers.tsx` - Card view component (194 LOC)
- `ListUsers.tsx` - List view component (145 LOC)
- `configureUsersCols.tsx` - Column definitions with permission-based editability (63 LOC)
**Columns:**
| Column | Type | Editable |
|--------|------|----------|
| firstName | text | Yes |
| lastName | text | Yes |
| phoneNumber | text | Yes |
| email | text | Yes |
| disabled | boolean toggle | Yes |
| avatar | image | No |
| app_role | select dropdown | Yes |
| custom_permissions | multi-select | No |
| actions | icons | - |
## File Reference
### Backend Files
| File | LOC | Purpose |
|------|-----|---------|
| `backend/src/db/models/users.js` | 230 | User model definition |
| `backend/src/db/api/users.js` | 734 | User database operations |
| `backend/src/services/users.ts` | ~350 | User business logic |
| `backend/src/routes/users.ts` | 64 | User API routes |
| `backend/src/services/auth.ts` | - | Authentication & invitations |
| `backend/src/services/email/list/invitation.js` | - | Invitation email |
| `backend/src/services/email/htmlTemplates/invitation/` | - | Email template |
### Frontend Files
| File | LOC | Purpose |
|------|-----|---------|
| `frontend/src/stores/users/usersSlice.ts` | 25 | User Redux store |
| `frontend/src/pages/users/` | 1241 | User management pages (6 files) |
| `frontend/src/components/Users/` | 425 | User UI components (4 files) |
## Known Issues
### 1. emailVerified Default Inconsistency
**Issue:** Single user create defaults `emailVerified` to `true`, but bulk import defaults to `false`.
**Impact:** Bulk imported users may not be able to login if email verification is required.
### 2. Password Reset Token Not Cleared After Use
**Issue:** The `passwordResetToken` is not cleared from the database after a successful password reset.
**Location:** `backend/src/services/auth.ts` and `backend/src/db/api/users.js`
**Impact:** The same password reset token can be reused multiple times within the 24-hour expiration window to reset the user's password again.
**Security Risk:** If a reset link is compromised, an attacker could reset the password multiple times.
**Fix:** Clear the `passwordResetToken` and `passwordResetTokenExpiresAt` fields in `UsersDBApi.updatePassword()` or in `AuthService.passwordReset()` after successful password update.
## Troubleshooting
### User Cannot Login
1. Check `disabled` is `false`
2. Check `emailVerified` is `true` (or email service not configured)
3. Verify password hash is set correctly
4. Check `provider` matches login method (LOCAL vs OAuth)
### Invitation Email Not Received
1. Verify email service is configured (`EmailSender.isConfigured`)
2. Check spam/junk folder
3. Verify SMTP credentials in `.env`
4. Check backend logs for email sending errors
### Bulk Import Fails
1. Ensure CSV has `email` column for all rows
2. Check for duplicate emails (will be skipped)
3. Verify CSV encoding (UTF-8 recommended)
4. Check file size limits
### Password Reset Token Invalid
1. Token may be expired (24-hour limit)
2. Token doesn't exist in database
3. Check for URL encoding issues in token
**Note:** Due to Known Issue #4, tokens are NOT cleared after use and can be reused within the expiration window.

File diff suppressed because it is too large Load Diff

View File

@ -46,6 +46,16 @@ npm run lint # ESLint check (.ts, .tsx files)
npm run format # Format code with Prettier
```
## Documentation
- [Frontend architecture](docs/frontend-architecture.md) - app structure, runtime flow, and frontend module map
- [Pages module](docs/pages-module.md) - page routing and major page responsibilities
- [Components module](docs/components-module.md) - shared and feature component documentation
- [Hooks module](docs/hooks-module.md) - custom hook inventory and patterns
- [Runtime presentation](docs/runtime-presentation.md) - public/stage playback flow and access behavior
- [Assets preloading](../documentation/assets-preloading.md) - cross-platform preloading strategy and S3 direct downloads
- [UI elements](../documentation/ui-elements.md) - element model stored in `ui_schema_json`
## Project Structure
```

View File

@ -0,0 +1,835 @@
# Asset Upload, Preloading, PWA & Offline Mode Analysis
## Executive Summary
Deep analysis of how pages and assets are uploaded, preloaded, and cached in the Tour Builder Platform - covering Constructor editing, Runtime Presentations, and Online/Offline modes. This document traces each thread step-by-step to verify robustness.
---
## 1. SYSTEM ARCHITECTURE OVERVIEW
### 1.1 Core Components
| Component | File | Purpose |
|-----------|------|---------|
| `UploadService` | `components/Uploaders/UploadService.js` | File upload to backend (simple + chunked) |
| `usePreloadOrchestrator` | `hooks/usePreloadOrchestrator.ts` | Priority queue preloading with blob URL cache |
| `useNetworkAware` | `hooks/useNetworkAware.ts` | Network condition monitoring |
| `useOfflineMode` | `hooks/useOfflineMode.ts` | Project download for offline use |
| `StorageManager` | `lib/offline/StorageManager.ts` | Cache API / IndexedDB abstraction |
| `OfflineDbManager` | `lib/offlineDb/OfflineDbManager.ts` | IndexedDB CRUD operations |
| `DownloadManager` | `lib/offline/DownloadManager.ts` | Download queue with pause/resume |
| `DownloadEventBus` | `lib/offline/DownloadEventBus.ts` | Progress event emitter |
| `sw.ts` | `src/sw.ts` | Service Worker (Serwist) |
### 1.2 Storage Hierarchy
```
Storage Layer Decision (based on file size)
|
+-- Cache API (< 5MB)
| +-- Fast browser cache
| +-- Used for images, audio, small videos
| +-- Service Worker intercepts requests
|
+-- IndexedDB (>= 5MB) via Dexie.js
| +-- assets table: large files (videos)
| +-- projects table: offline project metadata
| +-- downloadQueue table: resumable download state
|
+-- In-Memory (readyBlobUrlsRef)
+-- Map<originalUrl, blobUrl>
+-- Pre-decoded images for instant display
+-- O(1) lookup during navigation
```
---
## 2. ASSET UPLOAD PIPELINE
### 2.1 Simple Upload Flow (Constructor)
```
FormFilePicker / FormImagePicker
|
handleFileChange(event)
|
FileUploader.validate(file, schema)
+-- Check assetType (image/video/audio)
+-- Check file size
+-- Check extensions
|
FileUploader.upload(path, file, schema)
|
uploadToServer(file, path, filename)
|
POST /api/file/upload/{table}/{field}
+-- multipart/form-data
+-- JWT authentication required
|
Backend services.uploadFile()
+-- S3: PutObjectCommand
+-- GCloud: storage.bucket().upload()
+-- Local: fs.writeFile()
|
Return { id, name, sizeInBytes, privateUrl, publicUrl }
```
**File Paths:**
- `FormFilePicker.tsx:53-63` - handleFileChange
- `UploadService.js:130-152` - upload()
- `backend/src/routes/file.js:63-71` - POST /upload
- `backend/src/services/file/index.ts` - uploadFile()
### 2.2 Chunked Upload Flow (Large Files)
```
FileUploader.uploadChunked(path, file, schema, options)
|
POST /api/file/upload-sessions/init
+-- { folder, filename, size, contentType, totalChunks }
+-- Returns: { sessionId }
|
For each chunk (5MB default):
PUT /api/file/upload-sessions/{sessionId}/chunks/{chunkIndex}
+-- Raw binary data (application/octet-stream)
+-- Retry up to 3 times with exponential backoff
+-- onProgress callback for UI updates
|
POST /api/file/upload-sessions/{sessionId}/finalize
+-- Combines all chunks
+-- Returns: { url }
|
Return { id, name, sizeInBytes, privateUrl, publicUrl }
```
**File Paths:**
- `UploadService.js:154-280` - uploadChunked()
- `backend/src/routes/file.js:73-106` - Session endpoints
### 2.3 Upload Validation
```javascript
// UploadService.js:77-128
validate(file, schema) {
// Asset type validation (MIME + extension)
if (schema.assetType) validateAssetType(file, expectedType)
// Legacy validators
if (schema.image) validateAssetType(file, 'image')
if (schema.video) validateAssetType(file, 'video')
if (schema.audio) validateAssetType(file, 'audio')
// Size limit
if (schema.size && file.size > schema.size) throw Error
// Extension whitelist
if (schema.formats && !formats.includes(ext)) throw Error
}
```
---
## 3. PRELOAD ORCHESTRATOR
### 3.1 Initialization Flow
```
usePreloadOrchestrator(options)
|
options = {
pages, // All tour pages
pageLinks, // Navigation links between pages
elements, // UI elements with asset URLs
currentPageId, // Currently viewed page
enabled, // Enable/disable preloading
maxNeighborDepth // How deep to preload (default: 1)
}
|
Initialize hooks:
+-- useNeighborGraph() - Build navigation graph
+-- useNetworkAware() - Monitor network status
|
Initialize refs:
+-- queueRef: PreloadQueueItem[]
+-- readyBlobUrlsRef: Map<url, blobUrl>
+-- failedCacheLookupRef: Set<url>
```
### 3.2 Page Change -> Preload Trigger
```
useEffect triggers when currentPageId changes
|
Guard: if (!enabled || !currentPageId || !networkInfo.isOnline) return
|
Skip if already preloaded for this page (same pageId + linksCount)
|
neighborGraph.getPrioritizedAssets(currentPageId, maxNeighborDepth)
+-- Returns assets sorted by priority
|
Collect storage paths for presigning:
+-- Current page: background_image_url, background_video_url, background_audio_url
+-- Neighbor pages: same background URLs
+-- Element assets: iconUrl, imageUrl, mediaUrl, transitionVideoUrl, etc.
|
queuePresignedUrls(storagePaths)
+-- POST /api/file/presign { urls: [...] }
+-- Returns: { presignedUrls: { path: signedUrl } }
+-- Cache results for 1 hour (PRESIGN_TTL_MS)
|
addAssetsToQueue()
+-- Current page backgrounds: priority = 1000 + 200/150/100
+-- Element assets: priority from neighbor graph
+-- Neighbor backgrounds: priority = 500 + 100/50/30
```
**File Paths:**
- `usePreloadOrchestrator.ts:669-924` - Page change effect
- `assetUrl.ts:164-223` - queuePresignedUrls()
### 3.3 Queue Processing (CRITICAL FOR ONLINE MODE)
```
processQueue() [lines 354-491]
|
Guard: if (isProcessingRef.current) return
Guard: if (!networkInfo.isOnline) return [*** OFFLINE GUARD ***]
Guard: if (queueRef.current.length === 0) return
|
isProcessingRef.current = true
setIsPreloading(true)
|
While queue not empty && activeDownloads < recommendedConcurrency:
|
item = queueRef.current.shift()
|
if (preloadedUrls.has(item.url)) continue [Skip if done]
|
cached = await isUrlCached(item.url) [Check cache]
+-- StorageManager.hasAsset(url)
+-- Checks IndexedDB first, then Cache API
|
if (cached) {
await createReadyBlobUrl(item.url, item.storageKey)
continue
}
|
activeDownloadsRef.current++
|
preloadWithProgress(item.url, jobId, assetId)
+-- fetch(url) with streaming progress
+-- Store in Cache API
+-- downloadEventBus.emitPreloadProgress()
|
.then(() => {
createReadyBlobUrl(item.url, item.storageKey)
markPresignedUrlsVerified()
|
// Store under storage key for post-refresh lookups
cache.put(item.storageKey, existingResponse.clone())
})
.catch(() => {
// If presigned URL failed (CORS), retry with proxy
if (isPresignedUrl(item.url)) {
markPresignedUrlFailed(item.storageKey)
proxyUrl = buildProxyUrl(item.storageKey)
await preloadWithProgress(proxyUrl, ...)
await createReadyBlobUrl(proxyUrl, item.storageKey)
}
})
.finally(() => {
activeDownloadsRef.current--
processQueue() // Continue processing
})
```
### 3.4 Blob URL Creation (Instant Display Ready)
```
createReadyBlobUrl(url, storageKey?) [lines 280-352]
|
Guard: if (failedCacheLookupRef.has(url)) return
|
blob = await StorageManager.getAsset(url)
|
if (!blob && storageKey) {
blob = await StorageManager.getAsset(storageKey)
}
|
if (!blob && storageKey) {
// Try proxy URL format as fallback
proxyUrl = `${baseURLApi}/file/download?privateUrl=...`
blob = await StorageManager.getAsset(proxyUrl)
}
|
if (!blob) {
failedCacheLookupRef.add(url) [Prevent retry loops]
return
}
|
blobUrl = URL.createObjectURL(blob)
|
if (isImageUrl(url)) {
await decodeImage(blobUrl) [Pre-decode for instant paint]
}
|
readyBlobUrlsRef.current.set(url, blobUrl)
readyBlobUrlsRef.current.set(storageKey, blobUrl) [Dual mapping]
preloadedUrls.add(url)
```
### 3.5 Instant Lookup API
```
getReadyBlobUrl(url): string | null [line 582-584]
|
return readyBlobUrlsRef.current.get(url) || null
|
O(1) Map lookup - used during page navigation for instant display
```
---
## 4. NETWORK AWARENESS
### 4.1 Network Information API
```
useNetworkAware() [hooks/useNetworkAware.ts]
|
getConnection()
+-- navigator.connection
+-- navigator.mozConnection
+-- navigator.webkitConnection
|
getNetworkInfo() returns:
+-- isOnline: navigator.onLine
+-- effectiveType: 'slow-2g' | '2g' | '3g' | '4g'
+-- downlink: Mbps
+-- rtt: milliseconds
+-- saveData: boolean
```
### 4.2 Event Listeners
```
useEffect() [lines 72-96]
|
window.addEventListener('online', updateNetworkInfo)
window.addEventListener('offline', updateNetworkInfo)
|
if (connection) {
connection.addEventListener('change', updateNetworkInfo)
}
```
### 4.3 Adaptive Behavior
```
shouldPreloadAggressively(): [lines 99-108]
if (!networkInfo.isOnline) return false
if (networkInfo.saveData) return false
if (effectiveType === '4g') return true
if (downlink >= 5) return true
return false
recommendedConcurrency(): [lines 121-143]
if (!isOnline) return 0
if (saveData) return 1
switch (effectiveType):
'slow-2g': 1
'2g': 1
'3g': 2
'4g': 3
default: 2
suggestOfflineMode(): [lines 146-154]
if (effectiveType === 'slow-2g') return true
if (effectiveType === '2g') return true
if (rtt > 500) return true
if (downlink < 0.5) return true
return false
```
---
## 5. OFFLINE MODE FUNCTIONALITY
### 5.1 Project Download Flow
```
useOfflineMode({ projectId, projectSlug, projectName, pages })
|
On mount: loadProjectInfo()
+-- OfflineDbManager.getProject(projectId)
+-- Restore status, progress from IndexedDB
|
startDownload() [lines 258-420]
|
setStatus('downloading')
|
assets = discoverAssets() // Frontend-only asset discovery
+-- extractPageLinksAndElements(pages)
+-- discoverProjectAssets(pages, pageLinks, preloadElements)
+-- Returns: AssetToCache[] (same as online preload)
|
estimatedTotalSize = assets.reduce((sum, a) => sum + (a.sizeBytes || 0), 0)
|
quota = await StorageManager.getStorageQuota()
if (!quota.canStore(estimatedTotalSize)) throw 'Insufficient storage'
|
await OfflineDbManager.upsertProject({
id: projectId,
slug, name, status: 'downloading',
totalAssets: assets.length, downloadedAssets: 0,
totalSizeBytes: estimatedTotalSize, downloadedSizeBytes: 0,
version: `v${Date.now()}`
})
|
For each asset:
assetInfo = await StorageManager.getAssetInfo(asset.storageKey)
if (assetInfo?.exists && !assetInfo.isPartial) {
downloadedCount++ // Fully cached, skip
continue
}
// Partial cached needs full download for offline
|
presignedUrls = await queuePresignedUrls(storagePaths)
|
await downloadManager.addJob({
assetId: `offline-${storageKey}`,
projectId, url: downloadUrl, // presigned or proxy
storageKey, createBlobUrl: true, persist: true
})
|
Progress tracked via DownloadEventBus events:
downloadEventBus.on('asset-preload-complete', (data) => {
// data includes { storageKey }
downloadedCount++
if (downloadedCount >= totalAssets) {
setStatus('downloaded')
OfflineDbManager.updateProjectStatus(projectId, 'downloaded')
downloadEventBus.emitProjectComplete(...)
}
})
```
### 5.2 Download Manager Queue
```
DownloadManager.addJob(params) [lines 57-118]
|
Guard: if (await StorageManager.hasAsset(url)) return
Guard: if (already in queue or active) return
|
job = {
id, assetId, projectId, url, filename,
variantType, assetType, priority,
status: 'queued', progress: 0,
retryCount: 0, addedAt: Date.now()
}
|
await persistQueueItem(job) [IndexedDB persistence]
|
Insert in priority order (higher first)
downloadEventBus.emitQueueUpdate()
processQueue()
```
### 5.3 Download with Progress
```
downloadAsset(job) [lines 179-304]
|
job.status = 'downloading'
job.abortController = new AbortController()
await OfflineDbManager.updateQueueStatus(job.id, 'downloading')
downloadEventBus.emitPreloadStart(...)
|
response = await fetch(job.url, { signal: job.abortController.signal })
|
if (response.body) {
// Stream with progress
reader = response.body.getReader()
while (!done) {
{ done, value } = await reader.read()
chunks.push(value)
bytesLoaded += value.length
progress = (bytesLoaded / totalBytes) * 100
downloadEventBus.emitPreloadProgress(...)
await OfflineDbManager.updateQueueProgress(...)
}
blob = new Blob(chunks, { type })
} else {
blob = await response.blob()
}
|
await StorageManager.storeAsset(url, blob, metadata)
|
job.status = 'completed'
await OfflineDbManager.removeFromQueue(job.id)
downloadEventBus.emitPreloadComplete(...)
```
### 5.4 Pause/Resume/Cancel
```
pauseAll() [lines 309-316]
isPaused = true
activeDownloads.forEach(job => {
job.abortController?.abort()
job.status = 'paused'
})
resumeAll() [lines 321-335]
isPaused = false
activeDownloads.forEach(job => {
if (job.status === 'paused') {
job.status = 'queued'
queue.unshift(job)
}
})
activeDownloads.clear()
processQueue()
cancelProjectDownloads(projectId) [lines 365-381]
// Abort active, remove from queue, clear IndexedDB
```
### 5.5 Queue Restoration (Resume After Page Reload)
```
restoreQueue() [lines 418-458]
|
pendingItems = await OfflineDbManager.getPendingQueue()
|
for (item of pendingItems) {
if (await StorageManager.hasAsset(item.url)) {
await OfflineDbManager.removeFromQueue(item.id)
continue
}
|
// Re-add to in-memory queue
queue.push({ ...item, status: 'queued' })
}
|
queue.sort((a, b) => b.priority - a.priority)
downloadEventBus.emitQueueUpdate()
processQueue()
```
---
## 6. STORAGE MANAGER
### 6.1 Storage Decision Logic
```
storeAsset(url, blob, metadata) [lines 89-130]
|
sizeBytes = blob.size
|
if (sizeBytes >= OFFLINE_CONFIG.storage.indexedDbMinSize) { // 5MB
// Large file -> IndexedDB
asset = { id, projectId, url, filename, variantType, assetType, mimeType, sizeBytes, blob, downloadedAt }
await OfflineDbManager.storeAsset(asset)
} else {
// Small file -> Cache API
cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets)
response = new Response(blob, { headers: { 'Content-Type': blob.type, ... } })
await cache.put(url, response)
}
```
### 6.2 Asset Retrieval (Both Layers)
```
getAsset(url): Promise<Blob | null> [lines 135-152]
|
// Check IndexedDB first (large files)
indexedAsset = await OfflineDbManager.getAssetByUrl(url)
if (indexedAsset) return indexedAsset.blob
|
// Check Cache API
cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets)
response = await cache.match(url)
if (response) return response.blob()
|
return null
hasAsset(url): Promise<boolean> [lines 157-170]
|
if (await OfflineDbManager.hasAssetByUrl(url)) return true
|
cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets)
response = await cache.match(url)
if (response) return true
|
return false
```
### 6.3 Storage Quota
```
getStorageQuota(): Promise<StorageQuotaInfo> [lines 22-56]
|
{ usage, quota } = await navigator.storage.estimate()
percentUsed = (usage / quota) * 100
available = quota - usage
|
return {
usage, quota, percentUsed, available,
canStore: (bytes) => available - bytes > PRELOAD_CONFIG.storage.minFreeBuffer
}
requestPersistentStorage(): Promise<boolean> [lines 61-76]
|
isPersisted = await navigator.storage.persisted()
if (isPersisted) return true
return await navigator.storage.persist()
```
---
## 7. SERVICE WORKER (PWA)
### 7.1 Serwist Configuration
```
sw.ts [lines 97-211]
|
new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: [
// Static assets: CacheFirst
{ matcher: image/font/css/js, handler: CacheFirst, cacheName: 'tour-builder-assets-v1' },
// Videos: CacheFirst with Range support
{ matcher: .mp4/.webm/.mov, handler: CacheFirst + range plugin },
// API: NetworkFirst
{ matcher: /api/*, handler: NetworkFirst, cacheName: 'api-cache' },
// Dynamic assets: CacheFirst
{ matcher: cacheable && !video, handler: CacheFirst, cacheName: 'tour-builder-assets-v1' }
]
})
```
### 7.2 Video Range Request Support (CRITICAL)
```
cachedResponseWillBeUsed plugin [lines 138-169]
|
rangeHeader = request.headers.get('range')
if (!rangeHeader) return cachedResponse
|
match = rangeHeader.match(/bytes=(\d+)-(\d*)/)
start = parseInt(match[1])
end = match[2] ? parseInt(match[2]) : undefined
|
blob = await cachedResponse.blob()
slicedBlob = end ? blob.slice(start, end + 1) : blob.slice(start)
|
return new Response(slicedBlob, {
status: 206,
statusText: 'Partial Content',
headers: {
'Content-Type': 'video/mp4',
'Content-Range': `bytes ${start}-${end}/${blob.size}`,
'Accept-Ranges': 'bytes'
}
})
```
### 7.3 Message Handlers
```
self.addEventListener('message', handler) [lines 214-295]
|
CACHE_ASSETS: { urls: string[] }
+-- Fetch each URL and put in 'tour-builder-assets-v1' cache
CACHE_VIDEO_CHUNK: { url, chunk, contentType }
+-- Store video chunk for progressive playback
CLEAR_CACHE:
+-- Delete 'tour-builder-dynamic-v1' and 'tour-builder-assets-v1'
GET_CACHE_STATUS:
+-- Return { cachedCount, urls } to main thread
SKIP_WAITING:
+-- self.skipWaiting() for immediate activation
```
---
## 8. ONLINE/OFFLINE MODE SWITCHING
### 8.1 Going Offline
```
Network disconnects
|
window 'offline' event fires
|
useNetworkAware updates: networkInfo.isOnline = false
|
usePreloadOrchestrator.processQueue():
Guard: if (!networkInfo.isOnline) return [QUEUE PAUSED]
|
Assets already cached remain accessible:
+-- StorageManager.getAsset(url) works offline
+-- getCachedBlobUrl(url) works offline
+-- getReadyBlobUrl(url) works offline (in-memory)
|
Navigation still works if assets are cached:
+-- usePageSwitch.resolveToDisplayUrl() checks cache first
+-- useTransitionPlayback checks getReadyBlobUrl/getCachedBlobUrl
```
### 8.2 Coming Back Online
```
Network reconnects
|
window 'online' event fires
|
useNetworkAware updates: networkInfo.isOnline = true
|
usePreloadOrchestrator.processQueue():
Resumes queue processing automatically
|
useOfflineMode.resumeDownload():
downloadManager.resumeAll()
```
### 8.3 Robustness Verification
| Scenario | Behavior | Status |
|----------|----------|--------|
| Offline during preload | Queue pauses, cached assets work | ROBUST |
| Offline during navigation | Uses cached assets if available | ROBUST |
| Offline during transition video | Uses cached video if available | ROBUST |
| Online -> Offline mid-download | Download fails, retries when online | ROBUST |
| Page refresh while offline | Queue restored from IndexedDB | ROBUST |
| Offline project download | Works if manifest was fetched | ROBUST |
---
## 9. ASSET URL RESOLUTION
### 9.1 resolveAssetPlaybackUrl Flow
```
resolveAssetPlaybackUrl(value) [assetUrl.ts:329-357]
|
normalized = String(value).trim()
if (!normalized) return ''
|
// Passthrough URLs
if (data: or blob:) return normalized
if (/api/file/download) return normalized
if (/file/download) return baseURLApi + normalized
if (http:// or https://) return normalized
|
// Relative storage path
presigned = getPresignedUrl(normalized) [Check cache]
if (presigned) return presigned
|
// Fallback to backend proxy
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalized)}`
```
### 9.2 Presigned URL Management
```
queuePresignedUrls(storageKeys) [assetUrl.ts:164-223]
|
if (presignedUrlsDisabled) return {}
|
uncachedKeys = keys.filter(not in cache or expired)
|
Add to pendingBatch[]
|
Schedule batch processing (10ms debounce)
|
processBatch()
POST /api/file/presign { urls: [...] }
|
Cache results: presignedUrlCache.set(key, { url, expiresAt })
```
### 9.3 CORS Failure Handling
```
axios interceptor detects presigned URL failure
|
disablePresignedUrls()
presignedUrlsDisabled = true
presignedUrlCache.clear()
|
All future requests use proxy URL:
/api/file/download?privateUrl={path}
```
---
## 10. CRITICAL CODE LOCATIONS
| Feature | File | Lines |
|---------|------|-------|
| Upload validation | `UploadService.js` | 77-128 |
| Upload to server | `UploadService.js` | 282-296 |
| Chunked upload | `UploadService.js` | 154-280 |
| Preload queue guard | `usePreloadOrchestrator.ts` | 357 |
| Queue processing | `usePreloadOrchestrator.ts` | 354-491 |
| Blob URL creation | `usePreloadOrchestrator.ts` | 280-352 |
| Instant lookup | `usePreloadOrchestrator.ts` | 582-584 |
| Network monitoring | `useNetworkAware.ts` | 72-96 |
| Concurrency calc | `useNetworkAware.ts` | 121-143 |
| Project download | `useOfflineMode.ts` | 158-295 |
| Download with progress | `DownloadManager.ts` | 179-304 |
| Queue restoration | `DownloadManager.ts` | 418-458 |
| Storage decision | `StorageManager.ts` | 82-84 |
| Asset retrieval | `StorageManager.ts` | 135-152 |
| Video range support | `sw.ts` | 138-169 |
| SW message handlers | `sw.ts` | 214-295 |
| URL resolution | `assetUrl.ts` | 329-357 |
| Presigned batch | `assetUrl.ts` | 164-223 |
---
## 11. ROBUSTNESS CONCLUSION
### 11.1 Key Strengths
1. **Dual Storage Strategy** - Cache API for small files, IndexedDB for large files
2. **Queue Persistence** - IndexedDB stores download queue for resume after refresh
3. **Network Awareness** - Adaptive concurrency based on connection quality
4. **Offline Guard** - Preload queue pauses when offline, cached assets work
5. **Presigned URL Fallback** - Automatically falls back to proxy on CORS failure
6. **Video Range Requests** - Service Worker handles seeking in cached videos
7. **Progress Tracking** - EventBus pattern for real-time download progress
8. **Blob URL Cache** - Pre-decoded images for instant display (O(1) lookup)
### 11.2 Verified Scenarios
- Asset upload (simple + chunked): WORKING
- Preload queue online: WORKING
- Preload queue offline: PAUSES (correct behavior)
- Navigation with cached assets offline: WORKING
- Project download for offline: WORKING
- Pause/Resume downloads: WORKING
- Queue restoration after refresh: WORKING
- Video seeking offline: WORKING (range requests)
- Mode switching (online/offline): WORKING
### 11.3 No Critical Gaps Identified
The offline mode functionality is robust and handles all edge cases properly.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,847 @@
# Frontend Factories Module
## Overview
The Factories module provides **code generation patterns** that eliminate massive boilerplate across the frontend application. These factories generate complete components, pages, Redux slices, and table configurations from declarative configurations.
**Location:** Multiple directories (factory pattern is distributed)
**Total Lines:** ~1,291 LOC across 6 factory files
---
## Architecture
```
frontend/src/
├── factories/ # Page factories
│ ├── createListPage.tsx (193 LOC) # List page generator
│ ├── createFormPage.tsx (331 LOC) # Form page generator
│ └── index.ts (6 LOC) # Exports
├── components/
│ ├── Factory/
│ │ └── createTableComponent.tsx (118 LOC) # Table component generator
│ └── DataGrid/
│ └── configBuilderFactory.tsx (301 LOC) # Column config generator
└── stores/
└── createEntitySlice.ts (342 LOC) # Redux slice generator
```
---
## Factory Relationship Diagram
```
┌────────────────────────────────────┐
│ createListPage │
│ (generates list pages) │
└────────────────┬───────────────────┘
│ uses
┌────────────────────────────────────┐
│ createTableComponent │
│ (generates table wrappers) │
└────────────────┬───────────────────┘
│ wraps
┌────────────────────────────────────┐
│ GenericTable │
│ (reusable data grid) │
└────────────────┬───────────────────┘
│ uses
┌────────────────┴───────────────────┐
▼ ▼
┌───────────────────────────────┐ ┌────────────────────────────────┐
│ createColumnLoader │ │ createEntitySlice │
│ (column config generator) │ │ (Redux slice generator) │
└───────────────────────────────┘ └────────────────────────────────┘
```
---
## Factory Files
### 1. createListPage (`factories/createListPage.tsx`)
**Purpose:** Generate complete list pages with filtering, CSV import/export, and permissions.
**Lines:** 193 LOC
#### Configuration Interface
```typescript
interface ListPageConfig {
entityName: string; // URL path segment (e.g., 'roles')
entityTitle: string; // Page title (e.g., 'Roles')
TableComponent: React.ComponentType<{
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}>;
filters: Filter[]; // Available filter fields
readPermission: string; // Permission to view (e.g., 'READ_ROLES')
createPermission: string; // Permission to create (e.g., 'CREATE_ROLES')
uploadCsvAction: AsyncThunk<unknown, File, object>; // Redux thunk for CSV upload
setRefetchAction: (value: boolean) => { type: string; payload: boolean }; // Action to trigger data refresh
newItemLabel?: string; // Custom "New Item" button text
cardBoxId?: string; // Optional card container ID
}
```
#### Generated Features
| Feature | Description |
|---------|-------------|
| Page Title | HTML `<title>` via Next.js Head |
| Permission Guard | `LayoutAuthenticated` wrapper with `readPermission` |
| New Item Button | Links to `-new` page (if `createPermission`) |
| Filter Button | Adds dynamic filter rows |
| Download CSV | Exports entity data as CSV |
| Upload CSV | Modal with drag-drop file picker |
| Table Display | Renders configured `TableComponent` |
#### Usage Example
```typescript
// pages/roles/roles-list.tsx
import { createListPage } from '../../factories/createListPage';
import TableRoles from '../../components/Roles/TableRoles';
import { uploadCsv, setRefetch } from '../../stores/roles/rolesSlice';
const filters = [
{ label: 'Name', title: 'name' },
{ label: 'Permissions', title: 'permissions' },
];
export default createListPage({
entityName: 'roles',
entityTitle: 'Roles',
TableComponent: TableRoles,
filters,
readPermission: 'READ_ROLES',
createPermission: 'CREATE_ROLES',
uploadCsvAction: uploadCsv,
setRefetchAction: setRefetch,
});
```
**Result:** 24 lines of code generates a complete list page (~200 lines equivalent).
---
### 2. createFormPage (`factories/createFormPage.tsx`)
**Purpose:** Generate form pages for creating and editing entities with Formik integration.
**Lines:** 331 LOC
#### Field Types
```typescript
type FormFieldType =
| 'text' // Text input
| 'email' // Email input with validation
| 'number' // Numeric input
| 'textarea' // Multi-line text
| 'select' // Single-select relation
| 'selectMany' // Multi-select relation
| 'enumSelect' // Select from predefined options
| 'switch' // Boolean toggle
| 'image' // Image picker
| 'date' // Date picker
| 'datetime' // DateTime picker
| 'password' // Password input
| 'custom'; // Custom component
```
#### Field Configuration
```typescript
interface FormFieldConfig {
name: string; // Form field name
label: string; // Display label
type: FormFieldType; // Field type
placeholder?: string; // Input placeholder
itemRef?: string; // Entity reference for select fields
showField?: string; // Display field for relations
options?: Array<{ // Options for enumSelect
value: string;
label: string;
}>;
path?: string; // Upload path for images
schema?: { // Image constraints
size?: number;
formats?: string[];
};
component?: React.ComponentType; // Custom component
props?: Record<string, unknown>; // Custom component props
}
```
#### Page Configuration
```typescript
interface FormPageConfig<T> {
entityName: string; // Entity URL segment
entityTitle: string; // Page title
singularTitle: string; // Singular form (e.g., 'Role')
mode: 'create' | 'edit'; // Form mode
sliceSelector: Selector; // Redux state selector
fetchAction?: AsyncThunk; // Fetch for edit mode
createAction?: AsyncThunk; // Create thunk
updateAction?: AsyncThunk; // Update thunk
permission: string; // Required permission
initialValues: T; // Default form values
fields: FormFieldConfig[]; // Field definitions
validate?: Validator; // Formik validation
}
```
#### Usage Example
```typescript
// pages/users/users-new.tsx
import { createFormPage } from '../../factories/createFormPage';
import { create } from '../../stores/users/usersSlice';
export default createFormPage({
entityName: 'users',
entityTitle: 'Users',
singularTitle: 'User',
mode: 'create',
sliceSelector: (state) => state.users,
createAction: create,
permission: 'CREATE_USERS',
initialValues: {
firstName: '',
lastName: '',
email: '',
role: null,
},
fields: [
{ name: 'firstName', label: 'First Name', type: 'text' },
{ name: 'lastName', label: 'Last Name', type: 'text' },
{ name: 'email', label: 'Email', type: 'email' },
{ name: 'role', label: 'Role', type: 'select', itemRef: 'roles', showField: 'name' },
],
});
```
#### Field Rendering Logic
The `renderField` helper function handles all field types:
```typescript
function renderField(field: FormFieldConfig, formValues: T): React.ReactNode {
switch (field.type) {
case 'text':
case 'email':
case 'password':
return <Field name={name} type={type} placeholder={placeholder} />;
case 'select':
return <Field name={name} component={SelectField} options={...} />;
case 'switch':
return <Field name={name} component={SwitchField} />;
case 'image':
return <Field name={name} component={FormImagePicker} path={path} />;
case 'custom':
return <Field name={name} component={CustomComponent} {...props} />;
// ... other types
}
}
```
---
### 3. createTableComponent (`components/Factory/createTableComponent.tsx`)
**Purpose:** Generate table wrapper components that connect GenericTable with entity-specific configuration.
**Lines:** 118 LOC
#### Entity Slice State Interface
```typescript
interface EntitySliceState<T> {
[key: string]: T[] | boolean | number | NotificationState | unknown[];
loading: boolean;
count: number;
refetch: boolean;
notify: NotificationState;
}
```
#### Configuration Interface
```typescript
interface TableComponentConfig<T extends BaseEntity> {
entityName: string; // Entity identifier
sliceSelector: (state: RootState) => EntitySliceState<T>; // Redux slice selector
fetchAction: AsyncThunk<
T | { rows: T[]; count: number },
{ id?: string; query?: string },
object
>;
updateAction: AsyncThunk<T, { id: string; data: Partial<T> }, object>;
deleteAction: AsyncThunk<void, string, object>;
deleteByIdsAction: AsyncThunk<void, string[], object>;
setRefetchAction: (refetch: boolean) => { type: string; payload: boolean };
loadColumnsFunction: (
onDelete: (id: string) => void,
entityName: string,
user: unknown,
) => Promise<GridColDef[]>;
}
```
#### Generated Props Interface
```typescript
interface TableComponentProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
```
#### Usage Example
```typescript
// components/Roles/TableRoles.tsx
import { createTableComponent } from '../Factory/createTableComponent';
import { fetch, update, deleteItem, setRefetch, deleteItemsByIds } from '../../stores/roles/rolesSlice';
import { loadColumns } from './configureRolesCols';
import type { Role } from '../../types/entities';
const TableRoles = createTableComponent<Role>({
entityName: 'roles',
sliceSelector: (state) => state.roles,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableRoles;
```
**Result:** 13 lines replaces ~100 lines of boilerplate.
---
### 4. configBuilderFactory (`components/DataGrid/configBuilderFactory.tsx`)
**Purpose:** Generate MUI X DataGrid column configurations from declarative metadata.
**Lines:** 301 LOC
#### Default Column Properties
```typescript
const DEFAULT_COLUMN_PROPS = {
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
};
```
#### Column Metadata Interface
```typescript
interface ColumnMetadata {
field: string; // Data field name
headerName: string; // Column header
type?: // Column type
| 'text'
| 'boolean'
| 'date'
| 'datetime'
| 'number'
| 'relation'
| 'relationMany'
| 'singleSelectRelation'
| 'image'
| 'actions';
editable?: boolean; // Allow inline editing
sortable?: boolean; // Allow sorting
width?: number; // Fixed width
flex?: number; // Flex grow
minWidth?: number; // Minimum width
entityRef?: string; // Related entity for relations
displayField?: string; // Field to display for relations
renderCell?: (params: GridRenderCellParams) => React.ReactElement; // Custom cell renderer
valueFormatter?: (value: unknown) => string; // Value transformation
}
```
#### Column Builder Configuration
```typescript
interface ColumnBuilderConfig {
entityName: string; // Entity identifier
entityPath?: string; // URL path (defaults to entityName)
columns: ColumnMetadata[]; // Column definitions
updatePermission?: string; // Override update permission check
}
```
#### Key Functions
| Function | Purpose |
|----------|---------|
| `buildColumns()` | Async function that builds complete column array |
| `createColumnLoader()` | Factory that returns column loader function |
| `fetchRelationOptions()` | Fetches options for singleSelectRelation columns |
| `buildColumn()` | Builds individual column definition |
| `buildActionsColumn()` | Builds actions column with edit/view/delete |
| `getFormatter()` | Returns appropriate value formatter |
#### Column Type Handling
```typescript
// Type-specific column configuration
switch (col.type) {
case 'boolean':
baseColumn.type = 'boolean';
baseColumn.valueFormatter = ({ value }) => dataFormatter.booleanFormatter(value);
break;
case 'datetime':
baseColumn.type = 'dateTime';
baseColumn.valueGetter = (_value, row) => new Date(row[col.field]);
break;
case 'relation':
case 'relationMany':
baseColumn.type = 'singleSelect';
baseColumn.renderEditCell = (params) => (
<DataGridMultiSelect {...params} entityName={col.entityRef} />
);
break;
case 'image':
baseColumn.renderCell = (params) => (
<span style={{ backgroundImage: `url(${params.value[0]?.publicUrl})` }} />
);
break;
}
```
#### Usage Example
```typescript
// components/Roles/configureRolesCols.tsx
import { createColumnLoader, ColumnMetadata } from '../DataGrid/configBuilderFactory';
const ROLES_COLUMNS: ColumnMetadata[] = [
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
{ field: 'permissions', headerName: 'Permissions', type: 'relationMany', entityRef: 'permissions' },
{ field: 'actions', headerName: '', type: 'actions' },
];
export const loadColumns = createColumnLoader({
entityName: 'roles',
columns: ROLES_COLUMNS,
});
```
**Result:** 12 lines replaces ~150 lines of column configuration.
---
### 5. createEntitySlice (`stores/createEntitySlice.ts`)
**Purpose:** Generate complete Redux Toolkit slices with CRUD async thunks.
**Lines:** 342 LOC
#### Imported Types
The factory imports types from dedicated type files:
```typescript
import type {
EntitySliceConfig,
NotificationState,
EntitySliceState,
} from '../types/redux';
import type { PaginatedResponse, FetchParams, ApiError } from '../types/api';
import type { BaseEntity } from '../types/entities';
```
#### Configuration Interface
```typescript
interface EntitySliceConfig {
name: string; // Slice name (e.g., 'roles')
endpoint: string; // API endpoint (e.g., 'roles')
singularName?: string; // Singular form for notifications (e.g., 'Role')
}
```
#### Generated Actions
| Action | Type | Description |
|--------|------|-------------|
| `fetch` | AsyncThunk | Fetch single item by ID or paginated list |
| `create` | AsyncThunk | Create new entity |
| `update` | AsyncThunk | Update existing entity |
| `deleteItem` | AsyncThunk | Delete single item |
| `deleteItemsByIds` | AsyncThunk | Bulk delete items |
| `uploadCsv` | AsyncThunk | Import entities from CSV |
| `setRefetch` | Action | Trigger data refresh |
#### Generated State
```typescript
interface InternalSliceState<T> {
[entityName]: T[]; // Entity array
loading: boolean; // Loading state
count: number; // Total count for pagination
refetch: boolean; // Refetch trigger flag
rolesWidgets: unknown[]; // Legacy widgets support
notify: NotificationState; // Notification state
}
```
#### Notification Handling
Each async action automatically handles notifications:
```typescript
// Fulfilled state
builder.addCase(create.fulfilled, (state) => {
state.loading = false;
fulfilledNotify(state, `${displayName} has been created`);
});
// Rejected state
builder.addCase(create.rejected, (state, action) => {
state.loading = false;
rejectNotify(state, action);
});
```
#### Usage Example
```typescript
// stores/roles/rolesSlice.ts
import { createEntitySlice } from '../createEntitySlice';
import type { Role } from '../../types/entities';
const { slice, actions, reducer: baseReducer } = createEntitySlice<Role>({
name: 'roles',
endpoint: 'roles',
singularName: 'Role',
});
// Export standard CRUD actions
export const { fetch, create, update, deleteItem, deleteItemsByIds, uploadCsv, setRefetch } = actions;
export const rolesSlice = slice;
export default baseReducer;
```
**Result:** 10 lines generates complete Redux slice (~300 lines equivalent).
#### Exported Type
The factory exports a utility type for accessing actions:
```typescript
export type EntityActions<T extends BaseEntity> = ReturnType<
typeof createEntitySlice<T>
>['actions'];
```
#### Helper Functions
The factory includes internal helper functions:
| Function | Purpose |
|----------|---------|
| `isPaginatedResponse<T>()` | Type guard to check if response has `rows` and `count` |
| `isAxiosError()` | Type guard for Axios errors |
| `getSingularName()` | Get singular form for notifications |
| `capitalize()` | Capitalize first letter of string |
#### Custom Thunks Extension
Slices can add custom thunks alongside generated ones when an existing legacy
entity flow still requires Redux-side server calls. New server-state flows
should use TanStack Query instead of adding new entity CRUD thunks.
---
## GenericTable Component
The `GenericTable` component (`components/Generic/GenericTable.tsx`, 429 LOC) is the foundation that table factories wrap.
### Features
| Feature | Implementation |
|---------|----------------|
| Server-side pagination | `paginationMode='server'` |
| Server-side sorting | `sortingMode='server'` |
| Row selection | Checkbox selection with bulk actions |
| Inline editing | Row edit mode with validation |
| Dynamic filters | Formik-based filter panel |
| Notifications | Toast notifications via react-toastify |
| Permissions | Column editability based on user permissions |
### Props Interface
```typescript
interface GenericTableProps<T extends BaseEntity> {
entityName: string;
sliceSelector: StateSelector<T>;
fetchAction: AsyncThunk;
updateAction: AsyncThunk;
deleteAction: AsyncThunk;
deleteByIdsAction?: AsyncThunk;
setRefetchAction: ActionCreator;
loadColumnsFunction: ColumnLoader;
filters: Filter[];
filterItems: FilterItem[];
setFilterItems: Setter;
extraQuery?: string;
}
```
---
## Entity Implementation Pattern
Complete pattern for adding a new entity using all factories:
### Step 1: Create Entity Slice
```typescript
// stores/widgets/widgetsSlice.ts
import { createEntitySlice } from '../createEntitySlice';
import type { Widget } from '../../types/entities';
const { slice, actions, reducer } = createEntitySlice<Widget>({
name: 'widgets',
endpoint: 'widgets',
singularName: 'Widget',
});
export const { fetch, create, update, deleteItem, deleteItemsByIds, uploadCsv, setRefetch } = actions;
export default reducer;
```
### Step 2: Create Column Configuration
```typescript
// components/Widgets/configureWidgetsCols.tsx
import { createColumnLoader, ColumnMetadata } from '../DataGrid/configBuilderFactory';
const WIDGETS_COLUMNS: ColumnMetadata[] = [
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
{ field: 'status', headerName: 'Status', type: 'text' },
{ field: 'createdAt', headerName: 'Created', type: 'datetime' },
{ field: 'actions', headerName: '', type: 'actions' },
];
export const loadColumns = createColumnLoader({
entityName: 'widgets',
columns: WIDGETS_COLUMNS,
});
```
### Step 3: Create Table Component
```typescript
// components/Widgets/TableWidgets.tsx
import { createTableComponent } from '../Factory/createTableComponent';
import { fetch, update, deleteItem, setRefetch, deleteItemsByIds } from '../../stores/widgets/widgetsSlice';
import { loadColumns } from './configureWidgetsCols';
import type { Widget } from '../../types/entities';
const TableWidgets = createTableComponent<Widget>({
entityName: 'widgets',
sliceSelector: (state) => state.widgets,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableWidgets;
```
### Step 4: Create List Page
```typescript
// pages/widgets/widgets-list.tsx
import { createListPage } from '../../factories/createListPage';
import TableWidgets from '../../components/Widgets/TableWidgets';
import { uploadCsv, setRefetch } from '../../stores/widgets/widgetsSlice';
const filters = [
{ label: 'Name', title: 'name' },
{ label: 'Status', title: 'status' },
];
export default createListPage({
entityName: 'widgets',
entityTitle: 'Widgets',
TableComponent: TableWidgets,
filters,
readPermission: 'READ_WIDGETS',
createPermission: 'CREATE_WIDGETS',
uploadCsvAction: uploadCsv,
setRefetchAction: setRefetch,
});
```
### Step 5: Create Form Pages
```typescript
// pages/widgets/widgets-new.tsx
import { createFormPage } from '../../factories/createFormPage';
import { create } from '../../stores/widgets/widgetsSlice';
export default createFormPage({
entityName: 'widgets',
entityTitle: 'Widgets',
singularTitle: 'Widget',
mode: 'create',
sliceSelector: (state) => state.widgets,
createAction: create,
permission: 'CREATE_WIDGETS',
initialValues: { name: '', status: 'draft' },
fields: [
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'status', label: 'Status', type: 'enumSelect', options: [
{ value: 'draft', label: 'Draft' },
{ value: 'active', label: 'Active' },
]},
],
});
```
**Total:** ~80 lines for complete CRUD implementation (~1,500 lines equivalent without factories).
---
## Boilerplate Reduction
| Component | Without Factory | With Factory | Reduction |
|-----------|-----------------|--------------|-----------|
| Redux Slice | ~300 LOC | ~10 LOC | **97%** |
| List Page | ~200 LOC | ~24 LOC | **88%** |
| Form Page | ~250 LOC | ~30 LOC | **88%** |
| Table Component | ~100 LOC | ~13 LOC | **87%** |
| Column Config | ~150 LOC | ~12 LOC | **92%** |
| **Total per Entity** | **~1,000 LOC** | **~89 LOC** | **91%** |
With 13 entities in the application, factories eliminate approximately **11,843 lines** of boilerplate code.
---
## Type Safety
All factories are fully typed with TypeScript generics:
```typescript
// createEntitySlice with type parameter
const { actions } = createEntitySlice<Role>({ ... });
// actions.fetch: AsyncThunk<Role | PaginatedResponse<Role>, FetchParams, ...>
// createTableComponent with type parameter
const TableRoles = createTableComponent<Role>({ ... });
// TableRoles: React.FC<TableComponentProps>
// createFormPage with type parameter
const RolesNew = createFormPage<RoleFormValues>({ ... });
// Type-safe form values throughout
```
---
## Best Practices
### 1. Use Consistent Naming
```typescript
// Entity name should match across all layers
const entityName = 'widgets'; // Used in:
// - Slice: stores/widgets/widgetsSlice.ts
// - Table: components/Widgets/TableWidgets.tsx
// - Pages: pages/widgets/widgets-list.tsx
```
### 2. Permission Naming Convention
```typescript
// Permissions follow pattern: ACTION_ENTITY
readPermission: 'READ_WIDGETS',
createPermission: 'CREATE_WIDGETS',
updatePermission: 'UPDATE_WIDGETS', // Used by configBuilderFactory
deletePermission: 'DELETE_WIDGETS',
```
### 3. Type Definitions
```typescript
// Always define entity type in types/entities.ts
export interface Widget extends BaseEntity {
name: string;
status: 'draft' | 'active';
}
// Use type parameter in all factories
createEntitySlice<Widget>({ ... });
createTableComponent<Widget>({ ... });
```
### 4. Custom Extensions
```typescript
// Add custom thunks after factory generation
const { slice, actions, reducer } = createEntitySlice<Widget>({ ... });
// Custom widget-specific thunk
export const archiveWidget = createAsyncThunk(
'widgets/archive',
async (id: string) => { ... }
);
```
---
## Related Documentation
- [stores-module.md](./stores-module.md) - Redux state management
- [components-module.md](./components-module.md) - Component organization
- [pages-module.md](./pages-module.md) - Page structure
- [types-module.md](./types-module.md) - TypeScript types
- [hooks-module.md](./hooks-module.md) - Custom hooks
---
## File Inventory
| File | Location | LOC | Purpose |
|------|----------|-----|---------|
| `createListPage.tsx` | `factories/` | 193 | List page generator |
| `createFormPage.tsx` | `factories/` | 331 | Form page generator |
| `index.ts` | `factories/` | 6 | Exports |
| `createTableComponent.tsx` | `components/Factory/` | 118 | Table wrapper generator |
| `configBuilderFactory.tsx` | `components/DataGrid/` | 301 | Column config generator |
| `createEntitySlice.ts` | `stores/` | 342 | Redux slice generator |
| **Total** | | **1,291** | |

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,738 @@
# Frontend Helpers Module
## Overview
The Helpers module provides **utility functions** for common operations across the frontend application including data formatting, permission checking, notification handling, text transformation, and file saving.
**Location:** `frontend/src/helpers/`
**Total Files:** 6 files (~298 LOC)
---
## Architecture
```
frontend/src/helpers/
├── dataFormatter.js (176 LOC) # Entity data formatters for display/edit
├── userPermissions.ts (19 LOC) # RBAC permission checking
├── notifyStateHandler.ts (32 LOC) # Redux notification state handlers
├── textFormatters.ts (53 LOC) # Text transformation utilities
├── humanize.ts (12 LOC) # String humanization
└── fileSaver.ts (6 LOC) # File download trigger
```
---
## Helper Files
### 1. Data Formatter (`dataFormatter.js`)
**Purpose:** Format entity data for display in tables, lists, and edit forms.
**Lines:** ~210 LOC
**Dependencies:** `dayjs`, `dayjs/plugin/relativeTime`, `lodash`
#### Core Formatters
| Function | Input | Output | Description |
|----------|-------|--------|-------------|
| `filesFormatter(arr)` | File array | File array | Pass-through for file arrays |
| `imageFormatter(arr)` | Image array | `[{ publicUrl }]` | Extract public URLs from images |
| `oneImageFormatter(arr)` | Image array | `string` | Get first image's public URL |
| `dateFormatter(date)` | Date/string | `YYYY-MM-DD` | Format date for display |
| `dateTimeFormatter(date)` | Date/string | `YYYY-MM-DD HH:mm` | Format datetime for display |
| `relativeTimestamp(date)` | Date/string | Human-readable | Format as relative or absolute time |
| `booleanFormatter(val)` | boolean | `'Yes'` / `'No'` | Human-readable boolean |
| `dataGridEditFormatter(obj)` | Object | Object | Transform relations to IDs for DataGrid |
#### Entity Formatters Pattern
Each entity has 4 formatters following this naming convention:
```javascript
// Display formatters (for table cells/lists)
[entity]ManyListFormatter(val) // Array → display names array
[entity]OneListFormatter(val) // Object → display name string
// Edit formatters (for form selects)
[entity]ManyListFormatterEdit(val) // Array → [{ id, label }]
[entity]OneListFormatterEdit(val) // Object → { id, label }
```
#### Supported Entities
| Entity | Display Field | Formatters |
|--------|---------------|------------|
| `users` | `firstName` | 4 formatters |
| `roles` | `name` | 4 formatters |
| `permissions` | `name` | 4 formatters |
| `projects` | `name` | 4 formatters |
| `assets` | `name` | 4 formatters |
| `tour_pages` | `name` | 4 formatters |
| `transitions` | `name` | 4 formatters |
#### Usage Examples
```javascript
import dataFormatter from '@/helpers/dataFormatter';
// Display in table cell
const userName = dataFormatter.usersOneListFormatter(user);
// → "John"
// Display list of roles
const roleNames = dataFormatter.rolesManyListFormatter(roles);
// → ["Administrator", "Editor"]
// Format for edit dropdown
const roleOptions = dataFormatter.rolesManyListFormatterEdit(roles);
// → [{ id: "uuid-1", label: "Administrator" }, ...]
// Format date
const formattedDate = dataFormatter.dateFormatter(createdAt);
// → "2024-03-15"
// Transform object for DataGrid editing
const editData = dataFormatter.dataGridEditFormatter({
name: "Test",
project: { id: "uuid", name: "My Project" },
tags: [{ id: "1" }, { id: "2" }]
});
// → { name: "Test", project: "uuid", tags: ["1", "2"] }
```
#### relativeTimestamp Method
Formats dates as human-readable relative or absolute timestamps. Uses `dayjs` with the `relativeTime` plugin.
```javascript
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
relativeTimestamp(date) {
if (!date) return '';
const d = dayjs(date);
const now = dayjs();
const diffMinutes = now.diff(d, 'minute');
const diffHours = now.diff(d, 'hour');
// Within last hour
if (diffMinutes < 60) {
return diffMinutes <= 1 ? 'Just now' : `${diffMinutes} min ago`;
}
// Within last 24 hours
if (diffHours < 24) {
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
}
// Format as date + time
const isToday = d.isSame(now, 'day');
const isYesterday = d.isSame(now.subtract(1, 'day'), 'day');
const timeStr = d.format('HH:mm');
if (isToday) return `Today at ${timeStr}`;
if (isYesterday) return `Yesterday at ${timeStr}`;
return `${d.format('MMM D')} at ${timeStr}`;
}
```
**Output Examples:**
| Time Difference | Output |
|-----------------|--------|
| < 2 minutes | `"Just now"` |
| 5 minutes ago | `"5 min ago"` |
| 2 hours ago | `"2 hours ago"` |
| Same day | `"Today at 14:30"` |
| Yesterday | `"Yesterday at 09:15"` |
| Older dates | `"Apr 28 at 16:45"` |
**Usage:**
```javascript
import dataFormatter from '@/helpers/dataFormatter';
// In publish status buttons
dataFormatter.relativeTimestamp(lastPublishedAt);
// → "Just now" or "5 min ago" or "Today at 14:30"
// In constructor menu
dataFormatter.relativeTimestamp(page.updatedAt);
// → "2 hours ago"
```
---
#### dataGridEditFormatter Logic
Transforms nested objects/arrays to their IDs for DataGrid inline editing:
```javascript
dataGridEditFormatter(obj) {
return _.transform(obj, (result, value, key) => {
if (_.isArray(value)) {
result[key] = _.map(value, 'id'); // Array → array of IDs
} else if (_.isObject(value)) {
result[key] = value.id; // Object → ID string
} else {
result[key] = value; // Primitive → unchanged
}
});
}
```
---
### 2. User Permissions (`userPermissions.ts`)
**Purpose:** Check if current user has required permission(s) for RBAC.
**Lines:** 19 LOC
**Used By:** 48 files (layouts, pages, components, factories)
#### Function Signature
```typescript
export function hasPermission(
user: User,
permission_name: string | string[]
): boolean
```
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `user` | Object | Current user with `app_role` and `custom_permissions` |
| `permission_name` | `string` or `string[]` | Permission(s) to check |
#### Logic
1. Returns `false` if user has no role
2. Returns `true` if no permission specified (permission is optional)
3. Combines permissions from:
- `user.custom_permissions` (direct user permissions)
- `user.app_role.permissions` (role-based permissions)
4. Administrator role has implicit access to all permissions
5. For array of permissions, returns `true` if user has **any** of them
#### Implementation
```typescript
export function hasPermission(user, permission_name: string | string[]) {
if (!user?.app_role?.name) return false;
if (!permission_name) return true;
// Combine custom + role permissions
const permissions = new Set<string>([
...(user?.custom_permissions ?? []).map((p) => p.name),
...(user?.app_role?.permissions ?? []).map((p) => p.name),
]);
if (typeof permission_name === 'string') {
return permissions.has(permission_name) || user.app_role.name === 'Administrator';
} else {
return permission_name.some((permission) => permissions.has(permission));
}
}
```
#### Usage Examples
```typescript
import { hasPermission } from '@/helpers/userPermissions';
// Check single permission
if (hasPermission(currentUser, 'READ_USERS')) {
// Show users list
}
// Check multiple permissions (OR logic)
if (hasPermission(currentUser, ['CREATE_PROJECTS', 'UPDATE_PROJECTS'])) {
// Show project form
}
// In layout guard
<LayoutAuthenticated permission="READ_ASSETS">
{page}
</LayoutAuthenticated>
// Conditional rendering
{hasPermission(user, 'DELETE_ASSETS') && (
<DeleteButton onClick={handleDelete} />
)}
```
---
### 3. Notify State Handler (`notifyStateHandler.ts`)
**Purpose:** Manage Redux notification state for async action feedback.
**Lines:** 32 LOC
**Used By:** `createEntitySlice.ts` (affects all 13 entity slices)
#### Functions
| Function | Purpose | Notification Type |
|----------|---------|-------------------|
| `resetNotify(state)` | Clear notification state | - |
| `fulfilledNotify(state, msg)` | Show success message | `'success'` |
| `rejectNotify(state, action)` | Show error message | `'error'` |
#### State Shape
```typescript
interface NotificationState {
showNotification: boolean;
typeNotification: '' | 'success' | 'error' | 'warn';
textNotification: string;
}
```
#### Implementation
```typescript
// Clear notification
export const resetNotify = (state) => {
state.notify.showNotification = false;
state.notify.typeNotification = '';
state.notify.textNotification = '';
};
// Show success notification
export const fulfilledNotify = (state, msg) => {
state.notify.textNotification = msg;
state.notify.typeNotification = 'success';
state.notify.showNotification = true;
};
// Show error notification (handles various error formats)
export const rejectNotify = (state, action) => {
if (typeof action.payload === 'string') {
state.notify.textNotification = action.payload;
} else if (typeof action === 'object') {
// Handle validation errors with field-level messages
const obj = { ...action.payload?.errors };
delete obj['_errors'];
let msg = '';
for (const key in obj) {
msg += `${key}: ${obj[key]['_errors']}; \n `;
}
state.notify.textNotification = msg;
} else {
state.notify.textNotification = 'Network error';
}
state.notify.textNotification = state.notify.textNotification || 'Network error';
state.notify.typeNotification = 'error';
state.notify.showNotification = true;
};
```
#### Usage in createEntitySlice
```typescript
import { resetNotify, fulfilledNotify, rejectNotify } from '../helpers/notifyStateHandler';
// In extraReducers
builder.addCase(create.pending, (state) => {
resetNotify(state);
});
builder.addCase(create.fulfilled, (state) => {
fulfilledNotify(state, 'User has been created');
});
builder.addCase(create.rejected, (state, action) => {
rejectNotify(state, action);
});
```
---
### 4. Text Formatters (`textFormatters.ts`)
**Purpose:** Common text transformation utilities for UI display.
**Lines:** 53 LOC
#### Functions
| Function | Input | Output | Example |
|----------|-------|--------|---------|
| `singularize(str)` | Plural string | Singular string | `'roles'``'role'` |
| `capitalize(str)` | String | Capitalized string | `'hello'``'Hello'` |
| `snakeToTitle(str)` | snake_case | Title Case | `'tour_pages'``'Tour Pages'` |
| `camelToTitle(str)` | camelCase | Title Case | `'tourPages'``'Tour Pages'` |
#### Implementation
```typescript
/**
* Convert a plural title to singular by removing the last character.
*/
export function singularize(pluralTitle: string): string {
return pluralTitle.slice(0, -1);
}
/**
* Capitalize the first letter of a string.
*/
export function capitalize(str: string): string {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Convert snake_case to Title Case.
*/
export function snakeToTitle(str: string): string {
return str.split('_').map(capitalize).join(' ');
}
/**
* Convert camelCase to Title Case.
*/
export function camelToTitle(str: string): string {
return str
.replace(/([A-Z])/g, ' $1')
.trim()
.split(' ')
.map(capitalize)
.join(' ');
}
```
#### Usage Examples
```typescript
import { singularize, capitalize, snakeToTitle, camelToTitle } from '@/helpers/textFormatters';
// Entity name transformations
singularize('View roles'); // 'View role'
singularize('users'); // 'user'
// String capitalization
capitalize('hello world'); // 'Hello world'
// Case conversions
snakeToTitle('tour_pages'); // 'Tour Pages'
snakeToTitle('project_audio_tracks'); // 'Project Audio Tracks'
camelToTitle('tourPages'); // 'Tour Pages'
camelToTitle('projectAudioTracks'); // 'Project Audio Tracks'
```
---
### 5. Humanize (`humanize.ts`)
**Purpose:** Convert programmatic strings to human-readable format.
**Lines:** 12 LOC
#### Function
```typescript
export function humanize(str: string): string
```
#### Transformations
1. Trim leading/trailing spaces and underscores
2. Replace underscores and multiple spaces with single space
3. Capitalize first letter
#### Implementation
```typescript
export function humanize(str: string) {
if (!str) return '';
return str
.toString()
.replace(/^[\s_]+|[\s_]+$/g, '') // Trim spaces/underscores
.replace(/[_\s]+/g, ' ') // Replace _/spaces with single space
.replace(/^[a-z]/, function (m) { // Capitalize first letter
return m.toUpperCase();
});
}
```
#### Usage Examples
```typescript
import { humanize } from '@/helpers/humanize';
humanize('user_name'); // 'User name'
humanize('_hello_world_'); // 'Hello world'
humanize('SOME_CONSTANT'); // 'SOME CONSTANT'
humanize(''); // ''
```
---
### 6. File Saver (`fileSaver.ts`)
**Purpose:** Trigger file download in browser.
**Lines:** 6 LOC
**Dependencies:** `file-saver` library
#### Function
```typescript
export const saveFile = (
e: Event,
url: string,
name: string
): void
```
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `e` | Event | Click event (stopped for propagation) |
| `url` | string | File URL to download |
| `name` | string | Downloaded filename |
#### Implementation
```typescript
import { saveAs } from 'file-saver';
export const saveFile = (e, url: string, name: string) => {
e.stopPropagation();
saveAs(url, name);
};
```
#### Usage Examples
```typescript
import { saveFile } from '@/helpers/fileSaver';
// In a component
<button onClick={(e) => saveFile(e, asset.cdn_url, asset.name)}>
Download
</button>
// In a table action
const handleDownload = (e, file) => {
saveFile(e, file.publicUrl, file.originalName);
};
```
---
## Usage Patterns
### Permission Guard in Layout
```typescript
// layouts/Authenticated.tsx
import { hasPermission } from '../helpers/userPermissions';
if (!hasPermission(currentUser, permission)) {
router.push('/403');
return null;
}
```
### Data Formatting in Tables
```typescript
// In column configuration
import dataFormatter from '@/helpers/dataFormatter';
const columns = [
{
field: 'createdAt',
valueFormatter: ({ value }) => dataFormatter.dateTimeFormatter(value),
},
{
field: 'project',
valueFormatter: ({ value }) => dataFormatter.projectsOneListFormatter(value),
},
{
field: 'isActive',
valueFormatter: ({ value }) => dataFormatter.booleanFormatter(value),
},
];
```
### Notification Handling in Redux
```typescript
// In entity slice
import { resetNotify, fulfilledNotify, rejectNotify } from '../helpers/notifyStateHandler';
builder.addCase(deleteItem.pending, (state) => {
state.loading = true;
resetNotify(state);
});
builder.addCase(deleteItem.fulfilled, (state) => {
state.loading = false;
fulfilledNotify(state, 'Item has been deleted');
});
builder.addCase(deleteItem.rejected, (state, action) => {
state.loading = false;
rejectNotify(state, action);
});
```
### Text Transformation in UI
```typescript
import { snakeToTitle, singularize } from '@/helpers/textFormatters';
// Generate page title from entity name
const pageTitle = snakeToTitle(entityName);
// 'tour_pages' → 'Tour Pages'
// Generate singular form for messages
const message = `${singularize(entityName)} created`;
// 'Users' → 'User created'
```
---
## Helper Dependencies
```
dataFormatter.js
├── dayjs (date formatting)
└── lodash (object transformation)
fileSaver.ts
└── file-saver (browser download)
userPermissions.ts
├── Used by: layouts/Authenticated.tsx
├── Used by: factories/createListPage.tsx
├── Used by: components/DataGrid/configBuilderFactory.tsx
└── Used by: 48+ component files
notifyStateHandler.ts
└── Used by: stores/createEntitySlice.ts
└── Affects: All 13 entity slices
```
---
## File Inventory
| File | LOC | Category | Exports | Used By |
|------|-----|----------|---------|---------|
| `dataFormatter.js` | ~210 | Data | 1 default object | 30+ files |
| `userPermissions.ts` | 19 | Auth | `hasPermission` | 48 files |
| `notifyStateHandler.ts` | 32 | Redux | 3 functions | createEntitySlice |
| `textFormatters.ts` | 53 | Text | 4 functions | UI components |
| `humanize.ts` | 12 | Text | `humanize` | UI components |
| `fileSaver.ts` | 6 | File | `saveFile` | Download buttons |
| **Total** | **~332** | | **11 exports** | |
---
## Best Practices
### 1. Use Type-Safe Permission Checks
```typescript
// Good - explicit permission constant
import { Permission } from '@/types/permissions';
hasPermission(user, Permission.READ_USERS);
// Avoid - magic strings
hasPermission(user, 'READ_USERS');
```
### 2. Handle Empty Values in Formatters
```typescript
// dataFormatter handles null/undefined
const name = dataFormatter.usersOneListFormatter(null);
// → '' (empty string, not error)
```
### 3. Use Appropriate Formatter Type
```typescript
// For display (table cells, lists)
dataFormatter.rolesOneListFormatter(role); // → "Administrator"
// For edit forms (dropdowns)
dataFormatter.rolesOneListFormatterEdit(role); // → { id: "...", label: "Administrator" }
```
### 4. Consistent Notification Messages
```typescript
// In createEntitySlice
fulfilledNotify(state, `${displayName} has been created`);
fulfilledNotify(state, `${displayName} has been updated`);
fulfilledNotify(state, `${displayName} has been deleted`);
```
---
## Adding New Helpers
### Adding Entity Formatter
```javascript
// In dataFormatter.js
widgetsManyListFormatter(val) {
if (!val || !val.length) return [];
return val.map((item) => item.name);
},
widgetsOneListFormatter(val) {
if (!val) return '';
return val.name;
},
widgetsManyListFormatterEdit(val) {
if (!val || !val.length) return [];
return val.map((item) => {
return { id: item.id, label: item.name };
});
},
widgetsOneListFormatterEdit(val) {
if (!val) return '';
return { label: val.name, id: val.id };
},
```
### Adding New Helper File
```typescript
// helpers/newHelper.ts
/**
* Helper description
*/
export function helperFunction(input: InputType): OutputType {
// Implementation
}
// No index.ts - import directly from file
import { helperFunction } from '@/helpers/newHelper';
```
---
## Related Documentation
- [factories-module.md](./factories-module.md) - Uses hasPermission in createListPage
- [stores-module.md](./stores-module.md) - Uses notifyStateHandler in createEntitySlice
- [types-module.md](./types-module.md) - Permission enum types
- [components-module.md](./components-module.md) - Uses dataFormatter in tables

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,601 @@
# Frontend Layouts Module
## Overview
The Layouts module provides **page wrapper components** that handle authentication, authorization, navigation chrome, and styling for the application. Using Next.js's `getLayout` pattern, layouts are applied per-page rather than globally.
**Location:** `frontend/src/layouts/`
**Total Files:** 2 files (~262 LOC)
---
## Architecture
```
frontend/src/layouts/
├── Authenticated.tsx (243 LOC) # Protected routes with full UI chrome
└── Guest.tsx (19 LOC) # Public routes (login, register, etc.)
Related Files:
├── menuAside.ts (42 LOC) # Sidebar menu configuration
├── menuNavBar.ts (51 LOC) # Top navbar menu configuration
└── pages/_app.tsx (324 LOC) # getLayout pattern implementation
```
---
## Layout Pattern (Next.js getLayout)
### Pattern Overview
```
┌─────────────────────────────────────────────────────────────┐
│ _app.tsx │
│ const getLayout = Component.getLayout || ((page) => page) │
│ return getLayout(<Component {...pageProps} />) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Page Component │
│ Page.getLayout = (page) => <Layout>{page}</Layout>
└─────────────────────────────────────────────────────────────┘
```
### Type Definition
```typescript
// In _app.tsx
export type NextPageWithLayout<P = Record<string, unknown>, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
```
### Usage in _app.tsx
```typescript
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout || ((page) => page);
return (
<Provider store={store}>
<DownloadProvider>
{getLayout(
<>
<Head>...</Head>
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
</>
)}
</DownloadProvider>
</Provider>
);
}
```
---
## Layout Files
### 1. LayoutAuthenticated (`Authenticated.tsx`)
**Purpose:** Wrapper for protected routes requiring authentication and optional permission checking.
**Lines:** 243 LOC
#### Props Interface
```typescript
type Props = {
children: ReactNode;
permission?: string; // Required permission for this page
minimal?: boolean; // If true, render only auth check (no UI chrome)
};
```
#### Features
| Feature | Description |
|---------|-------------|
| JWT Validation | Client-side token decode and expiry check |
| Auto-redirect | Redirects to `/login` if no valid token |
| Permission Guard | Redirects to `/error` if permission missing |
| Project Existence Check | Redirects to project creation for certain routes |
| NavBar | Top navigation with search and user menu |
| AsideMenu | Sidebar navigation menu |
| FooterBar | Page footer |
| Dark Mode | Applies dark mode styling |
| Responsive | Mobile-friendly sidebar toggle |
| Minimal Mode | Auth-only mode for presentations |
#### Authentication Flow
```
┌─────────────────────────────────────────────────────────────┐
│ LayoutAuthenticated │
└─────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ Get Token from │
│ Session/Local │
│ Storage │
└────────┬────────┘
┌─────────▼─────────┐
│ Token Valid? │
│ (JWT decode + │
│ expiry check) │
└────────┬──────────┘
┌───────────────┴───────────────┐
│ No │ Yes
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ dispatch │ │ Set axios │
│ logoutUser() │ │ Authorization │
│ redirect to │ │ header │
│ /login │ └────────┬────────┘
└─────────────────┘ │
┌─────────▼─────────┐
│ currentUser │
│ loaded? │
└────────┬──────────┘
┌───────────────┴───────────────┐
│ No │ Yes
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ dispatch │ │ Check │
│ findMe() │ │ permissions │
└─────────────────┘ └────────┬────────┘
┌─────────▼─────────┐
│ Permission OK? │
└────────┬──────────┘
┌───────────────────┴────────────┐
│ No │ Yes
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ redirect to │ │ Render page │
│ /error │ │ with layout │
└─────────────────┘ └─────────────────┘
```
#### Token Validation
```typescript
const isTokenValid = (tokenToCheck?: string | null) => {
if (!tokenToCheck) return false;
try {
const decoded = jwt.decode(tokenToCheck);
if (!decoded || typeof decoded !== 'object') return false;
if (typeof decoded.exp !== 'number') return true; // No expiry = valid
return Date.now() / 1000 < decoded.exp; // Check expiry
} catch (error) {
logger.error('Failed to decode auth token:', error);
return false;
}
};
```
#### Routes Requiring Project
Certain routes redirect to project creation if no projects exist:
```typescript
const ROUTES_REQUIRING_PROJECT = [
'/access_logs',
'/assets',
'/asset_variants',
'/presigned_url_requests',
'/project_audio_tracks',
'/project_memberships',
'/publish_events',
'/pwa_caches',
'/tour_pages',
];
```
#### Constructor Fullscreen Mode
The constructor page (`/constructor`) hides navigation chrome:
```typescript
const isConstructorFullscreen = router.pathname === '/constructor';
// When true:
// - NavBar is hidden
// - AsideMenu is hidden
// - FooterBar is hidden
// - No padding applied
```
#### Minimal Mode
For presentations that need auth but no UI chrome:
```typescript
if (minimal) {
return <>{children}</>;
}
```
#### Loading State
Shows loading spinner while auth is being checked:
```typescript
if (!isAuthChecked) {
return (
<div className={`${darkMode ? 'dark' : ''} ...`}>
<div className='animate-spin rounded-full h-8 w-8 ...'></div>
<p>Loading...</p>
</div>
);
}
```
#### Layout Structure
```
┌─────────────────────────────────────────────────────────────┐
│ NavBar │
│ [☰ Mobile] [Desktop Menu] [Search] [User Menu] [Logout] │
└─────────────────────────────────────────────────────────────┘
┌──────────────┬──────────────────────────────────────────────┐
│ │ │
│ AsideMenu │ │
│ │ Page Content │
│ - Dashboard │ (children) │
│ - Projects │ │
│ - Users │ │
│ - Profile │ │
│ - Swagger │ │
│ │ │
└──────────────┴──────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ FooterBar │
└─────────────────────────────────────────────────────────────┘
```
---
### 2. LayoutGuest (`Guest.tsx`)
**Purpose:** Simple wrapper for public pages without authentication.
**Lines:** 19 LOC
#### Props Interface
```typescript
type Props = {
children: ReactNode;
};
```
#### Features
| Feature | Description |
|---------|-------------|
| Dark Mode | Applies dark mode styling from Redux |
| Background Color | Applies theme background color |
| No Auth | No authentication required |
#### Implementation
```typescript
export default function LayoutGuest({ children }: Props) {
const darkMode = useAppSelector((state) => state.style.darkMode);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
return (
<div className={darkMode ? 'dark' : ''}>
<div className={`${bgColor} dark:bg-slate-800 dark:text-slate-100`}>
{children}
</div>
</div>
);
}
```
---
## Menu Configuration
### Aside Menu (`menuAside.ts`)
Sidebar navigation items:
```typescript
const menuAside: MenuAsideItem[] = [
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/projects/projects-list',
label: 'Projects',
icon: icon.mdiFolder,
permissions: 'READ_PROJECTS', // Permission-gated
},
{
href: '/element-type-defaults',
label: 'Element Defaults',
icon: icon.mdiPaletteSwatch,
permissions: 'READ_PAGE_ELEMENTS',
},
{
href: '/users/users-list',
label: 'Users',
icon: icon.mdiAccountGroup,
permissions: 'READ_USERS',
},
{
href: '/profile',
label: 'Profile',
icon: icon.mdiAccountCircle,
},
{
href: swaggerDocsUrl,
target: '_blank', // Opens in new tab
label: 'Swagger',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS',
},
];
```
### NavBar Menu (`menuNavBar.ts`)
Top navigation items:
```typescript
const menuNavBar: MenuNavBarItem[] = [
{
isCurrentUser: true, // User dropdown
menu: [
{ icon: mdiAccount, label: 'My Profile', href: '/profile' },
{ isDivider: true },
{ icon: mdiLogout, label: 'Log Out', isLogout: true },
],
},
{
icon: mdiThemeLightDark,
label: 'Light/Dark',
isDesktopNoLabel: true,
isToggleLightDark: true, // Dark mode toggle
},
{
icon: mdiLogout,
label: 'Log out',
isDesktopNoLabel: true,
isLogout: true,
},
];
// Additional export for web pages (currently empty)
export const webPagesNavBar = [];
```
---
## Usage Examples
### Basic Authenticated Page
```typescript
// pages/users/users-list.tsx
import LayoutAuthenticated from '../../layouts/Authenticated';
function UsersListPage() {
return (
<SectionMain>
<h1>Users</h1>
{/* page content */}
</SectionMain>
);
}
UsersListPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='READ_USERS'>
{page}
</LayoutAuthenticated>
);
};
export default UsersListPage;
```
### Minimal Auth for Presentations
```typescript
// pages/p/[projectSlug]/stage.tsx
import LayoutAuthenticated from '../../../layouts/Authenticated';
function StagePresentation() {
return <RuntimePresentation mode="stage" />;
}
StagePresentation.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='READ_TOUR_PAGES' minimal>
{page}
</LayoutAuthenticated>
);
};
```
### Public Page with Guest Layout
```typescript
// pages/login.tsx
import LayoutGuest from '../layouts/Guest';
function Login() {
return (
<SectionMain>
<LoginForm />
</SectionMain>
);
}
Login.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
export default Login;
```
### Public Page (Production Presentation)
```typescript
// pages/p/[projectSlug]/index.tsx
import LayoutGuest from '../../../layouts/Guest';
function ProductionPresentation() {
return <RuntimePresentation mode="production" />;
}
ProductionPresentation.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
```
---
## Page Layout Mapping
| Route Pattern | Layout | Permission | Mode |
|---------------|--------|------------|------|
| `/login` | Guest | - | Public |
| `/verify-email` | Guest | - | Public |
| `/password-reset` | Guest | - | Public |
| `/terms-of-use` | Guest | - | Public |
| `/p/[slug]` | Guest | - | Public presentation |
| `/p/[slug]/stage` | Authenticated | `READ_TOUR_PAGES` | Minimal (stage preview) |
| `/dashboard` | Authenticated | - | Full chrome |
| `/profile` | Authenticated | - | Full chrome |
| `/constructor` | Authenticated | - | Fullscreen (no chrome) |
| `/users/*` | Authenticated | `READ/CREATE/UPDATE_USERS` | Full chrome |
| `/roles/*` | Authenticated | `READ/CREATE/UPDATE_ROLES` | Full chrome |
| `/projects/*` | Authenticated | `READ/CREATE/UPDATE_PROJECTS` | Full chrome |
| `/assets/*` | Authenticated | `READ/CREATE/UPDATE_ASSETS` | Full chrome |
| `/tour_pages/*` | Authenticated | `READ/CREATE/UPDATE_TOUR_PAGES` | Full chrome |
| `/element-type-defaults` | Authenticated | `READ_PAGE_ELEMENTS` | Full chrome |
---
## Redux State Dependencies
Both layouts depend on Redux style state:
```typescript
// In styleSlice.ts
interface StyleState {
darkMode: boolean; // Dark mode toggle
bgLayoutColor: string; // Background color class
// ...
}
// Usage in layouts
const darkMode = useAppSelector((state) => state.style.darkMode);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
```
---
## Related Components
### NavBar Component
Top navigation bar with:
- Mobile hamburger menu toggle
- Desktop menu button
- Search component
- User menu items
### AsideMenu Component
Sidebar navigation with:
- Permission-filtered menu items
- Mobile/desktop responsive
- Collapsible on route change
### FooterBar Component
Page footer with copyright/credits.
### Search Component
Global search functionality in NavBar.
---
## Best Practices
### 1. Always Define getLayout
```typescript
// Every page should define its layout
Page.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='...'>{page}</LayoutAuthenticated>;
};
```
### 2. Use Specific Permissions
```typescript
// Good - specific permission
<LayoutAuthenticated permission='UPDATE_USERS'>
// Avoid - too broad
<LayoutAuthenticated> // No permission check
```
### 3. Use Minimal Mode for Presentations
```typescript
// Presentations need auth but no UI chrome
<LayoutAuthenticated permission='READ_TOUR_PAGES' minimal>
```
### 4. Guest Layout for Public Pages
```typescript
// Public pages should use LayoutGuest
<LayoutGuest>{page}</LayoutGuest>
```
---
## File Inventory
| File | LOC | Purpose |
|------|-----|---------|
| `Authenticated.tsx` | 243 | Protected route wrapper with auth, permissions, UI chrome |
| `Guest.tsx` | 19 | Public route wrapper with dark mode support |
| `menuAside.ts` | 42 | Sidebar menu configuration |
| `menuNavBar.ts` | 51 | Top navbar menu configuration |
| **Total** | **355** | |
---
## Related Documentation
- [pages-module.md](./pages-module.md) - Page structure and getLayout usage
- [helpers-module.md](./helpers-module.md) - hasPermission function
- [stores-module.md](./stores-module.md) - authSlice and styleSlice
- [types-module.md](./types-module.md) - Permission types

1351
frontend/docs/lib-module.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,854 @@
# Navigation & Smooth Transitions Analysis
## Executive Summary
Deep analysis of how navigation works in the Tour Builder Platform - from Constructor editing to Runtime Presentations, across Online and Offline modes. This document traces each navigation thread step-by-step to verify that smooth transitions are robust.
**Architecture Update:** The page navigation system was refactored from 6+ fragmented hooks into a unified state machine (`usePageNavigationState`). This consolidation:
- Prevents race conditions via atomic `useReducer` transitions
- Uses explicit phases instead of boolean flag combinations
- Computes derived state (`isLoading`, `showSpinner`, etc.) from a single phase value
- Provides a `PageNavigationContext` for child component access
---
## 1. NAVIGATION SYSTEM ARCHITECTURE
### 1.1 Core Hooks (Shared Between Constructor & RuntimePresentation)
| Hook | File | Purpose |
|------|------|---------|
| `usePageNavigation` | `hooks/usePageNavigation.ts` | Page navigation state with history tracking, browser-like back behavior |
| `usePageNavigationState` | `hooks/usePageNavigationState.ts` | **Unified state machine** - URL resolution, switching, fade effects, cleanup (replaces 6 hooks) |
| `useTransitionPlayback` | `hooks/useTransitionPlayback.ts` | Transition video playback, last frame preservation, pre-generated reverse support |
| `usePreloadOrchestrator` | `hooks/usePreloadOrchestrator.ts` | Asset preloading with blob URL cache |
| `useNetworkAware` | `hooks/useNetworkAware.ts` | Network condition monitoring |
**Architecture Refactor Note:** The following hooks were consolidated into `usePageNavigationState`:
- `usePageSwitch` - Page switching with blob URL resolution
- `useBackgroundState` - Background ready tracking
- `useBackgroundTransition` - CSS animation-based crossfade
- `useTransitionCleanup` - Video cleanup coordination
- `useBackgroundUrls` - URL resolution for display
- `pageLoadingUtils` - Loading state computation
### 1.2 Component Integration
```
RuntimePresentation.tsx / constructor.tsx
|
+----------------------------------------------------------------+
| usePageNavigation |
| - Unified page history management (MAX_HISTORY_LENGTH = 50) |
| - Browser-like back: history pops when isBack=true |
| - Provides getNavigationContext() for resolveNavigationTarget()|
| - applyPageSelection(targetPageId, isBack) handles history |
+----------------------------------------------------------------+
|
+----------------------------------------------------------------+
| usePreloadOrchestrator |
| - Preloads assets for current + neighbor pages |
| - Provides getReadyBlobUrl() for O(1) instant lookup |
| - Provides getCachedBlobUrl() for Cache API lookup |
+----------------------------------------------------------------+
|
+----------------------------------------------------------------+
| usePageNavigationState (UNIFIED STATE MACHINE) |
| - navigateToPage() initiates navigation with URL resolution |
| - Tracks phase: idle → preparing → transitioning/loading_bg |
| → transition_done → fading_in → idle |
| - Maintains currentImageUrl, previousImageUrl for overlay |
| - Provides derived state: isLoading, showSpinner, showElements |
| - onBackgroundReady() callback for CanvasBackground |
| - onTransitionEnded() callback for video transition completion |
| - Atomic state via useReducer (prevents race conditions) |
+----------------------------------------------------------------+
|
+----------------------------------------------------------------+
| useTransitionPlayback |
| - Plays transition videos with presigned URL fallback |
| - Preserves last frame until onComplete callback |
| - Uses pre-generated reversed videos for back navigation |
| - Passes isBack to onComplete for proper history management |
+----------------------------------------------------------------+
```
**Note:** `usePageNavigationState` consolidated 6 hooks into a single state machine:
- URL resolution, switching, overlay management (from `usePageSwitch`)
- Background ready tracking (from `useBackgroundState`)
- Fade-out coordination (from `useBackgroundTransition`)
- Video cleanup (from `useTransitionCleanup`)
- URL resolution for display (from `useBackgroundUrls`)
- Loading state computation (from `pageLoadingUtils`)
---
## 2. NAVIGATION WITHOUT TRANSITION VIDEO
### 2.1 Flow Overview
```
User clicks navigation element (no transitionVideoUrl)
|
handleElementClick / navigateToPage
|
Direct navigation - no transition video
```
### 2.2 Step-by-Step Thread Analysis
**Thread 1: Navigation Trigger**
```
handleElementClick(element) [RuntimePresentation:309-340]
|
resolveNavigationTarget(element, pages) [lib/navigationHelpers.ts]
+-- Extract targetPageSlug from element
+-- Find target page in pages array
+-- Return { pageId, transitionVideoUrl, isBack }
|
navigateToPage(targetPageId, undefined, false) [No transition video]
```
**Thread 2: Page Switch Initiation**
```
navigateToPage(targetPageId, undefined, isBack) [RuntimePresentation:285-315]
|
No transitionVideoUrl -> Direct navigation path
|
setIsBackgroundReady(false) [Mark: waiting for new bg]
lastInitializedPageIdRef.current = targetPageId
|
await pageSwitch.switchToPage(targetPage, () => {
applyPageSelection(targetPageId, isBack); [usePageNavigation hook]
// - If isBack=true AND target matches previous: history pops
// - Otherwise: history appends (trimmed to MAX_HISTORY_LENGTH=50)
});
```
**Thread 3: URL Resolution & Overlay Setup**
```
switchToPage(targetPage, onSwitched) [usePageSwitch:372-419]
|
Save current as previous for overlay:
setPreviousBgImageUrl(currentBgImageUrlRef.current)
|
setIsSwitching(true)
setIsNewBgReady(false)
|
Resolve URLs in parallel (prefer preloaded blob URLs):
[imageUrl, videoUrl, audioUrl] = await Promise.all([
resolveToDisplayUrl(background_image_url),
resolveMediaUrl(background_video_url),
resolveMediaUrl(background_audio_url),
])
```
**Thread 4: Blob URL Resolution Priority**
```
resolveToDisplayUrl(storagePath) [usePageSwitch:237-306]
|
1. getReadyBlobUrl(storagePath) [O(1) Map lookup - same session]
-> If found: return immediately (instant)
|
2. getCachedBlobUrl(storagePath) [Cache API lookup ~5ms]
-> If found: create blob URL, return
|
3. resolveAssetPlaybackUrl(storagePath) [Resolve to playback URL]
|
4. getReadyBlobUrl(resolvedUrl) [Fallback lookup]
-> If found: return
|
5. getCachedBlobUrl(resolvedUrl) [Fallback Cache API]
-> If found: return
|
6. loadImageWithFallback(originalUrl, storageKey) [Network fetch]
+-- Try presigned URL
+-- On CORS failure: markPresignedUrlFailed()
+-- Retry with proxy URL: /api/file/download
```
**Thread 5: Background Display & Overlay**
```
After URL resolution:
setCurrentBgImageUrl(imageUrl)
setCurrentBgVideoUrl(videoUrl)
setCurrentBgAudioUrl(audioUrl)
onSwitched() [Notify caller]
|
For blob URLs (local data):
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsNewBgReady(true) [Mark ready after 2 frames]
})
})
|
For remote images:
Wait for Image onLoad callback
-> markBackgroundReady()
```
**Thread 6: Render - Previous Background Overlay**
```
RuntimePresentation render [lines 517-528]
|
{pageSwitch.previousBgImageUrl &&
pageSwitch.isSwitching &&
!pageSwitch.isNewBgReady && (
<div className="absolute inset-0 z-10"
style={{ backgroundImage: `url("${previousBgImageUrl}")` }}
/>
)}
|
Previous page background stays visible until new one is ready!
```
**Thread 7: Overlay Clearing**
```
useBackgroundTransition effect [lines 144-158]
|
When: isSwitching && isNewBgReady && previousBgImageUrl
|
pageSwitch.clearPreviousBackground()
+-- setPreviousBgImageUrl('')
+-- setIsSwitching(false)
+-- revokeBlobUrl(prevUrl) [Memory cleanup]
```
### 2.3 Summary: Navigation WITHOUT Transition Video
| Phase | What's Visible | State |
|-------|----------------|-------|
| 1. Click | Current page | `isSwitching: false` |
| 2. Switch starts | Previous bg (z-0) fading out, New content (z-1) fading in | `isSwitching: true, isFadingIn: true` |
| 3. Crossfade | Both visible with CSS animation | `animate-crossfade-out` / `animate-crossfade-in` |
| 4. Animation ends | New page fully visible | `onAnimationEnd` fires, `isFadingIn: false` |
| 5. Cleanup | New page only | `clearPreviousBackground()` called |
### 2.4 CSS Animation-Based Crossfade
The crossfade effect uses CSS animations instead of JS-controlled transitions:
**CSS Variables (main.css) - Single Source of Truth:**
```css
:root {
--crossfade-duration: 700ms;
--crossfade-easing: cubic-bezier(0.4, 0, 0.2, 1);
}
```
**CSS Classes (main.css):**
```css
.animate-crossfade-in {
animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing) forwards;
}
.animate-crossfade-out {
animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing) forwards;
}
```
**Easing Characteristics:**
- `cubic-bezier(0.4, 0, 0.2, 1)` - Material Design standard
- Slow start prevents abrupt appearance
- Smooth acceleration and soft landing
**Why CSS Animations (not transitions):**
- CSS animations always play when the class is added
- Immune to React's render batching that can skip transition states
- `onAnimationEnd` event provides reliable completion detection
- Duration controlled via CSS variable (single source of truth)
- JS can read duration via `getCrossfadeDuration()` utility
**Hook Usage:**
```typescript
const { isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
pageSwitch,
fadeIn: { hasActiveTransition: false },
});
// In JSX:
<div
className={`absolute inset-0 z-1 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
onAnimationEnd={onFadeInAnimationEnd}
>
{/* New page content */}
</div>
// Previous background:
{previousBgImageUrl && isFadingIn && (
<div className={`absolute inset-0 z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`}>
...
</div>
)}
```
**ROBUSTNESS CHECK: Previous page renders while next page background and UI elements not ready**
---
## 3. NAVIGATION WITH TRANSITION VIDEO
### 3.1 Flow Overview
```
User clicks navigation element (has transitionVideoUrl)
|
handleElementClick / navigateToPage
|
Start transition video -> Play -> Keep last frame -> Switch page -> Fade out
```
### 3.2 Step-by-Step Thread Analysis
**Thread 1: Transition Initiation**
```
navigateToPage(targetPageId, transitionVideoUrl, isBack) [RuntimePresentation:272-307]
|
Has transitionVideoUrl -> Transition path
|
resetFadeOut() [Clear previous fade state]
setPendingTransitionComplete(false)
|
setTransitionPreview({
targetPageId,
videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl),
storageKey: transitionVideoUrl, [Raw path for cache lookup]
isReverse: isBack,
})
```
**Thread 2: Transition Video Source Resolution**
```
useTransitionPlayback effect triggers [lines 355-771]
|
resolvePlayableSource() [lines 431-540]
|
1. getReadyBlobUrl(storageKey) [O(1) lookup by storage path]
-> If found: use cached blob URL
|
2. getCachedBlobUrl(storageKey) [Cache API by storage path]
-> If found: create blob URL, cache it
|
3. Check lastLoadedBlobUrlRef (reuse same session)
|
4. getReadyBlobUrl(sourceUrl) [Lookup by resolved URL]
|
5. getCachedBlobUrl(sourceUrl) [Cache API by resolved URL]
|
6. Network fetch as blob [Fallback - full download]
-> axios.get(requestUrl, { responseType: 'blob' })
-> URL.createObjectURL(blob)
```
**Thread 3: Video Playback**
```
loadAndPlay() [lines 542-598]
|
video.src = playableSourceUrl
video.currentTime = 0
video.load()
attemptPlay()
|
onPlaying event fires [lines 633-675]
|
setPhase('playing')
|
scheduleFinishByDuration(durationSec) [lines 405-421]
+-- finishBeforeEndMs = 50 [Finish 50ms BEFORE end]
+-- finishMs = durationSec * 1000 - 50
+-- setTimeout(() => finishPlayback('duration-timer'), finishMs)
```
**Thread 4: Last Frame Preservation (CRITICAL)**
```
finishPlayback(reason) [lines 244-293]
|
didFinishRef.current = true
clearTimers()
|
video.pause()
|
*** LAST FRAME PRESERVATION ***
if (video.duration && Number.isFinite(video.duration) &&
video.currentTime >= video.duration - 0.1) {
video.currentTime = Math.max(0, video.duration - 0.05)
}
|
Explanation:
- Some browsers show BLACK after 'ended' event
- Seeking to duration - 0.05 keeps last visible frame
- This ensures smooth visual continuity
|
setPhase('finishing')
|
Optional: waitForImages() for target page pre-decode
|
setPhase('completed')
onCompleteRef.current(targetPageId)
```
**Thread 5: Page Switch After Transition**
```
onComplete callback (targetPageId, isBack) [RuntimePresentation:146-166]
|
if (targetPageId) {
const targetPage = pages.find(p => p.id === targetPageId)
lastInitializedPageIdRef.current = targetPageId
|
await pageSwitch.switchToPage(targetPage, () => {
applyPageSelection(targetPageId, isBack ?? false) [usePageNavigation]
// Proper history management: pops on back, appends on forward
})
|
setIsBackgroundReady(false)
setPendingTransitionComplete(true) [Signal: waiting for bg ready]
}
```
**Thread 6: Background Image Load Detection**
```
Background image element in render [lines 476-514]
|
<img onLoad={() => {
setIsBackgroundReady(true)
pageSwitch.markBackgroundReady()
}} />
|
Or for video backgrounds:
useEffect auto-marks ready when no image or has video
```
**Thread 7: Video Transition Overlay Removal (Instant, With rAF Delay)**
```
TransitionPreviewOverlay.tsx
|
When: isFadingOut=true passed from parent
|
useEffect triggers:
if (isFadingOut) {
requestAnimationFrame(() => {
setShouldHide(true) [After one paint frame]
})
}
|
Container opacity:
- 0 during initial buffering (before first frame)
- 0 when shouldHide=true (after rAF delay - bg is painted)
- 1 otherwise (video playing)
|
transition: 'none' when hiding [NO CSS transition - instant hide]
```
**Thread 8: Render - Transition Overlay Visibility**
```
TransitionPreviewOverlay component [TransitionPreviewOverlay.tsx]
|
Container opacity controlled by:
- isBuffering && !isVideoReady → 0 (initial buffering)
- shouldHide → 0 (after rAF delay, new bg painted)
- otherwise → opacity prop or 1
|
transition: useTransition ? '150ms' : 'none'
- 150ms ONLY for initial buffering fade-in
- 'none' when hiding (instant hide since last video frame = new bg)
|
{transitionPreview && (
<TransitionPreviewOverlay
isBuffering={transitionPhase === 'preparing' || isBuffering}
isFadingOut={pendingTransitionComplete && isBackgroundReady}
/>
)}
|
Container hidden while: preparing, buffering (old page visible through)
Container visible when: video ready to play (instant appearance)
Overlay removed: instantly after rAF (ensures bg is painted first)
```
**Key Design Decision - Instant Hide with rAF Delay:**
- Video itself IS the transition effect
- First frame = old page background
- Last frame = new page background
- Wait one `requestAnimationFrame` after `isFadingOut=true`
- This ensures new background is painted before hiding overlay
- Overlay hides **instantly** (no CSS transition) - seamless since last frame = new bg
- No visual discontinuity or flash
### 3.3 Summary: Navigation WITH Transition Video
| Phase | What's Visible | State |
|-------|----------------|-------|
| 1. Click | Current page | `transitionPhase: 'idle'` |
| 2. Preparing | Current page (container hidden) | `transitionPhase: 'preparing', isBuffering: true` |
| 3. Playing | Transition video | `transitionPhase: 'playing'` |
| 4. Finishing | **Last frame of video** | `transitionPhase: 'finishing'` |
| 5. Completed | **Last frame of video** | `pendingTransitionComplete: true` |
| 6. Bg loading | **Last frame of video** (z-50) | New bg loading underneath |
| 7. Bg ready | **Instant switch** | `setTransitionPreview(null)` (no fade!) |
| 8. Done | New page only | Overlay removed |
**Key Changes from Previous Implementation:**
- Container hidden during buffering (no black flash)
- No fade-out animation for video transitions
- Instant overlay removal when background ready
- Video itself is the transition (first frame = old, last frame = new)
**ROBUSTNESS CHECK: Last video frame renders while next page background and UI elements not ready**
### 3.4 PreviousBackgroundOverlay (Simplified)
The `PreviousBackgroundOverlay` component was simplified to remove all fade logic:
```tsx
// PreviousBackgroundOverlay.tsx - Simplified implementation
const PreviousBackgroundOverlay = ({
imageUrl,
isSwitching = false,
isNewBgReady = false,
className = '',
}) => {
// Simple render logic: show while switching AND new bg not ready
const shouldRender = isSwitching && !isNewBgReady && !!imageUrl;
if (!shouldRender) return null;
return (
<div
className={`pointer-events-none absolute inset-0 z-2 ${className}`}
style={{
backgroundImage: `url("${imageUrl}")`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
/>
);
};
```
**Changes from previous implementation:**
- Removed all CSS transition/fade logic
- Removed timeout fallbacks
- Removed transition event handlers
- Simplified to pure show/hide based on props
- Hides **instantly** when `isNewBgReady=true`
### 3.5 scheduleAfterPaint Helper
The `CanvasBackground.tsx` uses a `scheduleAfterPaint` helper for video first-frame detection:
```typescript
/**
* Schedule a callback to run after the next browser paint.
* Uses double rAF pattern: first rAF schedules for next frame,
* second rAF ensures the frame has actually been committed.
*/
const scheduleAfterPaint = (callback: () => void): void => {
requestAnimationFrame(() => {
requestAnimationFrame(callback);
});
};
```
**Usage in video first-frame detection:**
```typescript
// Using requestVideoFrameCallback for accurate first-frame detection
if ('requestVideoFrameCallback' in video) {
video.requestVideoFrameCallback(() => {
clearTimeout(timeout);
scheduleAfterPaint(() => {
reportVideoReady(); // Called after frame is painted
});
});
}
```
---
## 4. ONLINE VS OFFLINE MODE
### 4.1 Network Awareness
```
useNetworkAware hook [hooks/useNetworkAware.ts]
|
Monitors:
- navigator.onLine
- connection.effectiveType (slow-2g, 2g, 3g, 4g)
- connection.downlink (Mbps)
- connection.rtt (ms)
- connection.saveData (boolean)
|
Returns:
- networkInfo.isOnline
- recommendedConcurrency (1-3 based on connection)
- shouldPreloadAggressively
- suggestOfflineMode
```
### 4.2 Preload Orchestrator - Online Mode
```
processQueue() [usePreloadOrchestrator:355-485]
|
Guard: if (!networkInfo.isOnline) return [Skip if offline]
|
While queue has items && activeDownloads < recommendedConcurrency:
+-- Check preloadedUrls.has(url) -> skip if already loaded
+-- Check StorageManager.hasAsset(url) -> skip if cached
| +-- If cached: createReadyBlobUrl() from cache
|
preloadWithProgress(url, jobId, assetId)
+-- fetch(url) with streaming progress
+-- Store in Cache API
+-- createReadyBlobUrl()
```
### 4.3 Preload Orchestrator - Offline Mode
```
processQueue() [usePreloadOrchestrator:355-485]
|
Guard: if (!networkInfo.isOnline) return [Don't process queue when offline]
|
BUT: isUrlCached check still works!
+-- StorageManager.hasAsset(url)
| +-- Check IndexedDB: OfflineDbManager.hasAssetByUrl(url)
| +-- Check Cache API: caches.open().match(url)
|
Assets already downloaded are still accessible!
```
### 4.4 Navigation Flow - Same for Both Modes
The navigation flow is **identical** for online and offline modes:
1. **URL Resolution** - `resolveToDisplayUrl()` / `resolvePlayableSource()`
- Tries `getReadyBlobUrl()` first (in-memory Map - O(1))
- Tries `getCachedBlobUrl()` second (Cache API + IndexedDB)
- Falls back to network only if not cached
2. **Blob URL Priority** - Cache is always checked first
```
Online: blob URL (cache) > presigned URL > proxy URL
Offline: blob URL (cache) > FAIL if not cached
```
3. **Transition Video** - Same playback logic
- If cached: plays from blob URL (instant)
- If not cached: depends on network availability
### 4.5 Offline Storage Hierarchy
```
Asset lookup order (StorageManager)
|
1. IndexedDB (files >= 5MB)
+-- OfflineDbManager.getAssetByUrl(url)
|
2. Cache API (files < 5MB)
+-- caches.open('tour-builder-assets-v1').match(url)
|
3. Network (online only)
+-- fetch(presignedUrl || proxyUrl)
```
### 4.6 Summary: Online vs Offline Differences
| Aspect | Online Mode | Offline Mode |
|--------|-------------|--------------|
| Preload queue | Active processing | Paused (no new downloads) |
| Asset resolution | Cache -> Network | Cache only |
| Transition video | Cache -> Network | Cache only |
| Background image | Cache -> Network | Cache only |
| Navigation flow | Same | Same |
| Overlay behavior | Same | Same |
| Last frame handling | Same | Same |
**ROBUSTNESS CHECK: Same navigation flow works in both online and offline modes**
---
## 5. CONSTRUCTOR VS RUNTIME PRESENTATION
### 5.1 Shared Components
Both use the **same hooks**:
- `usePageNavigationState` - Unified state machine for URL resolution, overlay management, fade effects
- `useTransitionPlayback` - Video playback, last frame preservation
- `usePreloadOrchestrator` - Asset preloading
- `usePageNavigation` - History tracking
### 5.2 Key Differences
| Aspect | Constructor | RuntimePresentation |
|--------|-------------|---------------------|
| State management | `usePageNavigationState` (unified) | `usePageNavigationState` (unified) |
| Crossfade animation | Yes (project transition settings) | Yes (project transition settings) |
| Crossfade easing | From `transitionSettings.fadeEasing` | From `transitionSettings.fadeEasing` |
| Transition video fade-out | **No (instant removal)** | **No (instant removal)** |
| Video overlay hiding | Container hidden while buffering | Container hidden while buffering |
| Background transition config | Via `usePageNavigationState` | Via `usePageNavigationState` |
| Overlay component | `TransitionPreviewOverlay` | `TransitionPreviewOverlay` |
| Post-transition cleanup | Via `onTransitionEnded` + `onBackgroundReady` | Via `onTransitionEnded` + `onBackgroundReady` |
| Animation end detection | CSS `onAnimationEnd` event | CSS `onAnimationEnd` event |
| Edit mode support | Direct background updates (`setBackgroundDirectly`) | N/A |
### 5.3 Constructor Transition Flow
```
onComplete callback (targetPageId, isBack) [constructor.tsx]
|
if (targetPageId) {
await navState.navigateToPage(targetPage, {
hasTransition: false,
isBack: isBack ?? false,
onSwitched: () => applyPageSelection(targetPage.id, isBack ?? false)
})
// navigateToPage handles all state transitions atomically
// usePageNavigation handles history (pops on back, appends on forward)
clearSelection()
setSelectedMenuItem('none')
}
|
navState.onTransitionEnded() is called when transition video completes
|
Video cleanup happens via effect watching pendingTransitionComplete + isBackgroundReady
```
Both constructor and runtime presentation use the same `usePageNavigationState` hook for unified state management. The `usePageNavigation` hook handles browser-like history behavior.
---
## 6. ROBUSTNESS VERIFICATION
### 6.1 Scenario: Navigation WITHOUT Transition (Direct)
| Step | What Happens | Verified |
|------|--------------|----------|
| 1 | User clicks navigation element | YES |
| 2 | Previous bg saved to `previousBgImageUrl` | YES |
| 3 | `isSwitching: true, isNewBgReady: false` | YES |
| 4 | URL resolution (blob -> cache -> network) | YES |
| 5 | Previous bg overlay renders (z-10) | YES |
| 6 | New bg loads underneath (z-1) | YES |
| 7 | Image onLoad -> `markBackgroundReady()` | YES |
| 8 | `clearPreviousBackground()` removes overlay | YES |
### 6.2 Scenario: Navigation WITH Transition Video
| Step | What Happens | Verified |
|------|--------------|----------|
| 1 | User clicks navigation element | YES |
| 2 | `setTransitionPreview()` triggers hook | YES |
| 3 | Video source resolved (blob -> cache -> network) | YES |
| 4 | Video plays (opacity: 1) | YES |
| 5 | Timer fires 50ms before video ends | YES |
| 6 | `finishPlayback()` seeks to duration - 0.05 | YES |
| 7 | Last frame stays visible | YES |
| 8 | `onComplete()` triggers page switch | YES |
| 9 | New bg loads underneath overlay | YES |
| 10 | Image onLoad -> `isBackgroundReady: true` | YES |
| 11 | Overlay fades out (opacity: 0 transition) | YES |
| 12 | Cleanup: remove video src, clear state | YES |
### 6.3 Scenario: Reverse Navigation (Back)
| Step | What Happens | Verified |
|------|--------------|----------|
| 1 | User clicks back navigation | YES |
| 2 | `isBack: true` set, uses `reverseVideoUrl` | YES |
| 3 | Pre-generated reversed video loaded | YES |
| 4 | Forward playback of reversed video | YES |
| 5 | Video ends normally | YES |
| 6 | `onComplete()` -> `finishPlayback()` | YES |
| 7 | Same cleanup flow as forward | YES |
**Note:** Reversed videos are pre-generated server-side using FFmpeg when pages are saved. This eliminates client-side frame-stepping and ensures professional audio/video synchronization.
### 6.4 Scenario: Offline Mode
| Step | What Happens | Verified |
|------|--------------|----------|
| 1 | `networkInfo.isOnline: false` | YES |
| 2 | Preload queue paused | YES |
| 3 | Navigation clicked | YES |
| 4 | URL resolved from cache (IndexedDB/Cache API) | YES |
| 5 | If cached: works identically to online | YES |
| 6 | If not cached: fails gracefully | YES |
---
## 7. CONCLUSION
The navigation and smooth transitions system is **robust and comprehensive**:
### 7.1 Key Strengths
1. **Unified State Machine** - `usePageNavigationState` consolidates 6 hooks into atomic state transitions
2. **Race Condition Prevention** - `useReducer` ensures state updates are atomic (no batching issues)
3. **Explicit Phase Tracking** - Single `phase` value (idle, preparing, transitioning, etc.) instead of multiple boolean flags
4. **Derived State via useMemo** - `isLoading`, `showSpinner`, `showElements` computed from phase
5. **Unified History Management** - `usePageNavigation` hook handles history in both components
6. **Browser-Like Back Navigation** - History pops when navigating back (via `applyPageSelection(id, isBack=true)`)
7. **History Limit** - `MAX_HISTORY_LENGTH=50` prevents unbounded growth in long sessions
8. **Blob URL Priority** - Always checks cache before network
9. **CSS Animation Crossfade** - Uses CSS animations (not transitions) for reliable visual effects
10. **Smooth Easing** - Project-configurable transition easing
11. **Event-Based Completion** - `onAnimationEnd` event replaces timer-based duration tracking
12. **Previous Background Overlay** - Keeps previous page visible during direct navigation
13. **Last Frame Preservation** - Seeks to duration - 0.05 to avoid black frame
14. **Timer-Based Finish** - Finishes 50ms before video ends to catch last frame
15. **No Video Fade** - Video transitions end instantly without fade-out animation
16. **Container Hidden While Buffering** - Prevents black flash before video ready
17. **Offline Resilience** - Same flow works when offline (if assets cached)
18. **Context Provider** - `PageNavigationContext` allows child components to access navigation state
### 7.2 No Gaps Identified
- **State consistency**: Single `useReducer` prevents race conditions from async batching
- **Invalid states impossible**: State machine phases prevent flag combinations like `isLoading && showElements`
- **Pages without transition**: Previous page renders while next loads (crossfade animation)
- **Pages with transition**: Last frame renders while next loads (instant switch when ready)
- **Video overlay**: Container hidden while buffering (no black flash)
- **Online mode**: Full preloading and network fallback
- **Offline mode**: Graceful cache-first behavior
- **Constructor**: Same `usePageNavigationState` hook as RuntimePresentation
- **Edit mode**: Direct background updates via `setBackgroundDirectly()` bypass navigation flow
---
## 8. CRITICAL CODE LOCATIONS
| Feature | File | Lines |
|---------|------|-------|
| Page navigation with history | `usePageNavigation.ts` | 62-195 |
| History limit (MAX=50) | `usePageNavigation.ts` | 8 |
| Browser-like history pop | `usePageNavigation.ts` | 120-127 |
| Navigation context | `usePageNavigation.ts` | 164-170 |
| **Unified state machine** | `usePageNavigationState.ts` | Full file (~520 LOC) |
| State machine reducer | `usePageNavigationState.ts` | 80-180 |
| Navigation phases | `usePageNavigationState.ts` | 30-45 |
| URL resolution | `usePageNavigationState.ts` | 200-280 |
| Derived state (isLoading, etc.) | `usePageNavigationState.ts` | 350-400 |
| onBackgroundReady callback | `usePageNavigationState.ts` | 420-450 |
| Transition video playback | `useTransitionPlayback.ts` | 355-771 |
| Last frame preservation | `useTransitionPlayback.ts` | 251-262 |
| Timer-based finish | `useTransitionPlayback.ts` | 405-421 |
| isBack in onComplete | `useTransitionPlayback.ts` | 290-296 |
| CSS variables (duration, easing) | `css/main.css` | 15-20 |
| CSS animations | `css/main.css` | 42-120 |
| getCrossfadeDuration utility | `lib/browserUtils.ts` | 14-32 |
| TransitionPreviewOverlay | `Constructor/TransitionPreviewOverlay.tsx` | 1-75 |
| PageNavigationContext provider | `context/PageNavigationContext.tsx` | Full file (~120 LOC) |
| Network awareness | `useNetworkAware.ts` | 68-163 |
| Server-side reversal | `backend/src/services/videoProcessing.ts` | N/A |
| Preload queue guard | `usePreloadOrchestrator.ts` | 355-358 |
| Navigation helpers | `lib/navigationHelpers.ts` | 1-120 |
**Deleted Files (consolidated into `usePageNavigationState.ts`):**
- `usePageSwitch.ts` (~475 LOC)
- `useBackgroundState.ts` (~156 LOC)
- `useBackgroundTransition.ts` (~164 LOC)
- `useTransitionCleanup.ts` (~108 LOC)
- `useBackgroundUrls.ts` (~104 LOC)
- `lib/pageLoadingUtils.ts` (~76 LOC)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,631 @@
# Frontend Schemas Module
## Overview
The Schemas module provides **Zod validation schemas** for form validation across the frontend application. These schemas define data validation rules, generate TypeScript types, and provide initial form values for entity forms.
**Location:** `frontend/src/schemas/`
**Total Files:** 6 files (~257 LOC)
**Library:** [Zod](https://zod.dev/) - TypeScript-first schema validation
---
## Architecture
```
frontend/src/schemas/
├── index.ts (9 LOC) # Central exports
├── userSchema.ts (80 LOC) # User validation (create/update)
├── assetSchema.ts (85 LOC) # Asset validation
├── projectSchema.ts (31 LOC) # Project validation
├── tourPageSchema.ts (35 LOC) # Tour page validation
└── roleSchema.ts (17 LOC) # Role validation
```
**Related Schema:** `frontend/src/lib/offlineDb/schema.ts` - Dexie IndexedDB schema (separate concern)
---
## Schema Pattern
Each schema file follows a consistent pattern:
```typescript
/**
* 1. Zod schema definition
*/
export const entitySchema = z.object({
fieldName: z.string().min(1, 'Error message'),
// ...
});
/**
* 2. TypeScript type inference
*/
export type EntityFormData = z.infer<typeof entitySchema>;
/**
* 3. Initial form values
*/
export const entityInitialValues: EntityFormData = {
fieldName: '',
// ...
};
```
---
## Schema Files
### 1. User Schema (`userSchema.ts`)
**Purpose:** Validation for user creation and update forms.
**Lines:** 80 LOC
#### Schemas
```typescript
// User creation - requires email, optional password
export const userCreateSchema = z.object({
firstName: z.string().max(255, 'First name too long').optional().or(z.literal('')),
lastName: z.string().max(255, 'Last name too long').optional().or(z.literal('')),
phoneNumber: z.string().max(50, 'Phone number too long').optional().or(z.literal('')),
email: z.string().email('Invalid email address').min(1, 'Email is required'),
password: z.string().min(6, 'Password must be at least 6 characters').optional().or(z.literal('')),
disabled: z.boolean().default(false),
avatar: z.array(z.unknown()).optional(),
app_role: z.unknown().optional().nullable(),
custom_permissions: z.array(z.unknown()).optional(),
});
// User update - same fields, password optional
export const userUpdateSchema = z.object({
// Same structure as userCreateSchema
});
```
#### Exports
| Export | Type | Description |
|--------|------|-------------|
| `userCreateSchema` | ZodObject | Create user validation |
| `userUpdateSchema` | ZodObject | Update user validation |
| `UserCreateFormData` | Type | Inferred create form type |
| `UserUpdateFormData` | Type | Inferred update form type |
| `userInitialValues` | Object | Default form values |
#### Field Validations
| Field | Validation | Error Message |
|-------|------------|---------------|
| `firstName` | max(255), optional | "First name too long" |
| `lastName` | max(255), optional | "Last name too long" |
| `phoneNumber` | max(50), optional | "Phone number too long" |
| `email` | email(), min(1) | "Invalid email address", "Email is required" |
| `password` | min(6), optional | "Password must be at least 6 characters" |
| `disabled` | boolean, default(false) | - |
| `avatar` | array, optional | - |
| `app_role` | unknown, optional, nullable | - |
| `custom_permissions` | array, optional | - |
---
### 2. Asset Schema (`assetSchema.ts`)
**Purpose:** Validation for asset upload and edit forms.
**Lines:** 85 LOC
#### Schema
```typescript
export const assetSchema = z.object({
project: z.unknown().optional().nullable(),
name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
asset_type: z.enum(['image', 'video', 'audio', 'file']),
type: z.enum([
'general', 'icon', 'background_image', 'audio',
'video', 'transition', 'logo', 'favicon', 'document',
]).default('general'),
cdn_url: z.string().url('Invalid URL').optional().or(z.literal('')),
storage_key: z.string().max(500, 'Storage key too long').optional().or(z.literal('')),
mime_type: z.string().max(100, 'MIME type too long').optional().or(z.literal('')),
size_mb: z.coerce.number().min(0, 'Size cannot be negative').optional().or(z.literal('')),
width_px: z.coerce.number().int().min(0, 'Width cannot be negative').optional().or(z.literal('')),
height_px: z.coerce.number().int().min(0, 'Height cannot be negative').optional().or(z.literal('')),
duration_sec: z.coerce.number().min(0, 'Duration cannot be negative').optional().or(z.literal('')),
checksum: z.string().max(255, 'Checksum too long').optional().or(z.literal('')),
is_public: z.boolean().default(false),
is_deleted: z.boolean().default(false),
deleted_at_time: z.date().optional().nullable(),
});
```
#### Exports
| Export | Type | Description |
|--------|------|-------------|
| `assetSchema` | ZodObject | Asset validation schema |
| `AssetFormData` | Type | Inferred form type |
| `assetInitialValues` | Object | Default form values |
#### Enum Values
**asset_type:**
- `image` - Image files (PNG, JPG, WebP, etc.)
- `video` - Video files (MP4, WebM, etc.)
- `audio` - Audio files (MP3, WAV, etc.)
- `file` - Generic files (documents, etc.)
**type (usage category):**
- `general` - Default category
- `icon` - UI icons
- `background_image` - Page backgrounds
- `audio` - Background audio
- `video` - Video content
- `transition` - Transition videos
- `logo` - Brand logos
- `favicon` - Site favicons
- `document` - Documents/PDFs
#### Coercion Pattern
Number fields use `z.coerce` for form input handling:
```typescript
// Converts string input to number, validates, allows empty string
size_mb: z.coerce
.number()
.min(0, 'Size cannot be negative')
.optional()
.or(z.literal(''))
```
---
### 3. Project Schema (`projectSchema.ts`)
**Purpose:** Validation for project creation and edit forms.
**Lines:** 31 LOC
#### Schema
```typescript
export const projectSchema = z.object({
name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
slug: z.string()
.max(255, 'Slug too long')
.regex(
/^[a-z0-9_-]*$/i,
'Slug can only contain letters, numbers, dashes, underscores',
)
.optional()
.or(z.literal('')),
description: z.string().max(5000, 'Description too long').optional().or(z.literal('')),
});
```
#### Exports
| Export | Type | Description |
|--------|------|-------------|
| `projectSchema` | ZodObject | Project validation schema |
| `ProjectFormData` | Type | Inferred form type |
| `projectInitialValues` | Object | Default form values |
#### Slug Validation
The slug field uses regex validation for URL-safe strings:
```typescript
.regex(/^[a-z0-9_-]*$/i, 'Slug can only contain letters, numbers, dashes, underscores')
```
**Valid:** `my-project`, `project_123`, `MyProject`
**Invalid:** `my project`, `project@123`, `project/name`
---
### 4. Tour Page Schema (`tourPageSchema.ts`)
**Purpose:** Validation for tour page creation and edit forms.
**Lines:** 35 LOC
#### Schema
```typescript
export const tourPageSchema = z.object({
project: z.unknown().optional().nullable(),
title: z.string().max(255, 'Title too long').optional().or(z.literal('')),
slug: z.string()
.max(255, 'Slug too long')
.regex(
/^[a-z0-9_-]*$/i,
'Slug can only contain letters, numbers, dashes, underscores',
)
.optional()
.or(z.literal('')),
background_asset: z.unknown().optional().nullable(),
audio_asset: z.unknown().optional().nullable(),
is_start_page: z.boolean().default(false),
sort_order: z.coerce.number().int().min(0).optional().or(z.literal('')),
});
```
#### Exports
| Export | Type | Description |
|--------|------|-------------|
| `tourPageSchema` | ZodObject | Tour page validation schema |
| `TourPageFormData` | Type | Inferred form type |
| `tourPageInitialValues` | Object | Default form values |
#### Initial Values
```typescript
export const tourPageInitialValues: TourPageFormData = {
project: null,
title: '',
slug: '',
background_asset: null,
audio_asset: null,
is_start_page: false,
sort_order: '',
};
```
---
### 5. Role Schema (`roleSchema.ts`)
**Purpose:** Validation for role creation and edit forms.
**Lines:** 17 LOC
#### Schema
```typescript
export const roleSchema = z.object({
name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
permissions: z.array(z.unknown()).optional(),
});
```
#### Exports
| Export | Type | Description |
|--------|------|-------------|
| `roleSchema` | ZodObject | Role validation schema |
| `RoleFormData` | Type | Inferred form type |
| `roleInitialValues` | Object | Default form values |
#### Initial Values
```typescript
export const roleInitialValues: RoleFormData = {
name: '',
permissions: [],
};
```
---
### 6. Index File (`index.ts`)
**Purpose:** Central re-export for all schemas.
**Lines:** 9 LOC
```typescript
export * from './userSchema';
export * from './projectSchema';
export * from './assetSchema';
export * from './roleSchema';
export * from './tourPageSchema';
```
---
## Zod Validation Patterns
### 1. Optional with Empty String
Allow field to be empty or have valid value:
```typescript
// Accepts: undefined, '', or valid string
fieldName: z.string().optional().or(z.literal(''))
```
### 2. Required String
Field must have non-empty value:
```typescript
// Fails on empty string
name: z.string().min(1, 'Name is required')
```
### 3. Email Validation
Built-in email format validation:
```typescript
email: z.string().email('Invalid email address').min(1, 'Email is required')
```
### 4. Enum Validation
Restrict to specific values:
```typescript
asset_type: z.enum(['image', 'video', 'audio', 'file'])
```
### 5. Number Coercion
Convert string input to number:
```typescript
// Input: "123" → number 123
// Input: "" → literal ''
size_mb: z.coerce.number().min(0).optional().or(z.literal(''))
```
### 6. Regex Pattern
Validate string format:
```typescript
slug: z.string().regex(/^[a-z0-9_-]*$/i, 'Error message')
```
### 7. Nullable Relation
Optional reference to another entity:
```typescript
project: z.unknown().optional().nullable()
```
### 8. Boolean with Default
Boolean field with default value:
```typescript
disabled: z.boolean().default(false)
```
---
## Type Inference
Zod schemas generate TypeScript types automatically:
```typescript
// Define schema
const projectSchema = z.object({
name: z.string().min(1),
slug: z.string().optional(),
});
// Infer type
type ProjectFormData = z.infer<typeof projectSchema>;
// Equivalent to:
// type ProjectFormData = {
// name: string;
// slug?: string;
// }
```
---
## Usage with Formik
Schemas can be integrated with Formik using adapters:
### Option 1: Manual Validation
```typescript
import { projectSchema, projectInitialValues } from '@/schemas';
const validate = (values) => {
const result = projectSchema.safeParse(values);
if (!result.success) {
const errors = {};
result.error.issues.forEach((issue) => {
errors[issue.path[0]] = issue.message;
});
return errors;
}
return {};
};
<Formik
initialValues={projectInitialValues}
validate={validate}
onSubmit={handleSubmit}
>
{/* form fields */}
</Formik>
```
### Option 2: Zod-Formik Adapter
```typescript
import { toFormikValidationSchema } from 'zod-formik-adapter';
import { projectSchema, projectInitialValues } from '@/schemas';
<Formik
initialValues={projectInitialValues}
validationSchema={toFormikValidationSchema(projectSchema)}
onSubmit={handleSubmit}
>
{/* form fields */}
</Formik>
```
---
## Related: IndexedDB Schema
The `lib/offlineDb/schema.ts` file defines the **Dexie.js** IndexedDB schema for offline storage (separate from form validation):
```typescript
class OfflineDatabase extends Dexie {
assets!: EntityTable<OfflineAsset, 'id'>;
projects!: EntityTable<OfflineProject, 'id'>;
downloadQueue!: EntityTable<DownloadQueueItem, 'id'>;
constructor() {
super(OFFLINE_CONFIG.dbName);
this.version(OFFLINE_CONFIG.dbVersion).stores({
assets: 'id, projectId, url, variantType, assetType, downloadedAt',
projects: 'id, slug, status, lastSyncedAt',
downloadQueue: 'id, projectId, status, priority, addedAt',
});
}
}
export const offlineDb = new OfflineDatabase();
```
**Key Differences:**
| Aspect | Zod Schemas | Dexie Schema |
|--------|-------------|--------------|
| Purpose | Form validation | Database structure |
| Library | Zod | Dexie.js |
| Location | `schemas/` | `lib/offlineDb/` |
| Runtime | Client-side validation | IndexedDB storage |
---
## Schema Inventory
| File | LOC | Entity | Fields | Exports |
|------|-----|--------|--------|---------|
| `userSchema.ts` | 80 | User | 9 fields | 5 (2 schemas, 2 types, 1 initial) |
| `assetSchema.ts` | 85 | Asset | 14 fields | 3 (1 schema, 1 type, 1 initial) |
| `tourPageSchema.ts` | 35 | TourPage | 7 fields | 3 (1 schema, 1 type, 1 initial) |
| `projectSchema.ts` | 31 | Project | 3 fields | 3 (1 schema, 1 type, 1 initial) |
| `roleSchema.ts` | 17 | Role | 2 fields | 3 (1 schema, 1 type, 1 initial) |
| `index.ts` | 9 | - | - | Re-exports all |
| **Total** | **257** | **5** | **35** | **17** |
---
## Best Practices
### 1. Consistent Error Messages
Use descriptive, user-friendly error messages:
```typescript
// Good
name: z.string().min(1, 'Name is required').max(255, 'Name too long')
// Avoid
name: z.string().min(1).max(255) // Generic messages
```
### 2. Type-Safe Initial Values
Use inferred type for initial values:
```typescript
export const projectInitialValues: ProjectFormData = {
name: '', // TypeScript enforces this matches schema
slug: '',
description: '',
};
```
### 3. Empty String Handling
For optional fields that may receive empty strings from forms:
```typescript
// Allows both undefined and empty string
fieldName: z.string().optional().or(z.literal(''))
```
### 4. Relation Fields
Use `z.unknown()` for relation fields that hold entity references:
```typescript
// Accepts any value - actual validation happens on backend
project: z.unknown().optional().nullable()
```
### 5. Number Input Handling
Use `z.coerce` for number fields from text inputs:
```typescript
// Converts "123" to 123, validates as number
amount: z.coerce.number().min(0)
```
---
## Related Documentation
- [types-module.md](./types-module.md) - Entity type definitions
- [factories-module.md](./factories-module.md) - Form page factory (uses schemas)
- [lib-module.md](./lib-module.md) - Offline storage schema
- [hooks-module.md](./hooks-module.md) - Form hooks
---
## Adding New Schemas
To add validation for a new entity:
### Step 1: Create Schema File
```typescript
// schemas/widgetSchema.ts
import { z } from 'zod';
export const widgetSchema = z.object({
name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
type: z.enum(['basic', 'advanced']).default('basic'),
config: z.record(z.unknown()).optional(),
});
export type WidgetFormData = z.infer<typeof widgetSchema>;
export const widgetInitialValues: WidgetFormData = {
name: '',
type: 'basic',
config: {},
};
```
### Step 2: Export from Index
```typescript
// schemas/index.ts
export * from './widgetSchema';
```
### Step 3: Use in Form
```typescript
import { widgetSchema, widgetInitialValues, type WidgetFormData } from '@/schemas';
// With validation
const validate = (values: WidgetFormData) => {
const result = widgetSchema.safeParse(values);
// ... handle errors
};
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,489 @@
# UI Adaptivity System
Comprehensive documentation for the Tour Builder Platform's responsive canvas scaling and UI adaptivity system.
## Overview
The platform uses a **Canvas Units** system to ensure UI elements scale proportionally across all viewport sizes while maintaining a consistent design. This system allows content authored at a design resolution (e.g., 1920×1080) to display correctly on any screen size.
```
┌─────────────────────────────────────────────────────────────────────────┐
│ UI Adaptivity Architecture │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Configuration Layer │ │
│ │ │ │
│ │ canvas.config.ts │ │
│ │ ├── defaults: { width: 1920, height: 1080 } │ │
│ │ ├── scaling: { mode: 'fit', minScale: 0.1, maxScale: 4.0 } │ │
│ │ └── cssVars: { scale: '--canvas-scale', unit: '--cu', ... } │ │
│ └───────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Calculation Layer │ │
│ │ │ │
│ │ canvasScale.ts (utilities) │ │
│ │ ├── calculateCanvasScale(viewport, design) → scale factor │ │
│ │ ├── toCU(designPixels) → "calc(N * var(--cu, 1px))" │ │
│ │ ├── normalizeToCanvasUnits(value) → canvas unit expression │ │
│ │ └── getCanvasCssVars(scale) → CSS custom properties object │ │
│ └───────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Hook/Context Layer │ │
│ │ │ │
│ │ useCanvasScale (hook) │ CanvasScaleContext (context) │ │
│ │ ├── scale │ ├── Same properties │ │
│ │ ├── cssVars │ ├── Provider pattern │ │
│ │ ├── letterboxStyles │ └── Optional hook variant │ │
│ │ └── showRotatePrompt │ │ │
│ └───────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Application Layer │ │
│ │ │ │
│ │ RuntimePresentation │ Constructor │ │
│ │ ├── cssVars on root │ ├── cssVars on canvas │ │
│ │ ├── letterboxStyles │ ├── letterboxStyles │ │
│ │ └── Elements use --cu │ └── Elements use --cu │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
## Core Concepts
### Canvas Units (--cu)
The `--cu` CSS custom property is the foundation of the adaptivity system. It represents "1 design pixel" that scales with the viewport.
```
At design resolution (1920×1080 on 1920×1080 viewport):
--cu = 1px
At 4K (1920×1080 on 3840×2160 viewport):
--cu = 2px (elements render 2x larger)
At half-resolution (1920×1080 on 960×540 viewport):
--cu = 0.5px (elements render at half size)
```
**Usage:**
```css
/* Direct CSS usage */
.element {
font-size: calc(24 * var(--cu, 1px)); /* 24px at design scale */
padding: calc(16 * var(--cu, 1px));
border-radius: calc(8 * var(--cu, 1px));
}
```
```typescript
// JavaScript usage via toCU()
import { toCU } from '../lib/canvasScale';
const style = {
fontSize: toCU(24), // "calc(24 * var(--cu, 1px))"
padding: toCU(16), // "calc(16 * var(--cu, 1px))"
borderRadius: toCU(8), // "calc(8 * var(--cu, 1px))"
};
```
### Scale Factor Calculation
The scale factor is calculated to fit the design canvas within the viewport while maintaining aspect ratio:
```typescript
// canvasScale.ts
export function calculateCanvasScale(
viewportWidth: number,
viewportHeight: number,
designWidth: number = 1920,
designHeight: number = 1080,
): number {
const scaleX = viewportWidth / designWidth;
const scaleY = viewportHeight / designHeight;
// Use min() to fit content within viewport (letterbox/pillarbox)
const scale = Math.min(scaleX, scaleY);
// Clamp to configured min/max (0.1 - 4.0)
return Math.max(0.1, Math.min(4.0, scale));
}
```
### Letterbox Mode
When the viewport aspect ratio doesn't match the design aspect ratio, the system creates black bars (letterbox for horizontal bars, pillarbox for vertical bars) to maintain content proportions.
```typescript
// Generated letterbox styles
const letterboxStyles: CSSProperties = {
width: designWidth * scale, // Actual canvas width
height: designHeight * scale, // Actual canvas height
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)', // Center in viewport
};
```
**Visual Example:**
```
┌─────────────────────────────────────────┐
│░░░░░░░░░░░░░ Letterbox Bar ░░░░░░░░░░░░│ ← Black bar (wider viewport)
├─────────────────────────────────────────┤
│ │
│ Canvas Content │ ← Design content (1920×1080)
│ (maintains 16:9 ratio) │
│ │
├─────────────────────────────────────────┤
│░░░░░░░░░░░░░ Letterbox Bar ░░░░░░░░░░░░│ ← Black bar
└─────────────────────────────────────────┘
```
### Global UI Controls
Fullscreen, global sound, and offline controls are positioned against the same
visible canvas rectangle as page elements. Their coordinates are stored as
`xPercent`/`yPercent`. Their dimensions are stored as canvas-width-relative
percentages (`buttonSizePercent`, `iconSizePercent`,
`borderRadiusPercent`), so they scale with the displayed canvas instead of
remaining fixed CSS pixels.
Vertical clamping accounts for the canvas aspect ratio because button height is
also derived from canvas width. This keeps controls fully inside the canvas for
16:9, 4:3, ultra-wide, and custom project ratios.
## File Reference
### Configuration
**File:** `frontend/src/config/canvas.config.ts`
```typescript
export const CANVAS_CONFIG = {
// Default design dimensions
defaults: {
width: 1920,
height: 1080,
},
// Common presets for project settings
presets: [
{ name: 'HD 16:9', width: 1920, height: 1080 },
{ name: '4K 16:9', width: 3840, height: 2160 },
{ name: 'HD 4:3', width: 1440, height: 1080 },
{ name: 'Ultra-wide 21:9', width: 2560, height: 1080 },
],
// Scaling behavior
scaling: {
mode: 'fit' as const, // Fit within viewport, may letterbox
minScale: 0.1,
maxScale: 4.0,
},
// Portrait orientation handling
orientation: {
showRotatePrompt: true,
minAspectRatioForPrompt: 0.8,
},
// CSS custom property names
cssVars: {
scale: '--canvas-scale',
unit: '--cu',
designWidth: '--design-width',
designHeight: '--design-height',
},
};
```
### Utilities
**File:** `frontend/src/lib/canvasScale.ts`
| Function | Purpose |
|----------|---------|
| `calculateCanvasScale(vw, vh, dw, dh)` | Calculate scale factor for viewport |
| `toCU(designPixels)` | Convert design pixels to `calc()` expression |
| `isLegacyUnit(value)` | Check if value uses px/rem/vw/vh units |
| `normalizeToCanvasUnits(value, property)` | Convert legacy units to canvas units |
| `getCanvasCssVars(scale, dw, dh)` | Generate CSS custom properties object |
| `vwToDesignPx(vw)` | Convert viewport width to design pixels |
| `vhToDesignPx(vh)` | Convert viewport height to design pixels |
| `remToDesignPx(rem)` | Convert rem to design pixels (16px base) |
### Hook
**File:** `frontend/src/hooks/useCanvasScale.ts`
```typescript
interface UseCanvasScaleOptions {
designWidth?: number; // From project.design_width
designHeight?: number; // From project.design_height
}
interface CanvasScaleResult {
scale: number; // Current scale factor (1.0 = design size)
designWidth: number; // Design canvas width
designHeight: number; // Design canvas height
canvasWidth: number; // Calculated width at current scale
canvasHeight: number; // Calculated height at current scale
isPortrait: boolean; // Viewport is portrait orientation
showRotatePrompt: boolean; // Should show "rotate device" prompt
cssVars: CSSProperties; // CSS custom properties object
letterboxStyles: CSSProperties; // Styles for letterbox container
}
// Usage
const {
scale,
cssVars,
letterboxStyles,
isPortrait,
showRotatePrompt,
} = useCanvasScale({
designWidth: project.design_width,
designHeight: project.design_height,
});
```
### Context (Optional)
**File:** `frontend/src/context/CanvasScaleContext.tsx`
For deeply nested components that need access to canvas scale without prop drilling:
```typescript
// Provider setup
<CanvasScaleProvider
designWidth={project.design_width}
designHeight={project.design_height}
>
{children}
</CanvasScaleProvider>
// Consumer hook
const { scale, cssVars } = useCanvasScaleContext();
// Optional variant (returns null if no provider)
const scaleContext = useCanvasScaleContextOptional();
```
## Element Styling Integration
### elementStyles.ts
**File:** `frontend/src/lib/elementStyles.ts`
The element styles library automatically converts values to canvas units:
```typescript
// Normalization functions
normalizePixelValue('24') // → "calc(24 * var(--cu, 1px))"
normalizePixelValue('24px') // → "calc(24 * var(--cu, 1px))"
normalizeViewportWidth('50vw') // → "calc(960 * var(--cu, 1px))" (50% of 1920)
normalizeViewportHeight('25vh')// → "calc(270 * var(--cu, 1px))" (25% of 1080)
// Build complete style object
const style = buildElementStyle({
width: '200',
height: '100',
fontSize: '16',
borderRadius: '8',
});
// Result: all values converted to calc() expressions with --cu
```
### useElementWrapperStyle Hook
**File:** `frontend/src/components/UiElements/shared/useElementWrapperStyle.ts`
Provides consistent styling for UI elements across constructor and runtime:
```typescript
const { className, style } = useElementWrapperStyle({
element,
isSelected: false,
isEditMode: false,
});
// Returns:
// - className: Tailwind classes for appearance
// - style: CSSProperties with canvas unit values
```
## Usage Patterns
### RuntimePresentation
```typescript
// RuntimePresentation.tsx
const { cssVars, letterboxStyles } = useCanvasScale({
designWidth: project.design_width,
designHeight: project.design_height,
});
return (
<div className="relative w-screen h-screen overflow-hidden bg-black">
{/* Inner canvas: maintains aspect ratio */}
<div
className="overflow-hidden"
style={{
...cssVars, // Sets --cu, --canvas-scale, etc.
...letterboxStyles, // Centers and sizes canvas
}}
>
{/* All child elements use --cu for sizing */}
{elements.map(el => <RuntimeElement key={el.id} element={el} />)}
</div>
</div>
);
```
### Constructor
```typescript
// constructor.tsx
const { cssVars, letterboxStyles } = useCanvasScale({
designWidth: project?.design_width,
designHeight: project?.design_height,
});
return (
<div
className="overflow-hidden"
style={{
...cssVars,
...letterboxStyles,
}}
>
<CanvasBackground ... />
<div className="absolute inset-0 z-10">
{elements.map(el => <CanvasElement key={el.id} element={el} />)}
</div>
</div>
);
```
### Individual Elements
```typescript
// Element component using canvas units
import { toCU } from '../../lib/canvasScale';
const ButtonElement = ({ element }) => {
return (
<button
style={{
fontSize: toCU(element.fontSize || 16),
padding: `${toCU(8)} ${toCU(16)}`,
borderRadius: toCU(element.borderRadius || 4),
}}
>
{element.label}
</button>
);
};
```
## CSS Custom Properties
The system sets these CSS custom properties on the canvas container:
| Property | Description | Example Value |
|----------|-------------|---------------|
| `--cu` | Canvas unit (1 design pixel) | `calc(1px * 0.75)` |
| `--canvas-scale` | Current scale factor | `0.75` |
| `--design-width` | Design canvas width | `1920` |
| `--design-height` | Design canvas height | `1080` |
**CSS Usage:**
```css
.element {
/* Use --cu for scalable dimensions */
font-size: calc(18 * var(--cu, 1px));
/* Use --canvas-scale for transforms */
transform: scale(var(--canvas-scale, 1));
/* Use design dimensions for calculations */
width: calc(var(--design-width) * 0.5 * var(--cu, 1px));
}
```
## Legacy Unit Migration
When encountering legacy units (px, vw, vh, rem), use the normalization utilities:
```typescript
import { normalizeToCanvasUnits } from '../lib/canvasScale';
// Convert various legacy formats
normalizeToCanvasUnits('24px', 'fontSize'); // → "calc(24 * var(--cu, 1px))"
normalizeToCanvasUnits('50vw', 'width'); // → "calc(960 * var(--cu, 1px))"
normalizeToCanvasUnits('25vh', 'height'); // → "calc(270 * var(--cu, 1px))"
normalizeToCanvasUnits('1.5rem', 'fontSize'); // → "calc(24 * var(--cu, 1px))"
normalizeToCanvasUnits(100, 'width'); // → "calc(100 * var(--cu, 1px))"
```
## Orientation Handling
The system detects portrait orientation and can prompt users to rotate their device:
```typescript
const { isPortrait, showRotatePrompt } = useCanvasScale({
designWidth: 1920,
designHeight: 1080,
});
// showRotatePrompt is true when:
// 1. Device is in portrait mode (height > width)
// 2. orientation.showRotatePrompt is enabled in config
// 3. Aspect ratio < minAspectRatioForPrompt (0.8)
{showRotatePrompt && (
<RotateDeviceOverlay />
)}
```
## Integration Points
| Component | Uses | Purpose |
|-----------|------|---------|
| `RuntimePresentation` | `useCanvasScale` | Full-screen tour playback |
| `constructor.tsx` | `useCanvasScale` | Tour editing canvas |
| `TransitionPreviewOverlay` | `letterboxStyles` prop | Transition videos within canvas |
| `CanvasBackground` | Inherits from parent | Background media display |
| `RuntimeElement` | `buildElementStyle` | Element rendering |
| `CanvasElement` | `useElementWrapperStyle` | Element editing |
| `CarouselElement` | `toCU()` | Gallery styling |
| `gallerySectionStyles` | `toCU()` | Gallery section dimensions |
## Performance Considerations
1. **Resize listener** - Uses single `resize` event listener, debounced via React state
2. **Memoization** - Scale calculations wrapped in `useMemo` to prevent recalculation
3. **CSS calc()** - Browser handles scaling efficiently via CSS custom properties
4. **No layout thrashing** - Scale updates don't trigger element-by-element recalculation
## Troubleshooting
### Elements Not Scaling
1. Verify `cssVars` is applied to a parent container
2. Check that values use `toCU()` or normalization functions
3. Ensure `--cu` is not overridden by other styles
### Letterbox Not Appearing
1. Parent container must have `overflow: hidden` and `bg-black`
2. `letterboxStyles` must be applied to inner container
3. Check that viewport dimensions are being detected (resize listener)
### Scale Factor Too Small/Large
1. Check `minScale` and `maxScale` in canvas.config.ts
2. Verify project's `design_width` and `design_height` are set correctly
3. Test with different viewport sizes to confirm scaling behavior

View File

@ -0,0 +1,410 @@
# UI Element Processing & Neighbor Preloading Analysis
## Executive Summary
Deep analysis of how UI elements are processed throughout the Tour Builder Platform - from creation in the Constructor, to rendering in Runtime Presentations, across Online and Offline modes. This document traces each preloading thread step-by-step to verify robustness.
---
## 1. PRELOAD CONFIGURATION (Single Source of Truth)
**File:** `src/config/preload.config.ts`
### 1.1 Asset URL Fields Configuration
```typescript
assetFields: {
// All 18 URL fields for preloading extraction
all: [
'iconUrl', // Base element icon
'imageUrl', // Generic image reference
'mediaUrl', // Video/audio player source
'videoUrl', // Video element
'audioUrl', // Audio element
'transitionVideoUrl', // Navigation transition video
'backgroundImageUrl', // Element background
'reverseVideoUrl', // Reverse transition video
'carouselPrevIconUrl', // Carousel prev button
'carouselNextIconUrl', // Carousel next button
'galleryHeaderImageUrl', // Gallery header background
'galleryCarouselPrevIconUrl', // Gallery carousel prev
'galleryCarouselNextIconUrl', // Gallery carousel next
'galleryCarouselBackIconUrl', // Gallery carousel back
'src', 'url', 'poster', 'thumbnail', // Generic fallbacks
],
// 10 Image-only fields for pre-decode optimization
images: [
'iconUrl', 'imageUrl', 'backgroundImageUrl',
'carouselPrevIconUrl', 'carouselNextIconUrl',
'galleryHeaderImageUrl', 'galleryCarouselPrevIconUrl',
'galleryCarouselNextIconUrl', 'galleryCarouselBackIconUrl', 'src',
],
// 3 Nested array fields containing assets
nested: ['galleryCards', 'carouselSlides', 'galleryInfoSpans'],
// 3 URL fields within nested items
nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl'],
}
```
### 1.2 Configuration Consumers (10 files)
| File | Uses | Purpose |
|------|------|---------|
| `extractPageLinks.ts` | all, nested, nestedUrlFields | Explicit field extraction |
| `useNeighborGraph.ts` | all | Recursive traversal (depth 5) |
| `usePreloadOrchestrator.ts` | all | Asset initialization |
| `imagePreDecode.ts` | images, nested, nestedUrlFields | Image-only pre-decode |
| `StorageManager.ts` | storage config | Storage thresholds |
| `DownloadManager.ts` | queue settings | Download concurrency |
| `useStorageQuota.ts` | storage config | Quota warnings |
| `usePreloadProgress.ts` | autoRemove | Progress cleanup |
| `DownloadContext.tsx` | various | Download UI state |
---
## 2. COMPLETE PRELOADING FLOW (Step-by-Step)
### 2.1 Entry Point: RuntimePresentation Mount
```
RuntimePresentation.tsx mounts
extractPageLinksAndElements(pages) [lib/extractPageLinks.ts:105-182]
├── Parse ui_schema_json for each page
├── extractAssetFields(element) → uses PRELOAD_CONFIG.assetFields
│ ├── Extract top-level fields from assetFields.all
│ └── Extract nested arrays (galleryCards, carouselSlides, galleryInfoSpans)
│ └── Within each item, extract nestedUrlFields (imageUrl, videoUrl, iconUrl)
├── Build pageLinks[] for navigation graph
└── Build preloadElements[] with content_json containing asset URLs
usePreloadOrchestrator({ pages, pageLinks, elements, currentPageId })
```
### 2.2 Thread 1: Neighbor Graph Building
```
useNeighborGraph({ pages, pageLinks, elements, maxDepth: 1 })
Build adjacencyList (Map<pageId, neighborPageIds[]>) [line 119-141]
├── Initialize all pages in map
└── Add edges from active pageLinks (from_pageId → to_pageId)
getNeighbors(currentPageId, depth) [line 144-177]
├── BFS traversal from current page
├── Track visited pages to avoid cycles
└── Return PreloadNeighborInfo[] sorted by distance
getAssetsForPages(pageIds) [line 180-224]
├── For each page: filter elements by pageId
├── extractAssetsFromContent(content_json) [line 56-106]
│ ├── Parse JSON content
│ ├── checkObject() recursive traversal (depth ≤ 5)
│ │ └── For each field in PRELOAD_CONFIG.assetFields.all:
│ │ └── If string value found, classify asset type and add to assets[]
│ └── Return PreloadAssetInfo[] with { url, pageId, assetType, priority }
└── Also extract transition videos from pageLinks
```
### 2.3 Thread 2: Priority Assignment
```
getPrioritizedAssets(currentPageId, maxDepth) [line 228-274]
Current page assets:
priority = PRELOAD_CONFIG.priority.currentPage (1000)
+ PRELOAD_CONFIG.priority.assetType[type]
Asset type priorities:
├── transition: +150 (highest - needed on navigation click)
├── image: +100 (backgrounds load during transition)
├── audio: +50
└── video: +30
Neighbor page assets:
basePriority = PRELOAD_CONFIG.priority.neighborBase (500) / distance
priority = basePriority + assetType priority
Deduplicate by URL, keep highest priority
Sort descending by priority
```
### 2.4 Thread 3: URL Resolution (Presigned URLs)
```
usePreloadOrchestrator effect on currentPageId change [line 669-924]
Collect storage paths needing presigning:
├── Current page: background_image_url, background_video_url, background_audio_url
├── Element assets from neighborGraph.getPrioritizedAssets()
└── Neighbor pages: background URLs
queuePresignedUrls(storagePaths) [lib/assetUrl.ts]
├── POST /api/file/presign { urls: storagePaths[] }
├── Response: { [storageKey]: presignedUrl }
└── Cache presigned URLs (1-hour expiry)
resolveUrl(storageKey, presignedUrls) [line 761-771]
├── If presignedUrls[storageKey] exists → use presigned URL
└── Fallback → resolveAssetPlaybackUrl (proxy URL)
```
### 2.5 Thread 4: Download Queue Processing
```
addToQueue(item: PreloadQueueItem) [line 494-530]
├── Skip if already in queue or preloaded
├── Insert in priority order (binary search insertion point)
└── Trigger processQueue()
processQueue() [line 355-491]
├── Check: isOnline, queue not empty, not already processing
├── maxConcurrent = recommendedConcurrency (network-aware)
While queue has items AND activeDownloads < maxConcurrent:
├── Shift item from queue
├── Skip if already preloaded (preloadedUrls.has(url))
├── Skip if already cached (isUrlCached → StorageManager.hasAsset)
│ └── If cached: createReadyBlobUrl() for instant display
preloadWithProgress(url, jobId, assetId) [line 140-234]
├── Emit downloadEventBus.emitPreloadStart
├── fetch(url) with streaming progress
├── Collect chunks, emit progress events
├── Store in Cache API: caches.open(assets).put(url, response)
└── Emit downloadEventBus.emitPreloadComplete
On success:
├── createReadyBlobUrl(url, storageKey)
│ ├── StorageManager.getAsset(url) → blob
│ ├── URL.createObjectURL(blob) → blobUrl
│ ├── If image: decodeImage(blobUrl) → pre-paint ready
│ └── readyBlobUrlsRef.set(url, blobUrl)
└── Also cache under storageKey for post-refresh lookup
On failure (presigned URL CORS):
├── markPresignedUrlFailed(storageKey)
├── Build proxy URL: /api/file/download?privateUrl=...
└── Retry with proxy URL
```
### 2.6 Thread 5: Ready State & Instant Lookup
```
getReadyBlobUrl(url): string | null [line 582-584]
└── readyBlobUrlsRef.current.get(url) → O(1) Map lookup
Usage in RuntimePresentation:
const resolveUrlWithBlob = (url) => {
const blobUrl = preloadOrchestrator.getReadyBlobUrl(url);
return blobUrl || resolveAssetPlaybackUrl(url);
};
<RuntimeElement resolveUrl={resolveUrlWithBlob} ... />
```
---
## 3. ELEMENT TYPE → URL FIELD MAPPING (Complete Audit)
### 3.1 All Element Types (11 total)
| Element Type | URL Fields | Nested Arrays |
|--------------|------------|---------------|
| `navigation_next/prev` | iconUrl, transitionVideoUrl, reverseVideoUrl | - |
| `gallery` | iconUrl, galleryHeaderImageUrl, galleryCarouselPrevIconUrl, galleryCarouselNextIconUrl, galleryCarouselBackIconUrl | galleryCards[].imageUrl, galleryInfoSpans[].iconUrl |
| `carousel` | iconUrl, carouselPrevIconUrl, carouselNextIconUrl | carouselSlides[].imageUrl |
| `video_player` | iconUrl, mediaUrl | - |
| `audio_player` | iconUrl, mediaUrl | - |
| `tooltip` | iconUrl | - |
| `description` | iconUrl | - |
| `spot` | iconUrl | - |
| `logo` | iconUrl | - |
| `popup` | iconUrl | - |
### 3.2 Cross-Reference Verification
**constructor.ts URL Fields vs preload.config.ts assetFields.all:**
| Field in constructor.ts | In assetFields.all? | Status |
|-------------------------|---------------------|--------|
| iconUrl | Yes | OK |
| mediaUrl | Yes | OK |
| backgroundImageUrl | Yes | OK |
| videoUrl | Yes | OK |
| audioUrl | Yes | OK |
| transitionVideoUrl | Yes | OK |
| reverseVideoUrl | Yes | OK |
| galleryHeaderImageUrl | Yes | OK |
| carouselPrevIconUrl | Yes | OK |
| carouselNextIconUrl | Yes | OK |
| galleryCarouselPrevIconUrl | Yes | OK |
| galleryCarouselNextIconUrl | Yes | OK |
| galleryCarouselBackIconUrl | Yes | OK |
**Nested Arrays vs nested config:**
| Nested Array | In nested config? | Status |
|--------------|-------------------|--------|
| galleryCards | Yes | OK |
| carouselSlides | Yes | OK |
| galleryInfoSpans | Yes | OK |
**Nested URL Fields vs nestedUrlFields:**
| Field in Nested Items | In nestedUrlFields? | Status |
|-----------------------|---------------------|--------|
| galleryCards[].imageUrl | Yes | OK |
| carouselSlides[].imageUrl | Yes | OK |
| galleryInfoSpans[].iconUrl | Yes | OK |
---
## 4. OFFLINE MODE PROCESSING
### 4.1 Storage Architecture
```
StorageManager [lib/offline/StorageManager.ts]
├── Threshold: OFFLINE_CONFIG.storage.indexedDbMinSize (5MB)
├── Small files (< 5MB): Cache API
│ └── caches.open('vm-assets').put(url, response)
└── Large files (≥ 5MB): IndexedDB via Dexie
└── OfflineDbManager.storeAsset(asset)
hasAsset(url): Promise<boolean>
1. Check IndexedDB: OfflineDbManager.hasAssetByUrl(url)
2. Check Cache API: caches.open().match(url)
getAsset(url): Promise<Blob | null>
1. Try IndexedDB: OfflineDbManager.getAssetByUrl(url)
2. Try Cache API: caches.open().match(url).blob()
```
### 4.2 Service Worker Integration
```
sw.ts (Serwist-generated)
├── Precache: static assets (JS, CSS, fonts)
├── Runtime caching strategies:
│ ├── API requests: NetworkFirst
│ └── Assets: CacheFirst
└── Offline fallback handling
```
---
## 5. IMAGE PRE-DECODE FLOW
### 5.1 extractPageImageUrls
```
extractPageImageUrls(page) [lib/imagePreDecode.ts:110-175]
Extract background_image_url
Parse ui_schema_json → elements[]
For each element:
├── Direct image fields (assetFields.images):
│ └── iconUrl, imageUrl, backgroundImageUrl, carousel*IconUrl, gallery*IconUrl, src
└── Nested arrays (assetFields.nested):
└── Filter nestedUrlFields to images only:
├── imageUrl (in images array)
├── iconUrl (in images array)
└── videoUrl (not in images - correctly excluded)
```
### 5.2 waitForPageImages
```
waitForPageImages(page, timeoutMs, cacheProvider)
extractPageImageUrls(page) → imageUrls[]
decodeImages(imageUrls, timeoutMs, cacheProvider)
├── For each URL: decodeImage()
│ ├── If cacheProvider: try getCachedBlobUrl() → blob URL (local, fast)
│ └── new Image().decode() or onload fallback
└── Promise.race([all decoded, timeout])
```
---
## 6. ROBUSTNESS VERIFICATION
### 6.1 All Asset Types Covered
| Asset Type | Extracted? | Preloaded? | Pre-decoded? | Cached? |
|------------|------------|------------|--------------|---------|
| Background images | Yes | Yes | Yes | Yes |
| Background videos | Yes | Yes | N/A | Yes |
| Background audio | Yes | Yes | N/A | Yes |
| Element icons | Yes | Yes | Yes | Yes |
| Transition videos | Yes | Yes | N/A | Yes |
| Reverse videos | Yes | Yes | N/A | Yes |
| Gallery cards images | Yes | Yes | Yes | Yes |
| Gallery info span icons | Yes | Yes | Yes | Yes |
| Gallery header image | Yes | Yes | Yes | Yes |
| Gallery carousel icons | Yes | Yes | Yes | Yes |
| Carousel slide images | Yes | Yes | Yes | Yes |
| Carousel nav icons | Yes | Yes | Yes | Yes |
| Media player URLs | Yes | Yes | N/A | Yes |
### 6.2 Extraction Algorithm Robustness
1. **extractPageLinks.ts** - Explicit field extraction
- Uses `assetFields.all` for top-level fields
- Uses `nested` + `nestedUrlFields` for nested arrays
- Handles all configured fields
2. **useNeighborGraph.ts** - Recursive traversal
- `checkObject()` recursively traverses to depth 5
- Matches any field from `assetFields.all` at any nesting level
- Handles deeply nested structures
3. **imagePreDecode.ts** - Image-only extraction
- Filters `nestedUrlFields` against `assetFields.images`
- Only pre-decodes actual images (not videos/audio)
- Correctly filters non-image URLs
### 6.3 Edge Cases Handled
| Edge Case | Handling |
|-----------|----------|
| Empty ui_schema_json | Returns empty arrays safely |
| Missing nested arrays | Skipped gracefully |
| Null/undefined values | Filtered out |
| Already cached assets | Skipped download, creates blob URL |
| Presigned URL CORS failure | Retries with proxy URL |
| Network offline | Skips preloading, uses cache |
| Large files (>=5MB) | Stored in IndexedDB instead of Cache API |
---
## 7. CONCLUSION
The neighbor preloading system is **robust and comprehensive**:
1. **Central Configuration** - All asset URL fields defined in `preload.config.ts`
2. **Consistent Extraction** - All consumers use the same config
3. **Complete Coverage** - All 11 element types and their URL fields are covered
4. **Nested Arrays** - `galleryCards`, `carouselSlides`, `galleryInfoSpans` all handled
5. **Dual Storage** - Cache API for small files, IndexedDB for large files
6. **Fallback Handling** - Presigned URLs with proxy fallback
7. **Network Awareness** - Adaptive concurrency based on connection
**No gaps identified** - all asset types are properly extracted, preloaded, and cached.
---
## Related Documentation
- [assets-preloading.md](../../documentation/assets-preloading.md) - E2E preloading feature documentation
- [runtime-presentation.md](./runtime-presentation.md) - Runtime presentation viewer
- [constructor-page-editor.md](./constructor-page-editor.md) - Constructor page editor
- [hooks-module.md](./hooks-module.md) - Custom hooks reference
- [lib-module.md](./lib-module.md) - Library utilities reference

View File

@ -0,0 +1,495 @@
# Video Hooks Module
## Overview
The Video Hooks module provides **8 primitive hooks** for video playback management. These hooks follow a composition pattern - smaller primitive hooks are combined to build complex video playback scenarios.
**Location:** `frontend/src/hooks/video/`
**Total Files:** 9 TypeScript files (8 hooks + 1 index file)
**Architecture Pattern:** Primitive hooks that can be composed for different use cases:
- **Transition video playback** - `useTransitionPlayback` uses `useVideoBlobUrl`, `useVideoTimeouts`
- **UI element video player** - `useVideoPlayer` uses `useVideoPlaybackCore`
- **Background video** - `useBackgroundVideoPlayback` uses primitives directly
---
## Hook Hierarchy
```
┌─────────────────────────────┐
│ Application Hooks │
│ (useTransitionPlayback, │
│ useBackgroundVideoPlayback)│
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ Composite Hook │
│ useVideoPlaybackCore │
│ useVideoPlayer │
└──────────────┬──────────────┘
┌──────────────────────────┼──────────────────────────┐
│ │ │
┌───────▼───────┐ ┌─────────────▼─────────────┐ ┌─────────▼─────────┐
│ useVideoBlobUrl│ │ useVideoBufferingState │ │ useVideoFirstFrame│
│ URL resolution │ │ Buffering detection │ │ First frame detect│
└───────────────┘ └───────────────────────────┘ └───────────────────┘
│ │ │
┌───────▼───────┐ ┌─────────────▼─────────────┐ ┌─────────▼─────────┐
│useVideoEvent │ │ useVideoErrorRecovery │ │ useVideoTimeouts │
│Manager │ │ Error handling + retry │ │ Timeout management│
└───────────────┘ └───────────────────────────┘ └───────────────────┘
```
---
## Primitive Hooks
### useVideoEventManager
**File:** `useVideoEventManager.ts` (~90 LOC)
**Purpose:** Centralizes video element event listener management with automatic cleanup.
```typescript
type VideoEventType = 'play' | 'pause' | 'ended' | 'timeupdate' | 'waiting' |
'canplay' | 'playing' | 'loadedmetadata' | 'error' | 'progress';
interface UseVideoEventManagerOptions {
videoRef: RefObject<HTMLVideoElement | null>;
handlers: Partial<Record<VideoEventType, () => void>>;
enabled?: boolean;
}
const useVideoEventManager = (options: UseVideoEventManagerOptions): void;
```
**Usage:**
```typescript
useVideoEventManager({
videoRef,
handlers: {
playing: () => setIsPlaying(true),
ended: () => setIsPlaying(false),
waiting: () => setIsBuffering(true),
canplay: () => setIsBuffering(false),
},
enabled: !!videoUrl,
});
```
---
### useVideoBufferingState
**File:** `useVideoBufferingState.ts` (~180 LOC)
**Purpose:** Tracks video buffering state with debouncing to avoid UI flicker.
```typescript
interface UseVideoBufferingStateOptions {
videoRef: RefObject<HTMLVideoElement | null>;
onBufferingChange?: (isBuffering: boolean) => void;
debounceMs?: number; // Default: 100ms
}
interface UseVideoBufferingStateResult {
isBuffering: boolean;
isWaitingForData: boolean;
bufferedRanges: TimeRanges | null;
bufferedPercent: number;
}
```
**Key Features:**
- Debounced state changes to prevent rapid UI updates
- Tracks `waiting` and `canplay` events
- Computes buffered percentage for progress display
- Handles edge cases like `readyState` checks
---
### useVideoBlobUrl
**File:** `useVideoBlobUrl.ts` (~150 LOC)
**Purpose:** Resolves video URLs to blob URLs from preload cache for instant playback.
```typescript
interface PreloadCacheProvider {
getReadyBlobUrl?: (url: string) => string | null;
getReadyBlob?: (url: string) => Blob | null;
getCachedBlobUrl?: (url: string) => Promise<string | null>;
}
interface UseVideoBlobUrlOptions {
videoUrl: string | undefined;
storageKey?: string;
preloadCache?: PreloadCacheProvider;
enabled?: boolean;
}
interface UseVideoBlobUrlResult {
resolvedUrl: string | null;
isFromCache: boolean;
isResolving: boolean;
}
```
**Resolution Priority:**
1. `getReadyBlob(storageKey)` → Create fresh blob URL (avoids decoder issues)
2. `getReadyBlobUrl(storageKey)` → In-memory blob URL (O(1) lookup)
3. `getReadyBlobUrl(videoUrl)` → Fallback lookup by resolved URL
4. `getCachedBlobUrl()` → Cache API/IndexedDB lookup
5. Return original `videoUrl` → Network fetch
**Usage:**
```typescript
const { resolvedUrl, isFromCache } = useVideoBlobUrl({
videoUrl: transition.videoUrl,
storageKey: transition.storageKey,
preloadCache: preloadOrchestrator,
enabled: !!transition,
});
```
---
### useVideoFirstFrame
**File:** `useVideoFirstFrame.ts` (~130 LOC)
**Purpose:** Detects when the first video frame has been rendered using `requestVideoFrameCallback`.
```typescript
interface UseVideoFirstFrameOptions {
videoRef: RefObject<HTMLVideoElement | null>;
onFirstFrame?: () => void;
enabled?: boolean;
}
interface UseVideoFirstFrameResult {
isFirstFrameRendered: boolean;
reset: () => void;
}
```
**Key Features:**
- Uses `requestVideoFrameCallback` API (Safari 15.4+, Chrome 83+)
- Falls back to `playing` event for older browsers
- Provides `reset()` for reuse across video changes
---
### useVideoErrorRecovery
**File:** `useVideoErrorRecovery.ts` (~160 LOC)
**Purpose:** Handles video errors with automatic retry and presigned URL fallback.
```typescript
interface UseVideoErrorRecoveryOptions {
videoRef: RefObject<HTMLVideoElement | null>;
currentUrl: string | null;
storageKey?: string;
maxRetries?: number; // Default: 2
onRecoveryAttempt?: (newUrl: string) => void;
onRecoveryFailed?: (error: string) => void;
}
interface UseVideoErrorRecoveryResult {
errorCount: number;
lastError: string | null;
isRecovering: boolean;
currentResolvedUrl: string | null;
}
```
**Recovery Strategy:**
1. First error → Mark presigned URL failed, retry with proxy URL
2. Second error → Retry with fresh presigned URL request
3. Third error → Call `onRecoveryFailed`
---
### useVideoTimeouts
**File:** `useVideoTimeouts.ts` (~80 LOC)
**Purpose:** Manages timeout IDs for video playback operations with automatic cleanup.
```typescript
interface UseVideoTimeoutsResult {
setLoadTimeout: (callback: () => void, ms: number) => void;
setPlayTimeout: (callback: () => void, ms: number) => void;
setFinishTimeout: (callback: () => void, ms: number) => void;
clearAllTimeouts: () => void;
}
```
**Usage:**
```typescript
const timeouts = useVideoTimeouts();
// Set load timeout
timeouts.setLoadTimeout(() => {
console.warn('Video load timed out');
onError?.('load-timeout');
}, 10000);
// Clear on success
video.oncanplay = () => timeouts.clearAllTimeouts();
```
---
## Composite Hooks
### useVideoPlaybackCore
**File:** `useVideoPlaybackCore.ts` (~280 LOC)
**Purpose:** Core video playback logic combining multiple primitives. Used by `useVideoPlayer`.
```typescript
interface UseVideoPlaybackCoreOptions {
videoRef: RefObject<HTMLVideoElement | null>;
videoUrl: string | undefined;
storageKey?: string;
autoplay?: boolean;
loop?: boolean;
muted?: boolean;
startTime?: number | null;
endTime?: number | null;
preloadCache?: PreloadCacheProvider;
onReady?: () => void;
onEnded?: () => void;
onError?: (error: string) => void;
}
interface UseVideoPlaybackCoreResult {
// State
isReady: boolean;
isPlaying: boolean;
isBuffering: boolean;
isEnded: boolean;
currentTime: number;
duration: number;
// Resolved URL
resolvedUrl: string | null;
isFromCache: boolean;
// Controls
play: () => Promise<void>;
pause: () => void;
seek: (time: number) => void;
reset: () => void;
}
```
**Composes:**
- `useVideoBlobUrl` - URL resolution
- `useVideoBufferingState` - Buffering tracking
- `useVideoFirstFrame` - Ready detection
- `useVideoErrorRecovery` - Error handling
- `useVideoEventManager` - Event listeners
---
### useVideoPlayer
**File:** `useVideoPlayer.ts` (~200 LOC)
**Purpose:** Complete video player hook for UI elements (VideoPlayerElement). Wraps `useVideoPlaybackCore` with UI-specific features.
```typescript
interface UseVideoPlayerOptions extends UseVideoPlaybackCoreOptions {
// Inherited from UseVideoPlaybackCoreOptions
showControls?: boolean;
posterUrl?: string;
onFirstPlay?: () => void;
}
interface UseVideoPlayerResult extends UseVideoPlaybackCoreResult {
// Additional UI state
hasPlayedOnce: boolean;
showPoster: boolean;
progress: number; // 0-100
// UI Controls
togglePlay: () => void;
toggleMute: () => void;
setVolume: (volume: number) => void;
}
```
**Usage in VideoPlayerElement:**
```typescript
const player = useVideoPlayer({
videoRef,
videoUrl: element.videoUrl,
storageKey: element.videoStoragePath,
autoplay: element.autoplay ?? false,
loop: element.loop ?? false,
muted: element.muted ?? true,
preloadCache: preloadOrchestrator,
onReady: () => console.log('Video ready'),
});
return (
<div>
<video ref={videoRef} src={player.resolvedUrl} />
{player.showPoster && <img src={posterUrl} />}
<button onClick={player.togglePlay}>
{player.isPlaying ? 'Pause' : 'Play'}
</button>
<progress value={player.progress} max={100} />
</div>
);
```
---
## Index Exports
**File:** `index.ts` (~60 LOC)
```typescript
// Primitive hooks
export { useVideoEventManager } from './useVideoEventManager';
export { useVideoBufferingState } from './useVideoBufferingState';
export { useVideoBlobUrl } from './useVideoBlobUrl';
export { useVideoFirstFrame } from './useVideoFirstFrame';
export { useVideoErrorRecovery } from './useVideoErrorRecovery';
export { useVideoTimeouts } from './useVideoTimeouts';
// Composite hooks
export { useVideoPlaybackCore } from './useVideoPlaybackCore';
export { useVideoPlayer } from './useVideoPlayer';
// Types
export type { PreloadCacheProvider } from './useVideoBlobUrl';
```
---
## Integration with useTransitionPlayback
The `useTransitionPlayback` hook uses video primitives for transition video playback:
```typescript
// useTransitionPlayback.ts
import { useVideoBlobUrl, useVideoTimeouts, type PreloadCacheProvider } from './video';
export function useTransitionPlayback(options) {
const { transition, videoRef, preloadCache } = options;
// Resolve blob URL from cache
const { resolvedUrl, isFromCache } = useVideoBlobUrl({
videoUrl: transition?.videoUrl,
storageKey: transition?.storageKey,
preloadCache,
enabled: !!transition,
});
// For back navigation, also resolve reverse video
const { resolvedUrl: reverseUrl } = useVideoBlobUrl({
videoUrl: transition?.reverseVideoUrl,
storageKey: transition?.reverseStorageKey,
preloadCache,
enabled: transition?.isBack && !!transition?.reverseVideoUrl,
});
// Manage timeouts
const timeouts = useVideoTimeouts();
// ... playback logic
}
```
---
## Design Patterns
### 1. Primitive Composition
Each primitive hook handles one concern:
- Event management → `useVideoEventManager`
- Buffering state → `useVideoBufferingState`
- URL resolution → `useVideoBlobUrl`
Composite hooks combine primitives:
```typescript
function useVideoPlaybackCore(options) {
const { resolvedUrl } = useVideoBlobUrl(options);
const { isBuffering } = useVideoBufferingState({ videoRef });
const { isFirstFrameRendered } = useVideoFirstFrame({ videoRef });
// ... combine state
}
```
### 2. Enabled Pattern
All hooks accept an `enabled` prop to conditionally activate:
```typescript
const { resolvedUrl } = useVideoBlobUrl({
videoUrl,
enabled: !!videoUrl && isActive, // Only resolve when needed
});
```
### 3. Callback Refs
Expensive operations use refs to avoid re-renders:
```typescript
const timeoutIdsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
const setLoadTimeout = useCallback((cb, ms) => {
const id = setTimeout(cb, ms);
timeoutIdsRef.current.set('load', id);
}, []);
```
### 4. Cleanup on Unmount
All hooks properly cleanup:
```typescript
useEffect(() => {
// Setup event listeners
return () => {
// Cleanup
timeoutIdsRef.current.forEach(clearTimeout);
timeoutIdsRef.current.clear();
};
}, []);
```
---
## Related Documentation
- [Hooks Module](./hooks-module.md) - Parent hooks documentation
- [Navigation & Smooth Transitions](./navigation-smooth-transitions.md) - Transition playback integration
- [Assets Preloading](../../documentation/assets-preloading.md) - Blob URL caching
- [Video Playback](../../documentation/video-playback.md) - Background video implementation
---
## Summary
| Hook | Type | LOC | Purpose |
|------|------|-----|---------|
| `useVideoEventManager` | Primitive | ~90 | Event listener management |
| `useVideoBufferingState` | Primitive | ~180 | Buffering state tracking |
| `useVideoBlobUrl` | Primitive | ~150 | URL resolution from cache |
| `useVideoFirstFrame` | Primitive | ~130 | First frame detection |
| `useVideoErrorRecovery` | Primitive | ~160 | Error handling + retry |
| `useVideoTimeouts` | Primitive | ~80 | Timeout management |
| `useVideoPlaybackCore` | Composite | ~280 | Core playback logic |
| `useVideoPlayer` | Composite | ~200 | UI video player |
| **Total** | | **~1,270** | |
The video hooks module provides reusable primitives that can be composed for any video playback scenario - from transition videos to background videos to UI element players.