From 961241ecc78029d0f948445eae59a2b7e6fd274e Mon Sep 17 00:00:00 2001 From: Dmitri Date: Thu, 26 Mar 2026 21:19:18 +0400 Subject: [PATCH] implemented projects scope separation --- README.md | 477 +++--- backend/README.md | 345 +++- ...i_elements.js => element_type_defaults.js} | 46 +- backend/src/db/api/page_elements.js | 210 --- backend/src/db/api/page_links.js | 263 --- .../src/db/api/project_element_defaults.js | 335 ++++ backend/src/db/api/projects.js | 52 +- backend/src/db/api/runtime-context.js | 18 +- backend/src/db/api/tour_pages.js | 3 - backend/src/db/api/transitions.js | 182 -- ...me-ui-elements-to-element-type-defaults.js | 73 + ...00002-convert-element-type-enum-to-text.js | 176 ++ ...6000003-create-project-element-defaults.js | 158 ++ ...00004-backfill-project-element-defaults.js | 266 +++ ...05-fix-project-audio-tracks-environment.js | 48 + .../20260326000006-copy-dev-to-stage.js | 208 +++ ...0326043002-enforce-environment-not-null.js | 58 + ...60326050442-remove-project-phase-column.js | 31 + ...326054410-remove-entry-page-slug-column.js | 20 + ...0326060000-convert-targetpageid-to-slug.js | 157 ++ ...20260326060001-drop-page-elements-table.js | 98 ++ .../20260326060002-drop-page-links-table.js | 106 ++ .../20260326060003-drop-transitions-table.js | 103 ++ ...71017-add-missing-element-type-defaults.js | 141 ++ ...i_elements.js => element_type_defaults.js} | 25 +- backend/src/db/models/page_elements.js | 187 --- backend/src/db/models/page_links.js | 148 -- .../src/db/models/project_element_defaults.js | 98 ++ backend/src/db/models/projects.js | 47 +- backend/src/db/models/tour_pages.js | 30 - backend/src/db/models/transitions.js | 168 -- .../db/seeders/20200430130760-user-roles.js | 2 +- .../db/seeders/20231127130745-sample-data.js | 983 +---------- backend/src/helpers.js | 2 +- backend/src/index.js | 27 +- backend/src/middlewares/runtime-context.js | 115 +- backend/src/middlewares/runtime-public.js | 49 +- backend/src/routes/element_type_defaults.js | 7 + backend/src/routes/page_elements.js | 153 -- backend/src/routes/page_links.js | 139 -- .../src/routes/project_element_defaults.js | 81 + backend/src/routes/projects.js | 7 +- backend/src/routes/publish.js | 36 + backend/src/routes/transitions.js | 147 -- backend/src/routes/ui_elements.js | 7 - backend/src/services/element_type_defaults.js | 6 + backend/src/services/page_elements.js | 6 - backend/src/services/page_links.js | 6 - .../src/services/project_element_defaults.js | 31 + backend/src/services/projects.js | 2 - backend/src/services/publish.js | 359 ++-- backend/src/services/pwa_manifest.js | 87 +- backend/src/services/search.js | 107 +- backend/src/services/transitions.js | 6 - backend/src/services/ui_elements.js | 6 - frontend/README.md | 439 ++++- .../Page_elements/CardPage_elements.tsx | 221 --- .../Page_elements/ListPage_elements.tsx | 151 -- .../Page_elements/TablePage_elements.tsx | 262 --- .../configurePage_elementsCols.tsx | 223 --- .../components/Page_links/CardPage_links.tsx | 175 -- .../components/Page_links/ListPage_links.tsx | 131 -- .../components/Page_links/TablePage_links.tsx | 48 - .../Page_links/configurePage_linksCols.tsx | 169 -- .../src/components/Projects/CardProjects.tsx | 18 - .../src/components/Projects/ListProjects.tsx | 12 - .../Projects/configureProjectsCols.tsx | 24 - .../src/components/RuntimePresentation.tsx | 478 +++--- frontend/src/components/TourFlowManager.tsx | 55 +- .../Transitions/CardTransitions.tsx | 187 --- .../Transitions/ListTransitions.tsx | 135 -- .../Transitions/TableTransitions.tsx | 48 - .../Transitions/configureTransitionsCols.tsx | 177 -- frontend/src/config/preload.config.ts | 8 +- frontend/src/hooks/useNeighborGraph.ts | 17 +- frontend/src/hooks/usePageNavigation.ts | 14 +- frontend/src/hooks/usePageSwitch.ts | 423 +++++ frontend/src/hooks/usePreloadOrchestrator.ts | 129 +- frontend/src/hooks/useTransitionPlayback.ts | 9 +- frontend/src/layouts/Authenticated.tsx | 13 +- frontend/src/lib/elementStyles.ts | 6 +- frontend/src/lib/extractPageLinks.ts | 182 ++ frontend/src/lib/imagePreDecode.ts | 85 +- frontend/src/menuAside.ts | 4 +- frontend/src/pages/constructor.tsx | 414 +++-- frontend/src/pages/dashboard.tsx | 110 -- ...elements.tsx => element-type-defaults.tsx} | 32 +- .../[id].tsx | 49 +- frontend/src/pages/index.tsx | 42 +- frontend/src/pages/p/[projectSlug]/stage.tsx | 8 +- .../pages/page_elements/[page_elementsId].tsx | 268 --- .../page_elements/page_elements-edit.tsx | 253 --- .../page_elements/page_elements-list.tsx | 232 --- .../pages/page_elements/page_elements-new.tsx | 209 --- .../page_elements-project-edit.tsx | 1495 ----------------- .../page_elements/page_elements-table.tsx | 287 ---- .../page_elements/page_elements-view.tsx | 167 -- .../src/pages/page_links/[page_linksId].tsx | 197 --- .../src/pages/page_links/page_links-edit.tsx | 176 -- .../src/pages/page_links/page_links-list.tsx | 33 - .../src/pages/page_links/page_links-new.tsx | 148 -- .../src/pages/page_links/page_links-table.tsx | 182 -- .../src/pages/page_links/page_links-view.tsx | 155 -- .../src/pages/project-element-defaults.tsx | 199 +++ .../pages/project-element-defaults/[id].tsx | 374 +++++ frontend/src/pages/projects/[projectsId].tsx | 14 +- frontend/src/pages/projects/projects-edit.tsx | 14 - frontend/src/pages/projects/projects-list.tsx | 4 - frontend/src/pages/projects/projects-new.tsx | 14 - .../src/pages/projects/projects-table.tsx | 8 - frontend/src/pages/projects/projects-view.tsx | 90 - frontend/src/pages/runtime.tsx | 579 ------- .../src/pages/tour_pages/tour_pages-view.tsx | 192 --- .../src/pages/transitions/[transitionsId].tsx | 185 -- .../pages/transitions/transitions-edit.tsx | 176 -- .../pages/transitions/transitions-list.tsx | 15 - .../src/pages/transitions/transitions-new.tsx | 150 -- .../pages/transitions/transitions-table.tsx | 186 -- .../pages/transitions/transitions-view.tsx | 210 --- frontend/src/schemas/projectSchema.ts | 2 - .../page_elements/page_elementsSlice.ts | 25 - .../src/stores/page_links/page_linksSlice.ts | 25 - frontend/src/stores/store.ts | 6 - .../stores/transitions/transitionsSlice.ts | 25 - frontend/src/types/constructor.ts | 114 +- frontend/src/types/entities.ts | 11 +- frontend/src/types/runtime.ts | 1 - 127 files changed, 5557 insertions(+), 12010 deletions(-) rename backend/src/db/api/{ui_elements.js => element_type_defaults.js} (85%) delete mode 100644 backend/src/db/api/page_elements.js delete mode 100644 backend/src/db/api/page_links.js create mode 100644 backend/src/db/api/project_element_defaults.js delete mode 100644 backend/src/db/api/transitions.js create mode 100644 backend/src/db/migrations/20260326000001-rename-ui-elements-to-element-type-defaults.js create mode 100644 backend/src/db/migrations/20260326000002-convert-element-type-enum-to-text.js create mode 100644 backend/src/db/migrations/20260326000003-create-project-element-defaults.js create mode 100644 backend/src/db/migrations/20260326000004-backfill-project-element-defaults.js create mode 100644 backend/src/db/migrations/20260326000005-fix-project-audio-tracks-environment.js create mode 100644 backend/src/db/migrations/20260326000006-copy-dev-to-stage.js create mode 100644 backend/src/db/migrations/20260326043002-enforce-environment-not-null.js create mode 100644 backend/src/db/migrations/20260326050442-remove-project-phase-column.js create mode 100644 backend/src/db/migrations/20260326054410-remove-entry-page-slug-column.js create mode 100644 backend/src/db/migrations/20260326060000-convert-targetpageid-to-slug.js create mode 100644 backend/src/db/migrations/20260326060001-drop-page-elements-table.js create mode 100644 backend/src/db/migrations/20260326060002-drop-page-links-table.js create mode 100644 backend/src/db/migrations/20260326060003-drop-transitions-table.js create mode 100644 backend/src/db/migrations/20260326171017-add-missing-element-type-defaults.js rename backend/src/db/models/{ui_elements.js => element_type_defaults.js} (70%) delete mode 100644 backend/src/db/models/page_elements.js delete mode 100644 backend/src/db/models/page_links.js create mode 100644 backend/src/db/models/project_element_defaults.js delete mode 100644 backend/src/db/models/transitions.js create mode 100644 backend/src/routes/element_type_defaults.js delete mode 100644 backend/src/routes/page_elements.js delete mode 100644 backend/src/routes/page_links.js create mode 100644 backend/src/routes/project_element_defaults.js delete mode 100644 backend/src/routes/transitions.js delete mode 100644 backend/src/routes/ui_elements.js create mode 100644 backend/src/services/element_type_defaults.js delete mode 100644 backend/src/services/page_elements.js delete mode 100644 backend/src/services/page_links.js create mode 100644 backend/src/services/project_element_defaults.js delete mode 100644 backend/src/services/transitions.js delete mode 100644 backend/src/services/ui_elements.js delete mode 100644 frontend/src/components/Page_elements/CardPage_elements.tsx delete mode 100644 frontend/src/components/Page_elements/ListPage_elements.tsx delete mode 100644 frontend/src/components/Page_elements/TablePage_elements.tsx delete mode 100644 frontend/src/components/Page_elements/configurePage_elementsCols.tsx delete mode 100644 frontend/src/components/Page_links/CardPage_links.tsx delete mode 100644 frontend/src/components/Page_links/ListPage_links.tsx delete mode 100644 frontend/src/components/Page_links/TablePage_links.tsx delete mode 100644 frontend/src/components/Page_links/configurePage_linksCols.tsx delete mode 100644 frontend/src/components/Transitions/CardTransitions.tsx delete mode 100644 frontend/src/components/Transitions/ListTransitions.tsx delete mode 100644 frontend/src/components/Transitions/TableTransitions.tsx delete mode 100644 frontend/src/components/Transitions/configureTransitionsCols.tsx create mode 100644 frontend/src/hooks/usePageSwitch.ts create mode 100644 frontend/src/lib/extractPageLinks.ts rename frontend/src/pages/{ui-elements.tsx => element-type-defaults.tsx} (75%) rename frontend/src/pages/{ui-elements => element-type-defaults}/[id].tsx (97%) delete mode 100644 frontend/src/pages/page_elements/[page_elementsId].tsx delete mode 100644 frontend/src/pages/page_elements/page_elements-edit.tsx delete mode 100644 frontend/src/pages/page_elements/page_elements-list.tsx delete mode 100644 frontend/src/pages/page_elements/page_elements-new.tsx delete mode 100644 frontend/src/pages/page_elements/page_elements-project-edit.tsx delete mode 100644 frontend/src/pages/page_elements/page_elements-table.tsx delete mode 100644 frontend/src/pages/page_elements/page_elements-view.tsx delete mode 100644 frontend/src/pages/page_links/[page_linksId].tsx delete mode 100644 frontend/src/pages/page_links/page_links-edit.tsx delete mode 100644 frontend/src/pages/page_links/page_links-list.tsx delete mode 100644 frontend/src/pages/page_links/page_links-new.tsx delete mode 100644 frontend/src/pages/page_links/page_links-table.tsx delete mode 100644 frontend/src/pages/page_links/page_links-view.tsx create mode 100644 frontend/src/pages/project-element-defaults.tsx create mode 100644 frontend/src/pages/project-element-defaults/[id].tsx delete mode 100644 frontend/src/pages/runtime.tsx delete mode 100644 frontend/src/pages/transitions/[transitionsId].tsx delete mode 100644 frontend/src/pages/transitions/transitions-edit.tsx delete mode 100644 frontend/src/pages/transitions/transitions-list.tsx delete mode 100644 frontend/src/pages/transitions/transitions-new.tsx delete mode 100644 frontend/src/pages/transitions/transitions-table.tsx delete mode 100644 frontend/src/pages/transitions/transitions-view.tsx delete mode 100644 frontend/src/stores/page_elements/page_elementsSlice.ts delete mode 100644 frontend/src/stores/page_links/page_linksSlice.ts delete mode 100644 frontend/src/stores/transitions/transitionsSlice.ts diff --git a/README.md b/README.md index 556c934..7410a27 100644 --- a/README.md +++ b/README.md @@ -1,244 +1,275 @@ - - # Tour Builder Platform +A web application for building and managing interactive virtual tours with drag-and-drop editing, video transitions, and PWA offline support. + +## Features + +- **Visual Tour Builder** - Drag-and-drop editor for creating interactive tour pages +- **Video Transitions** - Smooth video-based transitions between pages with forward/reverse playback +- **Multiple Element Types** - Navigation buttons, hotspots, galleries, tooltips, video/audio players +- **Three-Tier Publishing** - Dev → Stage → Production workflow with environment isolation +- **Asset Preloading** - Direct S3 download via presigned URLs for instant page navigation +- **PWA Offline Mode** - Tours work offline with Cache API and IndexedDB storage +- **Role-Based Access Control** - Granular permissions system +- **Team Collaboration** - Project memberships with role-based access +- **Asset Management** - Upload, optimize, and manage media assets with variants +- **Multi-Language Support** - i18n ready + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| Frontend | Next.js 15, React 19, TypeScript, Redux Toolkit, Tailwind CSS | +| Backend | Node.js, Express, Sequelize ORM | +| Database | PostgreSQL | +| Authentication | JWT, Google OAuth, Microsoft OAuth | +| File Storage | AWS S3 / Google Cloud Storage (direct presigned URL access) | +| PWA | Serwist Service Worker, Cache API, IndexedDB (Dexie) | + +## Quick Start + +### Prerequisites + +- Node.js 18+ +- PostgreSQL 14+ +- Yarn (backend) / npm (frontend) + +### Database Setup (First Time) + +```bash +# Create database user and database +PGPASSWORD='postgres' psql -U postgres -c "CREATE USER app_39215 WITH PASSWORD 'your-password';" +PGPASSWORD='postgres' psql -U postgres -c "CREATE DATABASE app_39215 OWNER app_39215;" +``` + +### Start Backend (Terminal 1) + +```bash +cd backend +yarn install +export $(cat .env | xargs) && NODE_ENV=production yarn start +``` + +Backend runs on **http://localhost:8080** + +### Start Frontend (Terminal 2) + +```bash +cd frontend +npm install +npm run dev +``` + +Frontend runs on **http://localhost:3000** + +### Default Login + +After seeding, login with credentials configured in `backend/.env`: +- Email: `ADMIN_EMAIL` (default: admin@flatlogic.com) +- Password: `ADMIN_PASS` (default: 88dbeaf8) + +## Project Structure + +``` +├── backend/ # Node.js/Express API server +│ ├── src/ +│ │ ├── routes/ # REST API endpoints +│ │ ├── services/ # Business logic +│ │ ├── db/ +│ │ │ ├── models/ # Sequelize models +│ │ │ ├── api/ # Database access layer +│ │ │ ├── migrations/ # Schema migrations +│ │ │ └── seeders/ # Seed data +│ │ ├── auth/ # Passport.js authentication +│ │ └── middlewares/ # Express middlewares +│ └── README.md # Backend documentation +│ +├── frontend/ # Next.js React application +│ ├── src/ +│ │ ├── pages/ # Next.js pages +│ │ ├── components/ # React components +│ │ ├── stores/ # Redux Toolkit slices +│ │ ├── hooks/ # Custom React hooks +│ │ ├── types/ # TypeScript definitions +│ │ └── lib/ # Utility libraries +│ └── README.md # Frontend documentation +│ +└── docker/ # Docker Compose setup + ├── docker-compose.yml + ├── start-backend.sh + └── wait-for-it.sh +``` + +## Key Workflows + +### Tour Creation + +1. Create a new project in the dashboard +2. Open the **Constructor** (`/constructor?projectId=...`) +3. Add pages with background images/videos +4. Place interactive elements (buttons, hotspots, etc.) +5. Configure navigation targets and transitions on elements +6. Preview in **Runtime** mode +7. Publish: Dev → Stage → Production + +### Publishing Flow + +Three-tier environment model with separate content per environment: + +``` + Dev Environment Stage Environment Production Environment + │ │ │ + /constructor?projectId= /p/[slug]/stage /p/[slug] + (editing mode) (preview) (public access) + │ │ │ + └── Save to Stage ──────►└── Publish ─────────────►│ +``` + +| Action | Endpoint | Description | +|--------|----------|-------------| +| Save to Stage | `POST /api/publish/save-to-stage` | Copy dev pages to stage | +| Publish | `POST /api/publish` | Copy stage pages to production | + +Pages have an `environment` field (`dev`, `stage`, `production`) that determines visibility. + +### Element Types + +| Type | Description | +|------|-------------| +| `navigation_next` | Forward navigation button | +| `navigation_prev` | Back navigation button | +| `spot` | Clickable hotspot area | +| `description` | Text description overlay | +| `tooltip` | Hover tooltip | +| `gallery` | Image gallery | +| `carousel` | Image carousel | +| `logo` | Logo element | +| `video_player` | Embedded video player | +| `audio_player` | Audio player | +| `popup` | Modal popup | + +## API Overview + +Base URL: `http://localhost:8080/api` + +| Endpoint | Description | +|----------|-------------| +| `POST /auth/signin/local` | Login | +| `POST /auth/signup` | Register | +| `GET /auth/me` | Current user | +| `GET /projects` | List projects | +| `POST /publish/save-to-stage` | Copy dev → stage | +| `POST /publish` | Copy stage → production | +| `GET /tour_pages` | List tour pages | +| `GET /assets` | List assets | +| `POST /file/presign` | Get S3 presigned URLs for asset download (public) | + +Full API documentation: `http://localhost:8080/api-docs` (Swagger) + +## Docker Setup + +```bash +cd docker +chmod +x start-backend.sh wait-for-it.sh + +# Start with fresh database +rm -rf data && docker-compose up + +# Or keep existing data +docker-compose up +``` + +Access at `http://localhost:3000` + +## Environment Variables + +### Backend (`backend/.env`) + +```env +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=app_39215 +DB_USER=app_39215 +DB_PASSWORD=your-password + +# JWT +SECRET_KEY=your-secret-key -## This project was generated by [Flatlogic Platform](https://flatlogic.com). +# Admin (for seeding) +ADMIN_EMAIL=admin@example.com +ADMIN_PASS=admin-password - - - Frontend: [React.js](https://flatlogic.com/templates?framework%5B%5D=react&sort=default) - - - - - - - - +# AWS S3 (optional) +AWS_S3_BUCKET=your-bucket +AWS_S3_REGION=us-east-1 +AWS_ACCESS_KEY_ID=your-key +AWS_SECRET_ACCESS_KEY=your-secret + +# OAuth (optional) +GOOGLE_CLIENT_ID=... +GOOGLE_CLIENT_SECRET=... +MS_CLIENT_ID=... +MS_CLIENT_SECRET=... +# Email - AWS SES (optional) +EMAIL_USER=... +EMAIL_PASS=... +``` + +### Frontend (`frontend/.env.local`) - - - Backend: [NodeJS](https://flatlogic.com/templates?backend%5B%5D=nodejs&sort=default) - -
Backend Folder Structure - - The generated application has the following backend folder structure: +```env +NEXT_PUBLIC_API_URL=http://localhost:8080 +``` - `src` folder which contains your working files that will be used later to create the build. The src folder contains folders as: +## Common Commands - - `auth` - config the library for authentication and authorization; +### Backend - - `db` - contains such folders as: +```bash +cd backend +yarn start # Start server (migrate + seed + watch) +yarn db:migrate # Run migrations +yarn db:seed # Seed data +yarn db:reset # Drop + create + migrate + seed +yarn lint # ESLint +``` - - `api` - documentation that is automatically generated by jsdoc or other tools; +### Frontend - - `migrations` - is a skeleton of the database or all the actions that users do with the database; +```bash +cd frontend +npm run dev # Development server +npm run build # Production build +npm run lint # ESLint +npm run format # Prettier +``` - - `models`- what will represent the database for the backend; +## Troubleshooting - - `seeders` - the entity that creates the data for the database. +### Connection Refused - - `routes` - this folder would contain all the routes that you have created using Express Router and what they do would be exported from a Controller file; +1. Ensure PostgreSQL is running +2. Check that port 5432 (db), 8080 (backend), 3000 (frontend) are available +3. Verify database credentials in `.env` - - `services` - contains such folders as `emails` and `notifications`. -
- - - - - +### Database Issues +```bash +# Reset database completely +cd backend +yarn db:reset +``` - - - - - Database: PostgreSQL - +### Permission Denied - - app-shel: Core application framework that provides essential infrastructure services -for the entire application. - ----------------------- -### We offer 2 ways how to start the project locally: by running Frontend and Backend or with Docker. ------------------------ +Ensure the database user has proper privileges: -## To start the project: +```sql +GRANT ALL PRIVILEGES ON DATABASE app_39215 TO app_39215; +``` -### Backend: +## License -> Please change current folder: `cd backend` - - - -#### Install local dependencies: -`yarn install` - - ------------ - -#### Adjust local db: -##### 1. Install postgres: - -MacOS: - -`brew install postgres` - - > if you don’t have ‘brew‘ please install it (https://brew.sh) and repeat step `brew install postgres`. - -Ubuntu: - -`sudo apt update` - -`sudo apt install postgresql postgresql-contrib` - -##### 2. Create db and admin user: -Before run and test connection, make sure you have created a database as described in the above configuration. You can use the `psql` command to create a user and database. - -`psql postgres --u postgres` - -Next, type this command for creating a new user with password then give access for creating the database. - -`postgres-# CREATE ROLE admin WITH LOGIN PASSWORD 'admin_pass';` - -`postgres-# ALTER ROLE admin CREATEDB;` - -Quit `psql` then log in again using the new user that previously created. - -`postgres-# \q` - -`psql postgres -U admin` - -Type this command to creating a new database. - -`postgres=> CREATE DATABASE db_{your_project_name};` - -Then give that new user privileges to the new database then quit the `psql`. - -`postgres=> GRANT ALL PRIVILEGES ON DATABASE db_{your_project_name} TO admin;` - -`postgres=> \q` - - - - - - ------------ - - -#### Create database: -`yarn db:create` - -#### Start production build: -`yarn start` - - - - - -### Frontend: - -> Please change current folder: `cd frontend` - - - - - - - - - - - - -## To start the project with Docker: -### Description: - -The project contains the **docker folder** and the `Dockerfile`. - -The `Dockerfile` is used to Deploy the project to Google Cloud. - -The **docker folder** contains a couple of helper scripts: - -- `docker-compose.yml` (all our services: web, backend, db are described here) -- `start-backend.sh` (starts backend, but only after the database) -- `wait-for-it.sh` (imported from https://github.com/vishnubob/wait-for-it) - - > To avoid breaking the application, we recommend you don't edit the following files: everything that includes the **docker folder** and `Dokerfile`. - -## Run services: - -1. Install docker compose (https://docs.docker.com/compose/install/) - -2. Move to `docker` folder. All next steps should be done from this folder. - - ``` cd docker ``` - -3. Make executables from `wait-for-it.sh` and `start-backend.sh`: - - ``` chmod +x start-backend.sh && chmod +x wait-for-it.sh ``` - -4. Download dependend projects for services. - -5. Review the docker-compose.yml file. Make sure that all services have Dockerfiles. Only db service doesn't require a Dockerfile. - -6. Make sure you have needed ports (see them in `ports`) available on your local machine. - -7. Start services: - - 7.1. With an empty database `rm -rf data && docker-compose up` - - 7.2. With a stored (from previus runs) database data `docker-compose up` - -8. Check http://localhost:3000 - -9. Stop services: - - 9.1. Just press `Ctr+C` - -## Most common errors: - -1. `connection refused` - - There could be many reasons, but the most common are: - - - The port is not open on the destination machine. - - - The port is open on the destination machine, but its backlog of pending connections is full. - - - A firewall between the client and server is blocking access (also check local firewalls). - - After checking for firewalls and that the port is open, use telnet to connect to the IP/port to test connectivity. This removes any potential issues from your application. - - ***MacOS:*** - - If you suspect that your SSH service might be down, you can run this command to find out: - - `sudo service ssh status` - - If the command line returns a status of down, then you’ve likely found the reason behind your connectivity error. - - ***Ubuntu:*** - - Sometimes a connection refused error can also indicate that there is an IP address conflict on your network. You can search for possible IP conflicts by running: - - `arp-scan -I eth0 -l | grep ` - - `arp-scan -I eth0 -l | grep ` - - and - - `arping ` - -2. `yarn db:create` creates database with the assembled tables (on MacOS with Postgres database) - - The workaround - put the next commands to your Postgres database terminal: - - `DROP SCHEMA public CASCADE;` - - `CREATE SCHEMA public;` - - `GRANT ALL ON SCHEMA public TO postgres;` - - `GRANT ALL ON SCHEMA public TO public;` - - Afterwards, continue to start your project in the backend directory by running: - - `yarn start` \ No newline at end of file +Proprietary - Tour Builder Platform diff --git a/backend/README.md b/backend/README.md index f08702f..c9aa366 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,56 +1,323 @@ +# Tour Builder Platform - Backend -#Tour Builder Platform - template backend, +Node.js/Express REST API server with Sequelize ORM for the Tour Builder Platform. -#### Run App on local machine: +## Tech Stack -##### Install local dependencies: -- `yarn install` +- **Runtime**: Node.js 18+ +- **Framework**: Express 4.x +- **Database**: PostgreSQL with Sequelize ORM +- **Authentication**: Passport.js (JWT, Google OAuth, Microsoft OAuth) +- **File Storage**: AWS S3 / Google Cloud Storage / Local filesystem +- **Email**: Nodemailer with AWS SES +- **API Docs**: Swagger/OpenAPI ------------- +## Prerequisites -##### Adjust local db: -###### 1. Install postgres: - - MacOS: - - `brew install postgres` +- Node.js 18+ +- PostgreSQL 14+ +- Yarn package manager -- Ubuntu: - - `sudo apt update` - - `sudo apt install postgresql postgresql-contrib` +## Quick Start -###### 2. Create db and admin user: - - Before run and test connection, make sure you have created a database as described in the above configuration. You can use the `psql` command to create a user and database. - - `psql postgres --u postgres` +```bash +# Install dependencies +yarn install -- Next, type this command for creating a new user with password then give access for creating the database. - - `postgres-# CREATE ROLE admin WITH LOGIN PASSWORD 'admin_pass';` - - `postgres-# ALTER ROLE admin CREATEDB;` +# Create database (first time only) +yarn db:create -- Quit `psql` then log in again using the new user that previously created. - - `postgres-# \q` - - `psql postgres -U admin` +# Start server (runs migrations, seeds, and watches for changes) +export $(cat .env | xargs) && NODE_ENV=production yarn start +``` -- Type this command to creating a new database. - - `postgres=> CREATE DATABASE db_tour_builder_platform;` +The server runs on **port 8080** by default. -- Then give that new user privileges to the new database then quit the `psql`. - - `postgres=> GRANT ALL PRIVILEGES ON DATABASE db_tour_builder_platform TO admin;` - - `postgres=> \q` - ------------- +## Environment Variables -#### Api Documentation (Swagger) +Create a `.env` file in the backend directory: -http://localhost:8080/api-docs (local host) +```env +# Database (required) +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=app_39215 +DB_USER=app_39215 +DB_PASSWORD=your_password -http://host_name/api-docs +# JWT Secret (required) +SECRET_KEY=your-secret-key ------------- +# Admin credentials (for seeding) +ADMIN_EMAIL=admin@example.com +ADMIN_PASS=admin_password +USER_PASS=user_password - ##### Setup database tables or update after schema change - - `yarn db:migrate` +# AWS S3 (optional - for file storage) +AWS_S3_BUCKET=your-bucket +AWS_S3_REGION=us-east-1 +AWS_ACCESS_KEY_ID=your-access-key +AWS_SECRET_ACCESS_KEY=your-secret-key +AWS_S3_PREFIX=your-prefix - ##### Seed the initial data (admin accounts, relevant for the first setup): - - `yarn db:seed` - - ##### Start build: - - `yarn start` +# Google OAuth (optional) +GOOGLE_CLIENT_ID=your-client-id +GOOGLE_CLIENT_SECRET=your-client-secret + +# Microsoft OAuth (optional) +MS_CLIENT_ID=your-client-id +MS_CLIENT_SECRET=your-client-secret + +# Email - AWS SES (optional) +EMAIL_USER=ses-smtp-user +EMAIL_PASS=ses-smtp-password + +# OpenAI (optional) +GPT_KEY=your-openai-key +``` + +## Project Structure + +``` +backend/src/ +├── index.js # Express app entry point +├── config.js # Environment configuration +├── helpers.js # Utility functions (wrapAsync) +│ +├── auth/ # Passport.js authentication strategies +│ └── passport.js # JWT, Google, Microsoft strategies +│ +├── db/ +│ ├── models/ # Sequelize model definitions +│ ├── api/ # Database access layer (CRUD per model) +│ ├── migrations/ # Database migrations +│ └── seeders/ # Seed data (admin users, permissions, roles) +│ +├── routes/ # Express route handlers +│ ├── auth.js # Authentication endpoints +│ ├── projects.js # Project CRUD + publishing +│ ├── tour_pages.js # Tour page management +│ ├── assets.js # Asset management +│ ├── publish.js # Publishing workflow +│ └── ... # Other entity routes +│ +├── services/ # Business logic layer +│ ├── auth.js # Auth service (JWT, OAuth) +│ ├── publish.js # Publishing workflow logic +│ ├── file.js # File storage abstraction +│ ├── email/ # Email templates and sending +│ └── ... # Other services +│ +├── middlewares/ +│ ├── check-permissions.js # RBAC permission checking +│ ├── runtime-context.js # Environment detection from headers +│ └── runtime-public.js # Public runtime access (no auth) +│ +├── factories/ +│ ├── router.factory.js # Generate CRUD routes +│ └── service.factory.js # Generate service classes +│ +└── utils/ + ├── env-validation.js # Environment variable validation + └── ... +``` + +## Database Setup + +### Create Database User and Database + +```bash +# Connect to PostgreSQL +psql postgres -U postgres + +# Create user +CREATE ROLE app_39215 WITH LOGIN PASSWORD 'your-password'; +ALTER ROLE app_39215 CREATEDB; + +# Create database +CREATE DATABASE app_39215 OWNER app_39215; +GRANT ALL PRIVILEGES ON DATABASE app_39215 TO app_39215; + +\q +``` + +### Available Commands + +```bash +yarn db:create # Create database +yarn db:drop # Drop database +yarn db:migrate # Run pending migrations +yarn db:migrate:undo # Undo last migration +yarn db:migrate:undo:all # Undo all migrations +yarn db:migrate:status # Show migration status +yarn db:seed # Run all seeders +yarn db:seed:undo # Undo all seeders +yarn db:reset # Drop, create, migrate, and seed +yarn start # Migrate, seed, and start with watch +yarn lint # Run ESLint +``` + +## API Documentation + +Swagger UI available at: `http://localhost:8080/api-docs` + +### Core Endpoints + +| Endpoint | Description | +|----------|-------------| +| `POST /api/auth/signin/local` | Email/password login | +| `POST /api/auth/signup` | User registration | +| `GET /api/auth/me` | Current user info (JWT required) | +| `GET /api/auth/signin/google` | Google OAuth login | +| `GET /api/auth/signin/microsoft` | Microsoft OAuth login | + +### Entity CRUD Pattern + +All entities follow standard REST patterns: + +``` +GET /api/{entity} # List with pagination & filters +GET /api/{entity}/:id # Get single record +POST /api/{entity} # Create record +PUT /api/{entity}/:id # Update record +DELETE /api/{entity}/:id # Soft delete record +``` + +### Main Entities + +| Entity | Description | +|--------|-------------| +| `projects` | Virtual tour projects | +| `tour_pages` | Pages within a tour (elements, navigation, transitions stored in ui_schema_json) | +| `assets` | Uploaded media files | +| `asset_variants` | Resized/optimized asset versions | +| `element_type_defaults` | Global element default settings | +| `project_element_defaults` | Project-specific element settings | +| `project_audio_tracks` | Background audio for projects | +| `users` | User accounts | +| `roles` | User roles | +| `permissions` | Granular permissions | +| `project_memberships` | Team access per project | + +### Publishing Workflow + +Three-tier environment model for content: `dev` → `stage` → `production` + +``` +POST /api/publish/save-to-stage # Copy dev content to stage (body: { projectId }) +POST /api/publish # Copy stage content to production (body: { projectId }) +``` + +Pages have an `environment` field (`dev`, `stage`, or `production`) that determines visibility: +- **Constructor** (`/constructor?projectId=`) - Always shows `dev` environment +- **Stage preview** (`/p/[slug]/stage`) - Shows `stage` environment +- **Public runtime** (`/p/[slug]`) - Shows `production` environment + +## Authentication + +### JWT Authentication + +Protected routes require JWT token in Authorization header: + +``` +Authorization: Bearer +``` + +### OAuth Providers + +- **Google**: `/api/auth/signin/google` +- **Microsoft**: `/api/auth/signin/microsoft` + +## File Storage + +Storage provider is auto-detected based on available credentials: + +1. **AWS S3** - If `AWS_S3_BUCKET` is configured +2. **Google Cloud Storage** - If GCS credentials are available +3. **Local filesystem** - Fallback (files stored in system temp directory) + +### Upload Flow (Presigned URLs) + +``` +POST /api/file/presigned-url # Get upload URL (authenticated) +PUT {presigned-url} # Upload directly to S3 +POST /api/assets # Register asset in database +``` + +### Download Flow (Direct S3 Access) + +For runtime asset preloading, the frontend can request presigned download URLs: + +``` +POST /api/file/presign # Get download URLs (public endpoint) +Request: { urls: ["assets/img1.jpg", "assets/video.mp4", ...] } +Response: { presignedUrls: { "assets/img1.jpg": "https://s3...", ... } } +``` + +- **Max URLs per request**: 50 +- **URL expiry**: 1 hour +- **Public endpoint**: No authentication required (for runtime playback) + +This allows the frontend to download assets directly from S3, bypassing the backend for better performance. + +## RBAC (Role-Based Access Control) + +### Permission Format + +``` +{ACTION}_{ENTITY} +``` + +Actions: `CREATE`, `READ`, `UPDATE`, `DELETE` + +Example: `CREATE_PROJECTS`, `READ_TOUR_PAGES`, `UPDATE_ASSETS` + +### Default Roles + +| Role | Description | +|------|-------------| +| Administrator | Full access to all features | +| Analytics Viewer | Read-only access for analytics | + +## Environment Detection + +### Server Environment (NODE_ENV) + +The backend uses `NODE_ENV` to determine database configuration: + +| Value | Database | Description | +|-------|----------|-------------| +| `production` | Production config | Live environment | +| `dev_stage` | Staging config | Staging environment | +| (other) | Development config | Local development | + +### Content Environment (tour_pages.environment) + +Separate from server environment, tour pages have a content environment field: + +| Value | Access | Description | +|-------|--------|-------------| +| `dev` | Constructor only | Editing/draft content | +| `stage` | Stage preview | Pre-production review | +| `production` | Public runtime | Published content | + +The `X-Runtime-Environment` header (set by frontend) determines which content environment to query. The `runtime-context.js` middleware resolves this for API requests. + +## Docker + +See `docker/` directory for Docker Compose setup: + +```bash +cd docker +docker-compose up +``` + +## Logging + +Uses Pino logger with pretty printing in development: + +```javascript +const logger = require('pino')(); +logger.info('Server started'); +logger.error({ err }, 'Error occurred'); +``` diff --git a/backend/src/db/api/ui_elements.js b/backend/src/db/api/element_type_defaults.js similarity index 85% rename from backend/src/db/api/ui_elements.js rename to backend/src/db/api/element_type_defaults.js index beefc1d..fd793a2 100644 --- a/backend/src/db/api/ui_elements.js +++ b/backend/src/db/api/element_type_defaults.js @@ -1,13 +1,13 @@ const GenericDBApi = require('./base.api'); const db = require('../models'); -class Ui_elementsDBApi extends GenericDBApi { +class Element_type_defaultsDBApi extends GenericDBApi { static get MODEL() { - return db.ui_elements; + return db.element_type_defaults; } static get TABLE_NAME() { - return 'ui_elements'; + return 'element_type_defaults'; } static get SEARCHABLE_FIELDS() { @@ -160,6 +160,42 @@ class Ui_elementsDBApi extends GenericDBApi { appearDurationSec: null, }, }, + { + element_type: 'spot', + name: 'Hotspot', + sort_order: 9, + default_settings_json: { + label: 'Hotspot', + iconUrl: '', + appearDelaySec: 0, + appearDurationSec: null, + }, + }, + { + element_type: 'logo', + name: 'Logo', + sort_order: 10, + default_settings_json: { + label: 'Logo', + iconUrl: '', + backgroundImageUrl: '', + appearDelaySec: 0, + appearDurationSec: null, + }, + }, + { + element_type: 'popup', + name: 'Popup', + sort_order: 11, + default_settings_json: { + label: 'Popup', + iconUrl: '', + popupTitle: '', + popupContent: '', + appearDelaySec: 0, + appearDurationSec: null, + }, + }, ]; } @@ -239,6 +275,6 @@ class Ui_elementsDBApi extends GenericDBApi { } } -Ui_elementsDBApi.initializationPromise = null; +Element_type_defaultsDBApi.initializationPromise = null; -module.exports = Ui_elementsDBApi; +module.exports = Element_type_defaultsDBApi; diff --git a/backend/src/db/api/page_elements.js b/backend/src/db/api/page_elements.js deleted file mode 100644 index 0247171..0000000 --- a/backend/src/db/api/page_elements.js +++ /dev/null @@ -1,210 +0,0 @@ -const GenericDBApi = require('./base.api'); -const db = require('../models'); -const Utils = require('../utils'); -const { - getRuntimeEnvironment, - getRuntimeProjectSlug, -} = require('./runtime-context'); - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -class Page_elementsDBApi extends GenericDBApi { - static get MODEL() { - return db.page_elements; - } - - static get TABLE_NAME() { - return 'page_elements'; - } - - static get SEARCHABLE_FIELDS() { - return ['name', 'style_json', 'content_json']; - } - - static get RANGE_FIELDS() { - return ['sort_order', 'x_percent', 'y_percent', 'width_percent', 'height_percent', 'rotation_deg']; - } - - static get ENUM_FIELDS() { - return ['element_type', 'is_visible']; - } - - static get CSV_FIELDS() { - return ['id', 'element_type', 'name', 'sort_order', 'is_visible', 'x_percent', 'y_percent', 'createdAt']; - } - - static get AUTOCOMPLETE_FIELD() { - return 'name'; - } - - static get ASSOCIATIONS() { - return [ - { field: 'page', setter: 'setPage', isArray: false }, - ]; - } - - static getFieldMapping(data) { - return { - id: data.id || undefined, - element_type: data.element_type ?? null, - name: data.name ?? null, - sort_order: data.sort_order ?? 0, - is_visible: data.is_visible ?? false, - x_percent: data.x_percent ?? null, - y_percent: data.y_percent ?? null, - width_percent: data.width_percent ?? null, - height_percent: data.height_percent ?? null, - rotation_deg: data.rotation_deg ?? null, - style_json: data.style_json ?? null, - content_json: data.content_json ?? null, - }; - } - - static async findBy(where, options = {}) { - const transaction = options.transaction; - const runtimeEnvironment = getRuntimeEnvironment(options); - const runtimeProjectSlug = getRuntimeProjectSlug(options); - const pageInclude = { - model: db.tour_pages, - as: 'page', - required: Boolean(runtimeEnvironment || runtimeProjectSlug), - where: runtimeEnvironment ? { environment: runtimeEnvironment } : {}, - include: runtimeProjectSlug - ? [{ - model: db.projects, - as: 'project', - required: true, - where: { slug: runtimeProjectSlug }, - }] - : [], - }; - - const record = await this.MODEL.findOne({ - where, - transaction, - include: [pageInclude], - }); - - if (!record) return null; - return record.get({ plain: true }); - } - - static async findAll(filter = {}, options = {}) { - filter = filter || {}; - const limit = filter.limit || 0; - const currentPage = +filter.page || 0; - const offset = currentPage * limit; - - let where = {}; - - let include = [ - { - model: db.tour_pages, - as: 'page', - where: filter.page ? { - [Op.or]: [ - { id: { [Op.in]: filter.page.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.page.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - }, - ]; - - const runtimeEnvironment = getRuntimeEnvironment(options); - const runtimeProjectSlug = getRuntimeProjectSlug(options); - - if (runtimeEnvironment) { - include[0].where = { - ...(include[0].where || {}), - environment: runtimeEnvironment, - }; - include[0].required = true; - } - - if (runtimeProjectSlug) { - include[0].include = [{ - model: db.projects, - as: 'project', - required: true, - where: { slug: runtimeProjectSlug }, - }]; - include[0].required = true; - } - - if (filter.id) { - where.id = Utils.uuid(filter.id); - } - - for (const field of this.SEARCHABLE_FIELDS) { - if (filter[field]) { - where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]); - } - } - - for (const field of this.RANGE_FIELDS) { - const rangeKey = `${field}Range`; - if (filter[rangeKey]) { - const [start, end] = filter[rangeKey]; - if (start !== undefined && start !== null && start !== '') { - where[field] = { ...where[field], [Op.gte]: start }; - } - if (end !== undefined && end !== null && end !== '') { - where[field] = { ...where[field], [Op.lte]: end }; - } - } - } - - for (const field of this.ENUM_FIELDS) { - if (filter[field] !== undefined) { - where[field] = filter[field]; - } - } - - if (filter.active !== undefined) { - where.active = filter.active === true || filter.active === 'true'; - } - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - if (start !== undefined && start !== null && start !== '') { - where.createdAt = { ...where.createdAt, [Op.gte]: start }; - } - if (end !== undefined && end !== null && end !== '') { - where.createdAt = { ...where.createdAt, [Op.lte]: end }; - } - } - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options.transaction, - }; - - if (!options.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await this.MODEL.findAndCountAll(queryOptions); - return { - rows: options.countOnly ? [] : rows, - count, - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } -} - -module.exports = Page_elementsDBApi; diff --git a/backend/src/db/api/page_links.js b/backend/src/db/api/page_links.js deleted file mode 100644 index 6b59287..0000000 --- a/backend/src/db/api/page_links.js +++ /dev/null @@ -1,263 +0,0 @@ -const GenericDBApi = require('./base.api'); -const db = require('../models'); -const Utils = require('../utils'); -const { - getRuntimeEnvironment, - getRuntimeProjectSlug, -} = require('./runtime-context'); - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -class Page_linksDBApi extends GenericDBApi { - static get MODEL() { - return db.page_links; - } - - static get TABLE_NAME() { - return 'page_links'; - } - - static get SEARCHABLE_FIELDS() { - return ['external_url', 'trigger_selector']; - } - - static get RANGE_FIELDS() { - return []; - } - - static get ENUM_FIELDS() { - return ['direction', 'is_active']; - } - - static get CSV_FIELDS() { - return ['id', 'direction', 'external_url', 'is_active', 'trigger_selector', 'createdAt']; - } - - static get AUTOCOMPLETE_FIELD() { - return 'direction'; - } - - static get ASSOCIATIONS() { - return [ - { field: 'from_page', setter: 'setFrom_page', isArray: false }, - { field: 'to_page', setter: 'setTo_page', isArray: false }, - { field: 'transition', setter: 'setTransition', isArray: false }, - ]; - } - - static getFieldMapping(data) { - return { - id: data.id || undefined, - direction: data.direction || null, - external_url: data.external_url || null, - is_active: data.is_active || false, - trigger_selector: data.trigger_selector || null, - }; - } - - static async findBy(where, options = {}) { - const transaction = options.transaction; - const runtimeEnvironment = getRuntimeEnvironment(options); - const runtimeProjectSlug = getRuntimeProjectSlug(options); - const buildProjectInclude = () => ( - runtimeProjectSlug - ? [{ - model: db.projects, - as: 'project', - required: true, - where: { slug: runtimeProjectSlug }, - }] - : [] - ); - - const record = await this.MODEL.findOne({ - where, - transaction, - include: [ - { - model: db.tour_pages, - as: 'from_page', - required: Boolean(runtimeEnvironment || runtimeProjectSlug), - where: runtimeEnvironment ? { environment: runtimeEnvironment } : {}, - include: buildProjectInclude(), - }, - { - model: db.tour_pages, - as: 'to_page', - required: false, - where: runtimeEnvironment ? { environment: runtimeEnvironment } : {}, - include: buildProjectInclude(), - }, - { - model: db.transitions, - as: 'transition', - required: false, - where: runtimeEnvironment ? { environment: runtimeEnvironment } : {}, - include: buildProjectInclude(), - }, - ], - }); - - if (!record) return null; - return record.get({ plain: true }); - } - - static async findAll(filter = {}, options = {}) { - filter = filter || {}; - const limit = filter.limit || 0; - const currentPage = +filter.page || 0; - const offset = currentPage * limit; - - let where = {}; - - let include = [ - { - model: db.tour_pages, - as: 'from_page', - where: filter.from_page ? { - [Op.or]: [ - { id: { [Op.in]: filter.from_page.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.from_page.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - }, - { - model: db.tour_pages, - as: 'to_page', - where: filter.to_page ? { - [Op.or]: [ - { id: { [Op.in]: filter.to_page.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.to_page.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - }, - { - model: db.transitions, - as: 'transition', - where: filter.transition ? { - [Op.or]: [ - { id: { [Op.in]: filter.transition.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.transition.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - }, - ]; - - const runtimeEnvironment = getRuntimeEnvironment(options); - const runtimeProjectSlug = getRuntimeProjectSlug(options); - - if (runtimeEnvironment) { - include[0].where = { ...(include[0].where || {}), environment: runtimeEnvironment }; - include[0].required = true; - include[1].where = { ...(include[1].where || {}), environment: runtimeEnvironment }; - include[2].where = { ...(include[2].where || {}), environment: runtimeEnvironment }; - include[2].required = false; - } - - if (runtimeProjectSlug) { - const projectInclude = [{ - model: db.projects, - as: 'project', - required: true, - where: { slug: runtimeProjectSlug }, - }]; - include[0].include = projectInclude; - include[0].required = true; - include[1].include = projectInclude; - include[2].include = projectInclude; - include[2].required = false; - } - - // Filter by project ID (through from_page's project association) - if (filter.project) { - const projectInclude = [{ - model: db.projects, - as: 'project', - required: true, - where: { - [Op.or]: [ - { id: { [Op.in]: filter.project.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.project.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - }, - }]; - include[0].include = [...(include[0].include || []), ...projectInclude]; - include[0].required = true; - } - - if (filter.id) { - where.id = Utils.uuid(filter.id); - } - - for (const field of this.SEARCHABLE_FIELDS) { - if (filter[field]) { - where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]); - } - } - - for (const field of this.ENUM_FIELDS) { - if (filter[field] !== undefined) { - where[field] = filter[field]; - } - } - - if (filter.active !== undefined) { - where.active = filter.active === true || filter.active === 'true'; - } - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - if (start !== undefined && start !== null && start !== '') { - where.createdAt = { ...where.createdAt, [Op.gte]: start }; - } - if (end !== undefined && end !== null && end !== '') { - where.createdAt = { ...where.createdAt, [Op.lte]: end }; - } - } - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options.transaction, - }; - - if (!options.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await this.MODEL.findAndCountAll(queryOptions); - return { - rows: options.countOnly ? [] : rows, - count, - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } -} - -module.exports = Page_linksDBApi; diff --git a/backend/src/db/api/project_element_defaults.js b/backend/src/db/api/project_element_defaults.js new file mode 100644 index 0000000..e623c0e --- /dev/null +++ b/backend/src/db/api/project_element_defaults.js @@ -0,0 +1,335 @@ +const GenericDBApi = require('./base.api'); +const db = require('../models'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +class Project_element_defaultsDBApi extends GenericDBApi { + static get MODEL() { + return db.project_element_defaults; + } + + static get TABLE_NAME() { + return 'project_element_defaults'; + } + + static get SEARCHABLE_FIELDS() { + return ['name', 'element_type']; + } + + static get RANGE_FIELDS() { + return ['sort_order', 'snapshot_version']; + } + + static get ENUM_FIELDS() { + return []; + } + + static get ASSOCIATIONS() { + return [ + { field: 'project', setter: 'setProject', isArray: false }, + ]; + } + + static get RELATION_FILTERS() { + return [ + { filterKey: 'project', model: db.projects, as: 'project', searchField: 'name' }, + ]; + } + + static get FIND_ALL_INCLUDES() { + return [ + { association: 'project' }, + { association: 'source_element' }, + ]; + } + + static get CSV_FIELDS() { + return ['id', 'element_type', 'name', 'sort_order', 'projectId', 'snapshot_version', 'createdAt']; + } + + static get AUTOCOMPLETE_FIELD() { + return 'name'; + } + + static getFieldMapping(data) { + return { + id: data.id || undefined, + element_type: data.element_type ?? null, + name: data.name ?? null, + sort_order: data.sort_order ?? 0, + settings_json: + data.settings_json === null || data.settings_json === undefined + ? null + : typeof data.settings_json === 'string' + ? data.settings_json + : JSON.stringify(data.settings_json), + source_element_id: data.source_element_id ?? null, + snapshot_version: data.snapshot_version ?? 1, + projectId: data.projectId || data.project || undefined, + }; + } + + /** + * Custom findAll with project filtering + * Supports both 'project' and 'projectId' query params for consistency + */ + static async findAll(filter = {}, options = {}) { + filter = filter || {}; + const limit = filter.limit || 0; + const currentPage = +filter.page || 0; + const offset = currentPage * limit; + + let where = {}; + + // Support both 'project' and 'projectId' query params + const projectFilter = filter.project || filter.projectId; + + let include = [ + { + model: db.projects, + as: 'project', + where: projectFilter ? { + [Op.or]: [ + { id: { [Op.in]: projectFilter.split('|').map(term => Utils.uuid(term)) } }, + { + name: { + [Op.or]: projectFilter.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) + } + }, + ] + } : {}, + }, + { + model: db.element_type_defaults, + as: 'source_element', + required: false, + }, + ]; + + if (filter.id) { + where.id = Utils.uuid(filter.id); + } + + for (const field of this.SEARCHABLE_FIELDS) { + if (filter[field]) { + where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]); + } + } + + for (const field of this.RANGE_FIELDS) { + const rangeKey = `${field}Range`; + if (filter[rangeKey]) { + const [start, end] = filter[rangeKey]; + if (start !== undefined && start !== null && start !== '') { + where[field] = { ...where[field], [Op.gte]: start }; + } + if (end !== undefined && end !== null && end !== '') { + where[field] = { ...where[field], [Op.lte]: end }; + } + } + } + + for (const field of this.ENUM_FIELDS) { + if (filter[field] !== undefined) { + where[field] = filter[field]; + } + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where.createdAt = { ...where.createdAt, [Op.gte]: start }; + } + if (end !== undefined && end !== null && end !== '') { + where.createdAt = { ...where.createdAt, [Op.lte]: end }; + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['sort_order', 'asc']], + transaction: options.transaction, + }; + + if (!options.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await this.MODEL.findAndCountAll(queryOptions); + return { + rows: options.countOnly ? [] : rows, + count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + /** + * Find project element default by element type for a specific project + */ + static async findByElementType(projectId, elementType, options = {}) { + return this.MODEL.findOne({ + where: { + projectId, + element_type: elementType, + deletedAt: null, + }, + ...options, + }); + } + + /** + * Snapshot all global element defaults to a project + * Used when creating a new project + */ + static async snapshotGlobalDefaults(projectId, options = {}) { + const Element_type_defaultsDBApi = require('./element_type_defaults'); + + // Get all global defaults + const globalDefaults = await Element_type_defaultsDBApi.findAll({}); + + if (!globalDefaults?.rows?.length) { + return []; + } + + const now = new Date(); + const currentUserId = options.currentUser?.id || null; + + // Create project defaults from global defaults + const projectDefaults = await this.MODEL.bulkCreate( + globalDefaults.rows.map((globalDefault) => ({ + projectId, + element_type: globalDefault.element_type, + name: globalDefault.name, + sort_order: globalDefault.sort_order, + settings_json: globalDefault.default_settings_json, + source_element_id: globalDefault.id, + snapshot_version: 1, + createdById: currentUserId, + updatedById: currentUserId, + createdAt: now, + updatedAt: now, + })), + { + transaction: options.transaction, + returning: true, + } + ); + + return projectDefaults; + } + + /** + * Reset a project element default to the current global default + */ + static async resetToGlobal(id, options = {}) { + const Element_type_defaultsDBApi = require('./element_type_defaults'); + + // Ensure global defaults are initialized + await Element_type_defaultsDBApi.ensureInitialized(); + + // Find the project default + const projectDefault = await this.MODEL.findByPk(id); + if (!projectDefault) { + throw new Error('Project element default not found'); + } + + // Find the matching global default + const globalDefault = await Element_type_defaultsDBApi.MODEL.findOne({ + where: { + element_type: projectDefault.element_type, + deletedAt: null, + }, + }); + + if (!globalDefault) { + throw new Error(`No global default found for element type: ${projectDefault.element_type}`); + } + + // Update with global settings and increment version + const now = new Date(); + await projectDefault.update( + { + name: globalDefault.name, + sort_order: globalDefault.sort_order, + settings_json: globalDefault.default_settings_json, + source_element_id: globalDefault.id, + snapshot_version: projectDefault.snapshot_version + 1, + updatedById: options.currentUser?.id || null, + updatedAt: now, + }, + { + transaction: options.transaction, + } + ); + + return projectDefault.reload(); + } + + /** + * Get diff between project default and current global default + */ + static async getDiffFromGlobal(id) { + const Element_type_defaultsDBApi = require('./element_type_defaults'); + + // Ensure global defaults are initialized + await Element_type_defaultsDBApi.ensureInitialized(); + + // Find the project default + const projectDefault = await this.MODEL.findByPk(id); + if (!projectDefault) { + throw new Error('Project element default not found'); + } + + // Find the matching global default + const globalDefault = await Element_type_defaultsDBApi.MODEL.findOne({ + where: { + element_type: projectDefault.element_type, + deletedAt: null, + }, + }); + + if (!globalDefault) { + return { + projectDefault, + globalDefault: null, + hasGlobalDefault: false, + isDifferent: true, + }; + } + + // Parse JSON settings for comparison + const projectSettings = typeof projectDefault.settings_json === 'string' + ? JSON.parse(projectDefault.settings_json || '{}') + : projectDefault.settings_json || {}; + + const globalSettings = typeof globalDefault.default_settings_json === 'string' + ? JSON.parse(globalDefault.default_settings_json || '{}') + : globalDefault.default_settings_json || {}; + + const isDifferent = JSON.stringify(projectSettings) !== JSON.stringify(globalSettings) || + projectDefault.name !== globalDefault.name || + projectDefault.sort_order !== globalDefault.sort_order; + + return { + projectDefault, + globalDefault, + hasGlobalDefault: true, + isDifferent, + projectSettings, + globalSettings, + }; + } +} + +module.exports = Project_element_defaultsDBApi; diff --git a/backend/src/db/api/projects.js b/backend/src/db/api/projects.js index d7d65d4..91ba507 100644 --- a/backend/src/db/api/projects.js +++ b/backend/src/db/api/projects.js @@ -1,10 +1,7 @@ const GenericDBApi = require('./base.api'); const db = require('../models'); const Utils = require('../utils'); -const { - getRuntimeEnvironment, - getRuntimeProjectSlug, -} = require('./runtime-context'); +const { getRuntimeProjectSlug } = require('./runtime-context'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; @@ -19,7 +16,7 @@ class ProjectsDBApi extends GenericDBApi { } static get SEARCHABLE_FIELDS() { - return ['name', 'slug', 'description', 'logo_url', 'favicon_url', 'og_image_url', 'theme_config_json', 'custom_css_json', 'cdn_base_url', 'entry_page_slug']; + return ['name', 'slug', 'description', 'logo_url', 'favicon_url', 'og_image_url', 'theme_config_json', 'custom_css_json', 'cdn_base_url']; } static get RANGE_FIELDS() { @@ -27,11 +24,11 @@ class ProjectsDBApi extends GenericDBApi { } static get ENUM_FIELDS() { - return ['phase']; + return []; } static get CSV_FIELDS() { - return ['id', 'name', 'slug', 'description', 'phase', 'logo_url', 'cdn_base_url', 'createdAt']; + return ['id', 'name', 'slug', 'description', 'logo_url', 'cdn_base_url', 'createdAt']; } static get AUTOCOMPLETE_FIELD() { @@ -48,14 +45,12 @@ class ProjectsDBApi extends GenericDBApi { name: data.name || null, slug: data.slug || null, description: data.description || null, - phase: data.phase || null, logo_url: data.logo_url || null, favicon_url: data.favicon_url || null, og_image_url: data.og_image_url || null, theme_config_json: data.theme_config_json || null, custom_css_json: data.custom_css_json || null, cdn_base_url: data.cdn_base_url || null, - entry_page_slug: data.entry_page_slug || null, }; } @@ -69,7 +64,6 @@ class ProjectsDBApi extends GenericDBApi { { association: 'assets_project' }, { association: 'presigned_url_requests_project' }, { association: 'tour_pages_project' }, - { association: 'transitions_project' }, { association: 'project_audio_tracks_project' }, { association: 'publish_events_project' }, { association: 'pwa_caches_project' }, @@ -79,16 +73,10 @@ class ProjectsDBApi extends GenericDBApi { static async findBy(where, options = {}) { const transaction = options.transaction; - const runtimeEnvironment = getRuntimeEnvironment(options); const runtimeProjectSlug = getRuntimeProjectSlug(options); const queryWhere = { ...where }; - if (runtimeEnvironment) { - queryWhere.phase = runtimeEnvironment === 'production' - ? 'production' - : { [Op.in]: ['stage', 'production'] }; - } - + // Runtime access: filter by project slug if (runtimeProjectSlug) { queryWhere.slug = runtimeProjectSlug; } @@ -107,6 +95,30 @@ class ProjectsDBApi extends GenericDBApi { return record.get({ plain: true }); } + /** + * Create a new project and auto-snapshot global element defaults + */ + static async create(data, options = {}) { + const transaction = options.transaction; + + // Create the project using parent's create + const project = await super.create(data, options); + + // Auto-snapshot global element defaults to the new project + try { + const Project_element_defaultsDBApi = require('./project_element_defaults'); + await Project_element_defaultsDBApi.snapshotGlobalDefaults(project.id, { + ...options, + transaction, + }); + } catch (error) { + // Log but don't fail project creation if snapshot fails + console.error('Failed to snapshot global element defaults to project:', error); + } + + return project; + } + static async findAll(filter = {}, options = {}) { filter = filter || {}; const limit = filter.limit || 0; @@ -159,13 +171,9 @@ class ProjectsDBApi extends GenericDBApi { } } - const runtimeEnvironment = getRuntimeEnvironment(options); + // Runtime access: filter by project slug const runtimeProjectSlug = getRuntimeProjectSlug(options); - if (runtimeEnvironment) { - where.phase = runtimeEnvironment; - } - if (runtimeProjectSlug) { where.slug = runtimeProjectSlug; } diff --git a/backend/src/db/api/runtime-context.js b/backend/src/db/api/runtime-context.js index eee581d..38e24a6 100644 --- a/backend/src/db/api/runtime-context.js +++ b/backend/src/db/api/runtime-context.js @@ -1,25 +1,32 @@ +/** + * Runtime Context Helpers + * For route-based environment access via X-Runtime-Environment header + */ + function getRuntimeContext(options = {}) { return (options || {}).runtimeContext || null; } function getRuntimeEnvironment(options = {}) { const runtimeContext = getRuntimeContext(options); - if (!runtimeContext) return null; - if (runtimeContext.mode === 'stage') return 'stage'; - if (runtimeContext.mode === 'production') return 'production'; + + // Read from header (route-based mode) + // SECURITY: Only allow 'production' and 'stage' from header + // to prevent unauthorized access to dev data + if (runtimeContext.headerEnvironment === 'production') return 'production'; + if (runtimeContext.headerEnvironment === 'stage') return 'stage'; return null; } function getRuntimeProjectSlug(options = {}) { const runtimeContext = getRuntimeContext(options); - return runtimeContext?.projectSlug || null; + return runtimeContext?.headerProjectSlug || null; } function applyRuntimeEnvironment(where = {}, options = {}) { const environment = getRuntimeEnvironment(options); - if (!environment) return where; return { @@ -30,7 +37,6 @@ function applyRuntimeEnvironment(where = {}, options = {}) { function applyRuntimeProjectFilter(projectInclude = {}, options = {}) { const projectSlug = getRuntimeProjectSlug(options); - if (!projectSlug) return projectInclude; return { diff --git a/backend/src/db/api/tour_pages.js b/backend/src/db/api/tour_pages.js index 596f0b0..4fc353a 100644 --- a/backend/src/db/api/tour_pages.js +++ b/backend/src/db/api/tour_pages.js @@ -98,9 +98,6 @@ class Tour_pagesDBApi extends GenericDBApi { transaction, include: [ projectInclude, - { association: 'page_elements_page' }, - { association: 'page_links_from_page' }, - { association: 'page_links_to_page' }, ], }); diff --git a/backend/src/db/api/transitions.js b/backend/src/db/api/transitions.js deleted file mode 100644 index c7acfaa..0000000 --- a/backend/src/db/api/transitions.js +++ /dev/null @@ -1,182 +0,0 @@ -const GenericDBApi = require('./base.api'); -const db = require('../models'); -const Utils = require('../utils'); -const { - applyRuntimeEnvironment, - applyRuntimeProjectFilter, -} = require('./runtime-context'); - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -class TransitionsDBApi extends GenericDBApi { - static get MODEL() { - return db.transitions; - } - - static get TABLE_NAME() { - return 'transitions'; - } - - static get SEARCHABLE_FIELDS() { - return ['source_key', 'name', 'slug', 'video_url', 'audio_url']; - } - - static get RANGE_FIELDS() { - return ['duration_sec']; - } - - static get ENUM_FIELDS() { - return ['environment', 'supports_reverse']; - } - - static get CSV_FIELDS() { - return ['id', 'source_key', 'name', 'slug', 'video_url', 'audio_url', 'duration_sec', 'createdAt']; - } - - static get AUTOCOMPLETE_FIELD() { - return 'name'; - } - - static get ASSOCIATIONS() { - return [ - { field: 'project', setter: 'setProject', isArray: false }, - ]; - } - - static getFieldMapping(data) { - return { - id: data.id || undefined, - environment: data.environment || null, - source_key: data.source_key || null, - name: data.name || null, - slug: data.slug || null, - video_url: data.video_url || null, - audio_url: data.audio_url || null, - supports_reverse: data.supports_reverse || false, - duration_sec: data.duration_sec || null, - }; - } - - static async findBy(where, options = {}) { - const transaction = options.transaction; - const queryWhere = applyRuntimeEnvironment({ ...where }, options); - const projectInclude = applyRuntimeProjectFilter( - { model: db.projects, as: 'project' }, - options - ); - - const record = await this.MODEL.findOne({ - where: queryWhere, - transaction, - include: [ - projectInclude, - { association: 'page_links_transition' }, - ], - }); - - if (!record) return null; - return record.get({ plain: true }); - } - - static async findAll(filter = {}, options = {}) { - filter = filter || {}; - const limit = filter.limit || 0; - const currentPage = +filter.page || 0; - const offset = currentPage * limit; - - let where = {}; - - let include = [ - { - model: db.projects, - as: 'project', - where: filter.project ? { - [Op.or]: [ - { id: { [Op.in]: filter.project.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.project.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - }, - ]; - - include[0] = applyRuntimeProjectFilter(include[0], options); - - if (filter.id) { - where.id = Utils.uuid(filter.id); - } - - for (const field of this.SEARCHABLE_FIELDS) { - if (filter[field]) { - where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]); - } - } - - for (const field of this.RANGE_FIELDS) { - const rangeKey = `${field}Range`; - if (filter[rangeKey]) { - const [start, end] = filter[rangeKey]; - if (start !== undefined && start !== null && start !== '') { - where[field] = { ...where[field], [Op.gte]: start }; - } - if (end !== undefined && end !== null && end !== '') { - where[field] = { ...where[field], [Op.lte]: end }; - } - } - } - - for (const field of this.ENUM_FIELDS) { - if (filter[field] !== undefined) { - where[field] = filter[field]; - } - } - - if (filter.active !== undefined) { - where.active = filter.active === true || filter.active === 'true'; - } - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - if (start !== undefined && start !== null && start !== '') { - where.createdAt = { ...where.createdAt, [Op.gte]: start }; - } - if (end !== undefined && end !== null && end !== '') { - where.createdAt = { ...where.createdAt, [Op.lte]: end }; - } - } - - where = applyRuntimeEnvironment(where, options); - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options.transaction, - }; - - if (!options.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await this.MODEL.findAndCountAll(queryOptions); - return { - rows: options.countOnly ? [] : rows, - count, - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } -} - -module.exports = TransitionsDBApi; diff --git a/backend/src/db/migrations/20260326000001-rename-ui-elements-to-element-type-defaults.js b/backend/src/db/migrations/20260326000001-rename-ui-elements-to-element-type-defaults.js new file mode 100644 index 0000000..2116828 --- /dev/null +++ b/backend/src/db/migrations/20260326000001-rename-ui-elements-to-element-type-defaults.js @@ -0,0 +1,73 @@ +'use strict'; + +/** + * Migration: Rename ui_elements table to element_type_defaults + * + * This migration renames the table for better clarity: + * - ui_elements contained GLOBAL platform-wide default settings + * - The new name element_type_defaults better describes this purpose + * - Adds index on deletedAt for soft delete queries + */ +module.exports = { + async up(queryInterface, Sequelize) { + // Check if old table exists + const tableExists = await queryInterface.sequelize.query( + `SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'ui_elements' + );`, + { type: Sequelize.QueryTypes.SELECT } + ); + + if (!tableExists[0]?.exists) { + console.log('Table ui_elements does not exist, skipping rename'); + return; + } + + // Check if new table already exists (migration may have been partially run) + const newTableExists = await queryInterface.sequelize.query( + `SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'element_type_defaults' + );`, + { type: Sequelize.QueryTypes.SELECT } + ); + + if (newTableExists[0]?.exists) { + console.log('Table element_type_defaults already exists, skipping rename'); + return; + } + + // Rename table + await queryInterface.renameTable('ui_elements', 'element_type_defaults'); + + // Update any sequences (PostgreSQL auto-creates these for SERIAL columns, but UUID doesn't need them) + // No sequence updates needed since we use UUID primary keys + + console.log('Successfully renamed ui_elements to element_type_defaults'); + }, + + async down(queryInterface, Sequelize) { + // Check if new table exists + const tableExists = await queryInterface.sequelize.query( + `SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'element_type_defaults' + );`, + { type: Sequelize.QueryTypes.SELECT } + ); + + if (!tableExists[0]?.exists) { + console.log('Table element_type_defaults does not exist, skipping rollback'); + return; + } + + // Rename table back + await queryInterface.renameTable('element_type_defaults', 'ui_elements'); + + console.log('Successfully rolled back: renamed element_type_defaults to ui_elements'); + }, +}; diff --git a/backend/src/db/migrations/20260326000002-convert-element-type-enum-to-text.js b/backend/src/db/migrations/20260326000002-convert-element-type-enum-to-text.js new file mode 100644 index 0000000..5773b3a --- /dev/null +++ b/backend/src/db/migrations/20260326000002-convert-element-type-enum-to-text.js @@ -0,0 +1,176 @@ +'use strict'; + +/** + * Migration: Convert page_elements.element_type from ENUM to TEXT + * + * This migration: + * 1. Converts element_type column from ENUM to TEXT for flexibility + * 2. Maps nav_button to navigation_next or navigation_prev based on content_json.navType + * 3. Drops the old ENUM type + * + * Benefits of TEXT over ENUM: + * - Flexibility to add new element types without migrations + * - No ENUM sync issues between environments + * - Application-level validation ensures type safety + */ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + // Step 1: Create a temporary TEXT column + await queryInterface.addColumn( + 'page_elements', + 'element_type_text', + { + type: Sequelize.TEXT, + allowNull: true, + }, + { transaction } + ); + + // Step 2: Copy ENUM values to TEXT column + await queryInterface.sequelize.query( + `UPDATE page_elements SET element_type_text = element_type::TEXT`, + { transaction } + ); + + // Step 3: Drop the old ENUM column + await queryInterface.removeColumn('page_elements', 'element_type', { + transaction, + }); + + // Step 4: Rename TEXT column to element_type + await queryInterface.renameColumn( + 'page_elements', + 'element_type_text', + 'element_type', + { transaction } + ); + + // Step 5: Add NOT NULL constraint + await queryInterface.changeColumn( + 'page_elements', + 'element_type', + { + type: Sequelize.TEXT, + allowNull: false, + }, + { transaction } + ); + + // Step 6: Now map nav_button to specific navigation types (column is TEXT now) + // Forward navigation (default if navType not specified) + await queryInterface.sequelize.query( + `UPDATE page_elements + SET element_type = 'navigation_next' + WHERE element_type = 'nav_button' + AND ( + content_json IS NULL + OR content_json::jsonb->>'navType' = 'forward' + OR content_json::jsonb->>'navType' IS NULL + )`, + { transaction } + ); + + // Back navigation + await queryInterface.sequelize.query( + `UPDATE page_elements + SET element_type = 'navigation_prev' + WHERE element_type = 'nav_button' + AND content_json IS NOT NULL + AND content_json::jsonb->>'navType' = 'back'`, + { transaction } + ); + + // Step 7: Drop the old ENUM type if it exists + await queryInterface.sequelize.query( + `DROP TYPE IF EXISTS "enum_page_elements_element_type"`, + { transaction } + ); + + await transaction.commit(); + console.log('Successfully converted element_type from ENUM to TEXT and mapped nav_button types'); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, _Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + // Step 1: Map navigation types back to nav_button (before creating ENUM) + await queryInterface.sequelize.query( + `UPDATE page_elements + SET element_type = 'nav_button' + WHERE element_type IN ('navigation_next', 'navigation_prev')`, + { transaction } + ); + + // Step 2: Drop any existing ENUM types that might conflict + await queryInterface.sequelize.query( + `DROP TYPE IF EXISTS "enum_page_elements_element_type" CASCADE`, + { transaction } + ); + await queryInterface.sequelize.query( + `DROP TYPE IF EXISTS "enum_page_elements_element_type_enum" CASCADE`, + { transaction } + ); + + // Step 3: Create the ENUM type with original values + await queryInterface.sequelize.query( + `CREATE TYPE "enum_page_elements_element_type" AS ENUM ( + 'nav_button', + 'spot', + 'description', + 'tooltip', + 'gallery', + 'carousel', + 'logo', + 'video_player', + 'popup' + )`, + { transaction } + ); + + // Step 4: Add ENUM column directly via raw SQL to avoid Sequelize creating another type + await queryInterface.sequelize.query( + `ALTER TABLE page_elements ADD COLUMN element_type_enum "enum_page_elements_element_type"`, + { transaction } + ); + + // Step 5: Copy TEXT values to ENUM column + await queryInterface.sequelize.query( + `UPDATE page_elements SET element_type_enum = element_type::"enum_page_elements_element_type"`, + { transaction } + ); + + // Step 6: Drop TEXT column + await queryInterface.removeColumn('page_elements', 'element_type', { + transaction, + }); + + // Step 7: Rename ENUM column + await queryInterface.renameColumn( + 'page_elements', + 'element_type_enum', + 'element_type', + { transaction } + ); + + // Step 8: Add NOT NULL constraint + await queryInterface.sequelize.query( + `ALTER TABLE page_elements ALTER COLUMN element_type SET NOT NULL`, + { transaction } + ); + + await transaction.commit(); + console.log('Successfully reverted element_type from TEXT to ENUM'); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/migrations/20260326000003-create-project-element-defaults.js b/backend/src/db/migrations/20260326000003-create-project-element-defaults.js new file mode 100644 index 0000000..e0f8139 --- /dev/null +++ b/backend/src/db/migrations/20260326000003-create-project-element-defaults.js @@ -0,0 +1,158 @@ +'use strict'; + +/** + * Migration: Create project_element_defaults table + * + * This table stores project-specific element default settings that override + * the global element_type_defaults. Key design decisions: + * + * - element_type is TEXT (not ENUM) for flexibility + * - source_element_id is optional FK for audit trail (SET NULL on global delete) + * - snapshot_version tracks generations for "check for updates" feature + * - NO environment field - applies across all environments for consistent branding + * - Unique constraint on (projectId, element_type) ensures one override per type per project + */ +module.exports = { + async up(queryInterface, Sequelize) { + // Check if table already exists + const tableExists = await queryInterface.sequelize.query( + `SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'project_element_defaults' + );`, + { type: Sequelize.QueryTypes.SELECT } + ); + + if (tableExists[0]?.exists) { + console.log('Table project_element_defaults already exists, skipping creation'); + return; + } + + await queryInterface.createTable('project_element_defaults', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + }, + element_type: { + type: Sequelize.TEXT, + allowNull: false, + }, + name: { + type: Sequelize.TEXT, + allowNull: true, + }, + sort_order: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + settings_json: { + type: Sequelize.TEXT, + allowNull: true, + }, + source_element_id: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'element_type_defaults', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + snapshot_version: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 1, + }, + projectId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'projects', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + createdById: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + importHash: { + type: Sequelize.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + }); + + // Add indexes + await queryInterface.addIndex('project_element_defaults', ['projectId'], { + name: 'project_element_defaults_projectId', + }); + + await queryInterface.addIndex('project_element_defaults', ['projectId', 'element_type'], { + name: 'project_element_defaults_projectId_element_type', + unique: true, + where: { deletedAt: null }, + }); + + await queryInterface.addIndex('project_element_defaults', ['element_type'], { + name: 'project_element_defaults_element_type', + }); + + await queryInterface.addIndex('project_element_defaults', ['source_element_id'], { + name: 'project_element_defaults_source_element_id', + }); + + await queryInterface.addIndex('project_element_defaults', ['deletedAt'], { + name: 'project_element_defaults_deletedAt', + }); + + console.log('Successfully created project_element_defaults table'); + }, + + async down(queryInterface, _Sequelize) { + // Drop indexes first + await queryInterface.removeIndex('project_element_defaults', 'project_element_defaults_projectId'); + await queryInterface.removeIndex('project_element_defaults', 'project_element_defaults_projectId_element_type'); + await queryInterface.removeIndex('project_element_defaults', 'project_element_defaults_element_type'); + await queryInterface.removeIndex('project_element_defaults', 'project_element_defaults_source_element_id'); + await queryInterface.removeIndex('project_element_defaults', 'project_element_defaults_deletedAt'); + + // Drop table + await queryInterface.dropTable('project_element_defaults'); + + console.log('Successfully dropped project_element_defaults table'); + }, +}; diff --git a/backend/src/db/migrations/20260326000004-backfill-project-element-defaults.js b/backend/src/db/migrations/20260326000004-backfill-project-element-defaults.js new file mode 100644 index 0000000..5c02c7a --- /dev/null +++ b/backend/src/db/migrations/20260326000004-backfill-project-element-defaults.js @@ -0,0 +1,266 @@ +'use strict'; + +/** + * Migration: Backfill project_element_defaults for existing projects + * + * For each existing project that doesn't have project_element_defaults, + * create a snapshot of the current global element_type_defaults. + */ + +// Default element types to ensure they exist before backfilling +const DEFAULT_ELEMENT_TYPES = [ + { + element_type: 'navigation_next', + name: 'Navigation Forward Button', + sort_order: 1, + settings_json: JSON.stringify({ + label: 'Navigation: Forward', + navLabel: 'Forward', + navType: 'forward', + navDisabled: false, + transitionReverseMode: 'auto_reverse', + transitionDurationSec: 0.7, + appearDelaySec: 0, + appearDurationSec: null, + }), + }, + { + element_type: 'navigation_prev', + name: 'Navigation Back Button', + sort_order: 2, + settings_json: JSON.stringify({ + label: 'Navigation: Back', + navLabel: 'Back', + navType: 'back', + navDisabled: false, + transitionReverseMode: 'auto_reverse', + transitionDurationSec: 0.7, + appearDelaySec: 0, + appearDurationSec: null, + }), + }, + { + element_type: 'tooltip', + name: 'Tooltip', + sort_order: 3, + settings_json: JSON.stringify({ + label: 'Tooltip', + tooltipTitle: 'Tooltip title', + tooltipText: 'Tooltip text', + appearDelaySec: 0, + appearDurationSec: null, + }), + }, + { + element_type: 'description', + name: 'Description', + sort_order: 4, + settings_json: JSON.stringify({ + label: 'Description', + descriptionTitle: 'TITLE', + descriptionText: '', + descriptionTitleFontSize: '48px', + descriptionTextFontSize: '36px', + descriptionTitleFontFamily: 'inherit', + descriptionTextFontFamily: 'inherit', + descriptionTitleColor: '#000000', + descriptionTextColor: '#4B5563', + descriptionBackgroundColor: 'transparent', + appearDelaySec: 0, + appearDurationSec: null, + }), + }, + { + element_type: 'gallery', + name: 'Gallery', + sort_order: 5, + settings_json: JSON.stringify({ + label: 'Gallery', + galleryCards: [{ imageUrl: '', title: 'Card 1', description: '' }], + appearDelaySec: 0, + appearDurationSec: null, + }), + }, + { + element_type: 'carousel', + name: 'Carousel', + sort_order: 6, + settings_json: JSON.stringify({ + label: 'Carousel', + carouselSlides: [{ imageUrl: '', caption: 'Slide 1' }], + carouselPrevIconUrl: '', + carouselNextIconUrl: '', + appearDelaySec: 0, + appearDurationSec: null, + }), + }, + { + element_type: 'video_player', + name: 'Video Player', + sort_order: 7, + settings_json: JSON.stringify({ + label: 'Video Player', + mediaUrl: '', + mediaAutoplay: true, + mediaLoop: true, + mediaMuted: true, + appearDelaySec: 0, + appearDurationSec: null, + }), + }, + { + element_type: 'audio_player', + name: 'Audio Player', + sort_order: 8, + settings_json: JSON.stringify({ + label: 'Audio Player', + mediaUrl: '', + mediaAutoplay: true, + mediaLoop: true, + mediaMuted: false, + appearDelaySec: 0, + appearDurationSec: null, + }), + }, +]; + +module.exports = { + async up(queryInterface, Sequelize) { + // First, ensure element_type_defaults has all default rows + // This is needed because the API's lazy initialization won't have run yet during migration + const [existingTypes] = await queryInterface.sequelize.query( + `SELECT element_type FROM element_type_defaults WHERE "deletedAt" IS NULL`, + { type: Sequelize.QueryTypes.SELECT } + ); + + const existingTypeSet = new Set( + Array.isArray(existingTypes) + ? existingTypes.map((t) => t.element_type) + : existingTypes + ? [existingTypes.element_type] + : [] + ); + + // Insert missing element types + for (const defaultType of DEFAULT_ELEMENT_TYPES) { + if (!existingTypeSet.has(defaultType.element_type)) { + await queryInterface.sequelize.query( + `INSERT INTO element_type_defaults (id, element_type, name, sort_order, settings_json, "createdAt", "updatedAt") + VALUES (gen_random_uuid(), :element_type, :name, :sort_order, :settings_json, NOW(), NOW())`, + { + replacements: { + element_type: defaultType.element_type, + name: defaultType.name, + sort_order: defaultType.sort_order, + settings_json: defaultType.settings_json, + }, + } + ); + console.log(`Created missing element_type_default: ${defaultType.element_type}`); + } + } + + // Get all existing projects + const [projects] = await queryInterface.sequelize.query( + `SELECT id FROM projects WHERE "deletedAt" IS NULL`, + { type: Sequelize.QueryTypes.SELECT } + ); + + if (!projects || projects.length === 0) { + console.log('No projects found, skipping backfill'); + return; + } + + // Get all global element type defaults (now guaranteed to have all types) + const [globalDefaults] = await queryInterface.sequelize.query( + `SELECT id, element_type, name, sort_order, settings_json + FROM element_type_defaults + WHERE "deletedAt" IS NULL`, + { type: Sequelize.QueryTypes.SELECT } + ); + + if (!globalDefaults || globalDefaults.length === 0) { + console.log('No global element type defaults found, skipping backfill'); + return; + } + + const projectIds = Array.isArray(projects) ? projects.map((p) => p.id) : [projects.id]; + const globalDefaultRows = Array.isArray(globalDefaults) ? globalDefaults : [globalDefaults]; + + // For each project, add any missing element type defaults + for (const projectId of projectIds) { + // Get existing element types for this project + const [existingDefaults] = await queryInterface.sequelize.query( + `SELECT element_type FROM project_element_defaults + WHERE "projectId" = :projectId AND "deletedAt" IS NULL`, + { + replacements: { projectId }, + type: Sequelize.QueryTypes.SELECT, + } + ); + + const existingProjectTypes = new Set( + Array.isArray(existingDefaults) + ? existingDefaults.map((d) => d.element_type) + : existingDefaults + ? [existingDefaults.element_type] + : [] + ); + + // Create project element defaults for missing types + let addedCount = 0; + for (const globalDefault of globalDefaultRows) { + if (existingProjectTypes.has(globalDefault.element_type)) { + continue; // Already has this type + } + + await queryInterface.sequelize.query( + `INSERT INTO project_element_defaults + (id, element_type, name, sort_order, settings_json, source_element_id, snapshot_version, "projectId", "createdAt", "updatedAt") + VALUES ( + gen_random_uuid(), + :element_type, + :name, + :sort_order, + :settings_json, + :source_element_id, + 1, + :projectId, + NOW(), + NOW() + )`, + { + replacements: { + element_type: globalDefault.element_type, + name: globalDefault.name, + sort_order: globalDefault.sort_order, + settings_json: globalDefault.settings_json, + source_element_id: globalDefault.id, + projectId, + }, + type: Sequelize.QueryTypes.INSERT, + } + ); + addedCount++; + } + + if (addedCount > 0) { + console.log(`Backfilled ${addedCount} element defaults for project ${projectId}`); + } else { + console.log(`Project ${projectId} already has all element defaults`); + } + } + + console.log('Successfully backfilled project_element_defaults for existing projects'); + }, + + async down(queryInterface, _Sequelize) { + // Delete all project_element_defaults with snapshot_version = 1 + // (only the ones we created during backfill) + await queryInterface.sequelize.query( + `DELETE FROM project_element_defaults WHERE snapshot_version = 1` + ); + + console.log('Successfully removed backfilled project_element_defaults'); + }, +}; diff --git a/backend/src/db/migrations/20260326000005-fix-project-audio-tracks-environment.js b/backend/src/db/migrations/20260326000005-fix-project-audio-tracks-environment.js new file mode 100644 index 0000000..8adf98e --- /dev/null +++ b/backend/src/db/migrations/20260326000005-fix-project-audio-tracks-environment.js @@ -0,0 +1,48 @@ +'use strict'; + +/** + * Migration: Fix project_audio_tracks.environment NULL constraint + * + * Unlike tour_pages and transitions, project_audio_tracks.environment allows NULL. + * This migration fixes it to match other models - NOT NULL with default 'dev'. + */ +module.exports = { + async up(queryInterface, Sequelize) { + // Check if column exists + const [columns] = await queryInterface.sequelize.query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = 'project_audio_tracks' AND column_name = 'environment'`, + { type: Sequelize.QueryTypes.SELECT } + ); + + if (!columns) { + console.log('Column project_audio_tracks.environment does not exist, skipping'); + return; + } + + // Set NULL values to 'dev' + await queryInterface.sequelize.query( + `UPDATE project_audio_tracks SET environment = 'dev' WHERE environment IS NULL` + ); + + // Alter column to NOT NULL with default + await queryInterface.changeColumn('project_audio_tracks', 'environment', { + type: Sequelize.ENUM('dev', 'stage', 'production'), + allowNull: false, + defaultValue: 'dev', + }); + + console.log('Successfully fixed project_audio_tracks.environment to NOT NULL with default dev'); + }, + + async down(queryInterface, Sequelize) { + // Revert to allow NULL + await queryInterface.changeColumn('project_audio_tracks', 'environment', { + type: Sequelize.ENUM('dev', 'stage', 'production'), + allowNull: true, + defaultValue: 'dev', + }); + + console.log('Reverted project_audio_tracks.environment to allow NULL'); + }, +}; diff --git a/backend/src/db/migrations/20260326000006-copy-dev-to-stage.js b/backend/src/db/migrations/20260326000006-copy-dev-to-stage.js new file mode 100644 index 0000000..c3b64fa --- /dev/null +++ b/backend/src/db/migrations/20260326000006-copy-dev-to-stage.js @@ -0,0 +1,208 @@ +'use strict'; + +/** + * Migration: Copy existing dev content to stage environment + * + * This migration initializes the stage environment for existing projects + * by copying all dev content to stage. This establishes the new workflow + * where constructor edits dev, then explicitly saves to stage. + */ +module.exports = { + async up(queryInterface, Sequelize) { + // Get all projects + const projects = await queryInterface.sequelize.query( + `SELECT id FROM projects WHERE "deletedAt" IS NULL`, + { type: Sequelize.QueryTypes.SELECT } + ); + + if (!projects || projects.length === 0) { + console.log('No projects found, skipping dev to stage copy'); + return; + } + + for (const project of projects) { + const projectId = project.id; + + // Check if stage content already exists + const [stageCheck] = await queryInterface.sequelize.query( + `SELECT COUNT(*)::int as count FROM tour_pages + WHERE "projectId" = '${projectId}' AND environment = 'stage' AND "deletedAt" IS NULL`, + { type: Sequelize.QueryTypes.SELECT } + ); + + if (stageCheck?.count > 0) { + console.log(`Project ${projectId} already has stage content, skipping`); + continue; + } + + // Get dev pages count first + const [devPageCount] = await queryInterface.sequelize.query( + `SELECT COUNT(*)::int as count FROM tour_pages + WHERE "projectId" = '${projectId}' AND environment = 'dev' AND "deletedAt" IS NULL`, + { type: Sequelize.QueryTypes.SELECT } + ); + + if (!devPageCount || devPageCount.count === 0) { + console.log(`Project ${projectId} has no dev content, skipping`); + continue; + } + + // Copy pages with direct INSERT...SELECT + await queryInterface.sequelize.query(` + INSERT INTO tour_pages + (id, slug, name, sort_order, background_image_url, background_video_url, background_audio_url, background_loop, requires_auth, ui_schema_json, "projectId", environment, source_key, "createdAt", "updatedAt", "createdById", "updatedById") + SELECT + gen_random_uuid(), + slug, + name, + sort_order, + background_image_url, + background_video_url, + background_audio_url, + background_loop, + requires_auth, + ui_schema_json, + "projectId", + 'stage', + id::text, + NOW(), + NOW(), + "createdById", + "updatedById" + FROM tour_pages + WHERE "projectId" = '${projectId}' AND environment = 'dev' AND "deletedAt" IS NULL + `); + + // Copy transitions + await queryInterface.sequelize.query(` + INSERT INTO transitions + (id, name, slug, video_url, audio_url, supports_reverse, duration_sec, "projectId", environment, source_key, "createdAt", "updatedAt", "createdById", "updatedById") + SELECT + gen_random_uuid(), + name, + slug, + video_url, + audio_url, + supports_reverse, + duration_sec, + "projectId", + 'stage', + id::text, + NOW(), + NOW(), + "createdById", + "updatedById" + FROM transitions + WHERE "projectId" = '${projectId}' AND environment = 'dev' AND "deletedAt" IS NULL + `); + + // Copy audio tracks + await queryInterface.sequelize.query(` + INSERT INTO project_audio_tracks + (id, name, slug, url, "loop", volume, sort_order, is_enabled, "projectId", environment, source_key, "createdAt", "updatedAt", "createdById", "updatedById") + SELECT + gen_random_uuid(), + name, + slug, + url, + "loop", + volume, + sort_order, + is_enabled, + "projectId", + 'stage', + id::text, + NOW(), + NOW(), + "createdById", + "updatedById" + FROM project_audio_tracks + WHERE "projectId" = '${projectId}' AND environment = 'dev' AND "deletedAt" IS NULL + `); + + // Copy page elements using a subquery to map page IDs + await queryInterface.sequelize.query(` + INSERT INTO page_elements + (id, element_type, name, sort_order, is_visible, x_percent, y_percent, width_percent, height_percent, rotation_deg, style_json, content_json, "pageId", "createdAt", "updatedAt", "createdById", "updatedById") + SELECT + gen_random_uuid(), + pe.element_type, + pe.name, + pe.sort_order, + pe.is_visible, + pe.x_percent, + pe.y_percent, + pe.width_percent, + pe.height_percent, + pe.rotation_deg, + pe.style_json, + pe.content_json, + stage_page.id, + NOW(), + NOW(), + pe."createdById", + pe."updatedById" + 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 AND stage_page.environment = 'stage' + WHERE dev_page."projectId" = '${projectId}' + AND dev_page.environment = 'dev' + AND dev_page."deletedAt" IS NULL + AND pe."deletedAt" IS NULL + `); + + // Copy page links using subqueries to map page and transition IDs + await queryInterface.sequelize.query(` + INSERT INTO page_links + (id, trigger_selector, external_url, "from_pageId", "to_pageId", "transitionId", "createdAt", "updatedAt", "createdById", "updatedById") + SELECT + gen_random_uuid(), + pl.trigger_selector, + pl.external_url, + stage_from.id, + stage_to.id, + stage_transition.id, + NOW(), + NOW(), + pl."createdById", + pl."updatedById" + FROM page_links pl + INNER JOIN tour_pages dev_from ON pl."from_pageId" = dev_from.id + INNER JOIN tour_pages stage_from ON stage_from.source_key = dev_from.id::text AND stage_from.environment = 'stage' + LEFT JOIN tour_pages dev_to ON pl."to_pageId" = dev_to.id + LEFT JOIN tour_pages stage_to ON stage_to.source_key = dev_to.id::text AND stage_to.environment = 'stage' + LEFT JOIN transitions dev_transition ON pl."transitionId" = dev_transition.id + LEFT JOIN transitions stage_transition ON stage_transition.source_key = dev_transition.id::text AND stage_transition.environment = 'stage' + WHERE dev_from."projectId" = '${projectId}' + AND dev_from.environment = 'dev' + AND dev_from."deletedAt" IS NULL + AND pl."deletedAt" IS NULL + `); + + console.log(`Copied dev content to stage for project ${projectId}`); + } + + console.log('Successfully copied dev content to stage for all projects'); + }, + + async down(queryInterface, _Sequelize) { + // Delete all stage content that has a source_key (meaning it was created by this migration) + await queryInterface.sequelize.query( + `DELETE FROM page_links WHERE "from_pageId" IN (SELECT id FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL)` + ); + await queryInterface.sequelize.query( + `DELETE FROM page_elements WHERE "pageId" IN (SELECT id FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL)` + ); + await queryInterface.sequelize.query( + `DELETE FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL` + ); + await queryInterface.sequelize.query( + `DELETE FROM transitions WHERE environment = 'stage' AND source_key IS NOT NULL` + ); + await queryInterface.sequelize.query( + `DELETE FROM project_audio_tracks WHERE environment = 'stage' AND source_key IS NOT NULL` + ); + + console.log('Removed stage content created by migration'); + }, +}; diff --git a/backend/src/db/migrations/20260326043002-enforce-environment-not-null.js b/backend/src/db/migrations/20260326043002-enforce-environment-not-null.js new file mode 100644 index 0000000..3bc0774 --- /dev/null +++ b/backend/src/db/migrations/20260326043002-enforce-environment-not-null.js @@ -0,0 +1,58 @@ +'use strict'; + +/** + * Migration: Enforce environment NOT NULL on all environment-aware tables + * + * This migration ensures that: + * 1. All NULL environment values are set to 'dev' + * 2. environment column is NOT NULL with default 'dev' + * + * This prevents data leaks where pages without environment could appear in production. + */ +module.exports = { + async up(queryInterface, _Sequelize) { + // Fix any NULL environments in tour_pages + await queryInterface.sequelize.query( + `UPDATE tour_pages SET environment = 'dev' WHERE environment IS NULL` + ); + + // Fix any NULL environments in transitions + await queryInterface.sequelize.query( + `UPDATE transitions SET environment = 'dev' WHERE environment IS NULL` + ); + + // Add NOT NULL constraint with default to tour_pages.environment + await queryInterface.sequelize.query(` + ALTER TABLE tour_pages + ALTER COLUMN environment SET NOT NULL, + ALTER COLUMN environment SET DEFAULT 'dev' + `); + + // Add NOT NULL constraint with default to transitions.environment + await queryInterface.sequelize.query(` + ALTER TABLE transitions + ALTER COLUMN environment SET NOT NULL, + ALTER COLUMN environment SET DEFAULT 'dev' + `); + + console.log('Successfully enforced NOT NULL on environment columns'); + }, + + async down(queryInterface, _Sequelize) { + // Remove NOT NULL constraint from tour_pages.environment + await queryInterface.sequelize.query(` + ALTER TABLE tour_pages + ALTER COLUMN environment DROP NOT NULL, + ALTER COLUMN environment DROP DEFAULT + `); + + // Remove NOT NULL constraint from transitions.environment + await queryInterface.sequelize.query(` + ALTER TABLE transitions + ALTER COLUMN environment DROP NOT NULL, + ALTER COLUMN environment DROP DEFAULT + `); + + console.log('Removed NOT NULL constraint from environment columns'); + }, +}; diff --git a/backend/src/db/migrations/20260326050442-remove-project-phase-column.js b/backend/src/db/migrations/20260326050442-remove-project-phase-column.js new file mode 100644 index 0000000..2a02325 --- /dev/null +++ b/backend/src/db/migrations/20260326050442-remove-project-phase-column.js @@ -0,0 +1,31 @@ +'use strict'; + +/** + * Remove project.phase column - it's redundant. + * Runtime access is controlled by tour_pages.environment, not project.phase. + */ +module.exports = { + async up(queryInterface, _Sequelize) { + // Drop the phase column + await queryInterface.removeColumn('projects', 'phase'); + + // Drop the ENUM type + await queryInterface.sequelize.query( + `DROP TYPE IF EXISTS "enum_projects_phase";` + ); + }, + + async down(queryInterface, Sequelize) { + // Recreate the ENUM type + await queryInterface.sequelize.query(` + CREATE TYPE "enum_projects_phase" AS ENUM ('dev', 'stage', 'production'); + `); + + // Recreate the column with default 'dev' + await queryInterface.addColumn('projects', 'phase', { + type: Sequelize.ENUM('dev', 'stage', 'production'), + allowNull: false, + defaultValue: 'dev', + }); + }, +}; diff --git a/backend/src/db/migrations/20260326054410-remove-entry-page-slug-column.js b/backend/src/db/migrations/20260326054410-remove-entry-page-slug-column.js new file mode 100644 index 0000000..18dde4f --- /dev/null +++ b/backend/src/db/migrations/20260326054410-remove-entry-page-slug-column.js @@ -0,0 +1,20 @@ +'use strict'; + +/** + * Migration: Remove entry_page_slug from projects table + * + * The entry page is now determined by the first page by sort_order, + * making entry_page_slug redundant. + */ +module.exports = { + async up(queryInterface, _Sequelize) { + await queryInterface.removeColumn('projects', 'entry_page_slug'); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.addColumn('projects', 'entry_page_slug', { + type: Sequelize.TEXT, + allowNull: true, + }); + }, +}; diff --git a/backend/src/db/migrations/20260326060000-convert-targetpageid-to-slug.js b/backend/src/db/migrations/20260326060000-convert-targetpageid-to-slug.js new file mode 100644 index 0000000..0c825d4 --- /dev/null +++ b/backend/src/db/migrations/20260326060000-convert-targetpageid-to-slug.js @@ -0,0 +1,157 @@ +'use strict'; + +/** + * Migration: Convert targetPageId to targetPageSlug in ui_schema_json + * + * This migration converts navigation elements from using page UUIDs (targetPageId) + * to using page slugs (targetPageSlug). This fixes the ID remapping issue when + * pages are copied between environments (dev -> stage -> production). + * + * Slugs are unique within project+environment and identical across environments, + * eliminating the need for ID remapping during publish. + */ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + // Get all tour pages with their ui_schema_json + const [tourPages] = await queryInterface.sequelize.query( + `SELECT id, "projectId", environment, slug, ui_schema_json FROM tour_pages WHERE ui_schema_json IS NOT NULL`, + { transaction } + ); + + // Build a lookup map: pageId -> { projectId, environment, slug } + const pageInfoById = new Map(); + tourPages.forEach(page => { + pageInfoById.set(page.id, { + projectId: page.projectId, + environment: page.environment, + slug: page.slug + }); + }); + + // Process each page and convert targetPageId to targetPageSlug + for (const page of tourPages) { + try { + const uiSchema = typeof page.ui_schema_json === 'string' + ? JSON.parse(page.ui_schema_json) + : page.ui_schema_json; + + if (!uiSchema || !Array.isArray(uiSchema.elements)) { + continue; + } + + let hasChanges = false; + + uiSchema.elements.forEach(element => { + // Convert targetPageId to targetPageSlug + if (element.targetPageId && typeof element.targetPageId === 'string') { + const targetPageInfo = pageInfoById.get(element.targetPageId); + if (targetPageInfo && targetPageInfo.slug) { + // Only convert if target page is in the same project and environment + if (targetPageInfo.projectId === page.projectId && + targetPageInfo.environment === page.environment) { + element.targetPageSlug = targetPageInfo.slug; + delete element.targetPageId; + hasChanges = true; + } + } + } + }); + + if (hasChanges) { + await queryInterface.sequelize.query( + `UPDATE tour_pages SET ui_schema_json = :json WHERE id = :id`, + { + replacements: { + json: JSON.stringify(uiSchema), + id: page.id + }, + type: Sequelize.QueryTypes.UPDATE, + transaction + } + ); + } + } catch (parseError) { + // Skip pages with invalid JSON + console.warn(`Skipping page ${page.id}: ${parseError.message}`); + } + } + + await transaction.commit(); + console.log('Migration complete: Converted targetPageId to targetPageSlug'); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + // Get all tour pages + const [tourPages] = await queryInterface.sequelize.query( + `SELECT id, "projectId", environment, slug, ui_schema_json FROM tour_pages WHERE ui_schema_json IS NOT NULL`, + { transaction } + ); + + // Build lookup: (projectId, environment, slug) -> pageId + const pageIdByKey = new Map(); + tourPages.forEach(page => { + const key = `${page.projectId}:${page.environment}:${page.slug}`; + pageIdByKey.set(key, page.id); + }); + + // Process each page and convert targetPageSlug back to targetPageId + for (const page of tourPages) { + try { + const uiSchema = typeof page.ui_schema_json === 'string' + ? JSON.parse(page.ui_schema_json) + : page.ui_schema_json; + + if (!uiSchema || !Array.isArray(uiSchema.elements)) { + continue; + } + + let hasChanges = false; + + uiSchema.elements.forEach(element => { + if (element.targetPageSlug && typeof element.targetPageSlug === 'string') { + const key = `${page.projectId}:${page.environment}:${element.targetPageSlug}`; + const targetPageId = pageIdByKey.get(key); + if (targetPageId) { + element.targetPageId = targetPageId; + delete element.targetPageSlug; + hasChanges = true; + } + } + }); + + if (hasChanges) { + await queryInterface.sequelize.query( + `UPDATE tour_pages SET ui_schema_json = :json WHERE id = :id`, + { + replacements: { + json: JSON.stringify(uiSchema), + id: page.id + }, + type: Sequelize.QueryTypes.UPDATE, + transaction + } + ); + } + } catch (parseError) { + console.warn(`Skipping page ${page.id}: ${parseError.message}`); + } + } + + await transaction.commit(); + console.log('Rollback complete: Converted targetPageSlug back to targetPageId'); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/db/migrations/20260326060001-drop-page-elements-table.js b/backend/src/db/migrations/20260326060001-drop-page-elements-table.js new file mode 100644 index 0000000..e7c6037 --- /dev/null +++ b/backend/src/db/migrations/20260326060001-drop-page-elements-table.js @@ -0,0 +1,98 @@ +'use strict'; + +/** + * Migration: Drop page_elements table + * + * This table was designed for storing individual page elements but was never used. + * All element data is stored in tour_pages.ui_schema_json instead. + */ +module.exports = { + async up(queryInterface, _Sequelize) { + // Verify the table is empty before dropping + const [results] = await queryInterface.sequelize.query( + 'SELECT COUNT(*) as count FROM page_elements' + ); + const count = parseInt(results[0].count, 10); + + if (count > 0) { + throw new Error(`Cannot drop page_elements table: it contains ${count} records. Please migrate or delete them first.`); + } + + await queryInterface.dropTable('page_elements'); + console.log('Dropped page_elements table (was empty)'); + }, + + async down(queryInterface, Sequelize) { + // Recreate the page_elements table + await queryInterface.createTable('page_elements', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + }, + pageId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'tour_pages', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + element_type: { + type: Sequelize.STRING, + allowNull: false, + }, + xPercent: { + type: Sequelize.DECIMAL(10, 6), + allowNull: true, + }, + yPercent: { + type: Sequelize.DECIMAL(10, 6), + allowNull: true, + }, + content_json: { + type: Sequelize.TEXT, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + createdById: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + updatedById: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + importHash: { + type: Sequelize.STRING(255), + allowNull: true, + unique: true, + }, + }); + } +}; diff --git a/backend/src/db/migrations/20260326060002-drop-page-links-table.js b/backend/src/db/migrations/20260326060002-drop-page-links-table.js new file mode 100644 index 0000000..59418c4 --- /dev/null +++ b/backend/src/db/migrations/20260326060002-drop-page-links-table.js @@ -0,0 +1,106 @@ +'use strict'; + +/** + * Migration: Drop page_links table + * + * This table was designed for storing navigation links between pages but was never used. + * Navigation targets are stored in tour_pages.ui_schema_json as targetPageSlug instead. + */ +module.exports = { + async up(queryInterface, _Sequelize) { + // Verify the table is empty before dropping + const [results] = await queryInterface.sequelize.query( + 'SELECT COUNT(*) as count FROM page_links' + ); + const count = parseInt(results[0].count, 10); + + if (count > 0) { + throw new Error(`Cannot drop page_links table: it contains ${count} records. Please migrate or delete them first.`); + } + + await queryInterface.dropTable('page_links'); + console.log('Dropped page_links table (was empty)'); + }, + + async down(queryInterface, Sequelize) { + // Recreate the page_links table + await queryInterface.createTable('page_links', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + }, + from_pageId: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'tour_pages', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + to_pageId: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'tour_pages', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + transitionId: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'transitions', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + is_active: { + type: Sequelize.BOOLEAN, + defaultValue: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + createdById: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + updatedById: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + importHash: { + type: Sequelize.STRING(255), + allowNull: true, + unique: true, + }, + }); + } +}; diff --git a/backend/src/db/migrations/20260326060003-drop-transitions-table.js b/backend/src/db/migrations/20260326060003-drop-transitions-table.js new file mode 100644 index 0000000..ea2c1bf --- /dev/null +++ b/backend/src/db/migrations/20260326060003-drop-transitions-table.js @@ -0,0 +1,103 @@ +'use strict'; + +/** + * Migration: Drop transitions table + * + * This table was designed for storing transition video metadata but was never used. + * Transition video URLs are stored directly in tour_pages.ui_schema_json as transitionVideoUrl. + */ +module.exports = { + async up(queryInterface, _Sequelize) { + // Verify the table is empty before dropping + const [results] = await queryInterface.sequelize.query( + 'SELECT COUNT(*) as count FROM transitions' + ); + const count = parseInt(results[0].count, 10); + + if (count > 0) { + throw new Error(`Cannot drop transitions table: it contains ${count} records. Please migrate or delete them first.`); + } + + await queryInterface.dropTable('transitions'); + console.log('Dropped transitions table (was empty)'); + }, + + async down(queryInterface, Sequelize) { + // Recreate the transitions table + await queryInterface.createTable('transitions', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + }, + projectId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'projects', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + environment: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'dev', + }, + name: { + type: Sequelize.STRING, + allowNull: true, + }, + video_url: { + type: Sequelize.TEXT, + allowNull: true, + }, + duration_ms: { + type: Sequelize.INTEGER, + allowNull: true, + }, + source_key: { + type: Sequelize.UUID, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + createdById: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + updatedById: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + importHash: { + type: Sequelize.STRING(255), + allowNull: true, + unique: true, + }, + }); + } +}; diff --git a/backend/src/db/migrations/20260326171017-add-missing-element-type-defaults.js b/backend/src/db/migrations/20260326171017-add-missing-element-type-defaults.js new file mode 100644 index 0000000..be130d1 --- /dev/null +++ b/backend/src/db/migrations/20260326171017-add-missing-element-type-defaults.js @@ -0,0 +1,141 @@ +'use strict'; + +const { v4: uuidv4 } = require('uuid'); + +/** + * Add missing element type defaults (spot, logo, popup) + * These were missing from the original DEFAULT_ROWS and need to be added to existing databases. + * Also backfills project_element_defaults for existing projects. + */ +module.exports = { + async up(queryInterface, Sequelize) { + const now = new Date(); + + // Define the missing element types + const missingTypes = [ + { + id: uuidv4(), + element_type: 'spot', + name: 'Hotspot', + sort_order: 9, + settings_json: JSON.stringify({ + label: 'Hotspot', + iconUrl: '', + appearDelaySec: 0, + appearDurationSec: null, + }), + createdAt: now, + updatedAt: now, + }, + { + id: uuidv4(), + element_type: 'logo', + name: 'Logo', + sort_order: 10, + settings_json: JSON.stringify({ + label: 'Logo', + iconUrl: '', + backgroundImageUrl: '', + appearDelaySec: 0, + appearDurationSec: null, + }), + createdAt: now, + updatedAt: now, + }, + { + id: uuidv4(), + element_type: 'popup', + name: 'Popup', + sort_order: 11, + settings_json: JSON.stringify({ + label: 'Popup', + iconUrl: '', + popupTitle: '', + popupContent: '', + appearDelaySec: 0, + appearDurationSec: null, + }), + createdAt: now, + updatedAt: now, + }, + ]; + + // Insert missing global defaults (skip if they already exist) + for (const elementType of missingTypes) { + const [existing] = await queryInterface.sequelize.query( + `SELECT id FROM element_type_defaults WHERE element_type = :element_type AND "deletedAt" IS NULL`, + { + replacements: { element_type: elementType.element_type }, + type: Sequelize.QueryTypes.SELECT, + } + ); + + if (!existing) { + await queryInterface.bulkInsert('element_type_defaults', [elementType]); + console.log(`Added global default for: ${elementType.element_type}`); + } else { + console.log(`Global default already exists for: ${elementType.element_type}`); + } + } + + // Get all inserted/existing global defaults for the missing types + const globalDefaults = await queryInterface.sequelize.query( + `SELECT id, element_type, name, sort_order, settings_json + FROM element_type_defaults + WHERE element_type IN ('spot', 'logo', 'popup') AND "deletedAt" IS NULL`, + { type: Sequelize.QueryTypes.SELECT } + ); + + // Get all projects + const projects = await queryInterface.sequelize.query( + `SELECT id FROM projects WHERE "deletedAt" IS NULL`, + { type: Sequelize.QueryTypes.SELECT } + ); + + console.log(`Backfilling ${globalDefaults.length} element types to ${projects.length} projects...`); + + // Backfill project_element_defaults for each project + for (const project of projects) { + for (const globalDefault of globalDefaults) { + // Check if project already has this element type + const [existing] = await queryInterface.sequelize.query( + `SELECT id FROM project_element_defaults + WHERE "projectId" = :projectId AND element_type = :element_type AND "deletedAt" IS NULL`, + { + replacements: { projectId: project.id, element_type: globalDefault.element_type }, + type: Sequelize.QueryTypes.SELECT, + } + ); + + if (!existing) { + await queryInterface.bulkInsert('project_element_defaults', [{ + id: uuidv4(), + projectId: project.id, + element_type: globalDefault.element_type, + name: globalDefault.name, + sort_order: globalDefault.sort_order, + settings_json: globalDefault.settings_json, + source_element_id: globalDefault.id, + snapshot_version: 1, + createdAt: now, + updatedAt: now, + }]); + } + } + } + + console.log('Backfill complete.'); + }, + + async down(queryInterface, _Sequelize) { + // Remove the added element types from project_element_defaults + await queryInterface.sequelize.query( + `DELETE FROM project_element_defaults WHERE element_type IN ('spot', 'logo', 'popup')` + ); + + // Remove from element_type_defaults + await queryInterface.sequelize.query( + `DELETE FROM element_type_defaults WHERE element_type IN ('spot', 'logo', 'popup')` + ); + }, +}; diff --git a/backend/src/db/models/ui_elements.js b/backend/src/db/models/element_type_defaults.js similarity index 70% rename from backend/src/db/models/ui_elements.js rename to backend/src/db/models/element_type_defaults.js index 5d56cad..62da74a 100644 --- a/backend/src/db/models/ui_elements.js +++ b/backend/src/db/models/element_type_defaults.js @@ -1,6 +1,6 @@ module.exports = function (sequelize, DataTypes) { - const ui_elements = sequelize.define( - 'ui_elements', + const element_type_defaults = sequelize.define( + 'element_type_defaults', { id: { type: DataTypes.UUID, @@ -58,15 +58,28 @@ module.exports = function (sequelize, DataTypes) { }, ); - ui_elements.associate = (db) => { - db.ui_elements.belongsTo(db.users, { + element_type_defaults.associate = (db) => { + db.element_type_defaults.belongsTo(db.users, { as: 'createdBy', }); - db.ui_elements.belongsTo(db.users, { + db.element_type_defaults.belongsTo(db.users, { as: 'updatedBy', }); + + // Add hasMany relationship to project_element_defaults + if (db.project_element_defaults) { + db.element_type_defaults.hasMany(db.project_element_defaults, { + as: 'project_defaults', + foreignKey: { + name: 'source_element_id', + }, + constraints: true, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }); + } }; - return ui_elements; + return element_type_defaults; }; diff --git a/backend/src/db/models/page_elements.js b/backend/src/db/models/page_elements.js deleted file mode 100644 index 592e33c..0000000 --- a/backend/src/db/models/page_elements.js +++ /dev/null @@ -1,187 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const page_elements = sequelize.define( - 'page_elements', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -element_type: { - type: DataTypes.ENUM, - allowNull: false, - - values: [ - -"nav_button", - - -"spot", - - -"description", - - -"tooltip", - - -"gallery", - - -"carousel", - - -"logo", - - -"video_player", - - -"popup" - - ], - - }, - -name: { - type: DataTypes.TEXT, - validate: { - len: { args: [0, 255], msg: 'Element name must be at most 255 characters' }, - }, - }, - -sort_order: { - type: DataTypes.INTEGER, - allowNull: false, - defaultValue: 0, - - }, - -is_visible: { - type: DataTypes.BOOLEAN, - - allowNull: false, - defaultValue: false, - - - - }, - -x_percent: { - type: DataTypes.DECIMAL, - - - - }, - -y_percent: { - type: DataTypes.DECIMAL, - - - - }, - -width_percent: { - type: DataTypes.DECIMAL, - - - - }, - -height_percent: { - type: DataTypes.DECIMAL, - - - - }, - -rotation_deg: { - type: DataTypes.DECIMAL, - - - - }, - -style_json: { - type: DataTypes.JSON, - }, - -content_json: { - type: DataTypes.JSON, - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - indexes: [ - { fields: ['pageId'] }, - { fields: ['pageId', 'sort_order'] }, - { fields: ['is_visible'] }, - { fields: ['deletedAt'] }, - ], - }, - ); - - page_elements.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.page_elements.belongsTo(db.tour_pages, { - as: 'page', - foreignKey: { - name: 'pageId', - allowNull: false, - }, - constraints: true, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }); - - - - - db.page_elements.belongsTo(db.users, { - as: 'createdBy', - }); - - db.page_elements.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return page_elements; -}; - diff --git a/backend/src/db/models/page_links.js b/backend/src/db/models/page_links.js deleted file mode 100644 index 4fab051..0000000 --- a/backend/src/db/models/page_links.js +++ /dev/null @@ -1,148 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const page_links = sequelize.define( - 'page_links', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -direction: { - type: DataTypes.ENUM, - allowNull: false, - defaultValue: 'forward', - - values: [ - -"forward", - - -"back", - - -"external" - - ], - - }, - -external_url: { - type: DataTypes.TEXT, - validate: { - len: { args: [0, 2048], msg: 'External URL must be at most 2048 characters' }, - isUrlOrEmpty(value) { - if (value && value.length > 0 && !/^https?:\/\/.+/.test(value)) { - throw new Error('External URL must be a valid URL'); - } - }, - }, - }, - -is_active: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false, - }, - -trigger_selector: { - type: DataTypes.TEXT, - validate: { - len: { args: [0, 1024], msg: 'Trigger selector must be at most 1024 characters' }, - }, - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - indexes: [ - { fields: ['from_pageId'] }, - { fields: ['to_pageId'] }, - { fields: ['transitionId'] }, - { fields: ['is_active'] }, - { fields: ['deletedAt'] }, - ], - }, - ); - - page_links.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.page_links.belongsTo(db.tour_pages, { - as: 'from_page', - foreignKey: { - name: 'from_pageId', - }, - constraints: true, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }); - - db.page_links.belongsTo(db.tour_pages, { - as: 'to_page', - foreignKey: { - name: 'to_pageId', - }, - constraints: true, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }); - - db.page_links.belongsTo(db.transitions, { - as: 'transition', - foreignKey: { - name: 'transitionId', - }, - constraints: true, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }); - - - - - db.page_links.belongsTo(db.users, { - as: 'createdBy', - }); - - db.page_links.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return page_links; -}; - diff --git a/backend/src/db/models/project_element_defaults.js b/backend/src/db/models/project_element_defaults.js new file mode 100644 index 0000000..ff6d96b --- /dev/null +++ b/backend/src/db/models/project_element_defaults.js @@ -0,0 +1,98 @@ +module.exports = function (sequelize, DataTypes) { + const project_element_defaults = sequelize.define( + 'project_element_defaults', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + element_type: { + // TEXT for flexibility - matches element_type_defaults and page_elements + type: DataTypes.TEXT, + allowNull: false, + validate: { + notEmpty: { msg: 'Element type is required' }, + len: { args: [1, 100], msg: 'Element type must be between 1 and 100 characters' }, + }, + }, + name: { + type: DataTypes.TEXT, + validate: { + len: { args: [0, 255], msg: 'Name must be at most 255 characters' }, + }, + }, + sort_order: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + settings_json: { + type: DataTypes.TEXT, + allowNull: true, + }, + source_element_id: { + // Optional FK - tracks which global default this was snapshotted from + // SET NULL on global delete to preserve project overrides + type: DataTypes.UUID, + allowNull: true, + }, + snapshot_version: { + // Increments when resetting from global - enables "check for updates" feature + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + indexes: [ + { fields: ['projectId'] }, + { fields: ['projectId', 'element_type'], unique: true }, + { fields: ['element_type'] }, + { fields: ['source_element_id'] }, + { fields: ['deletedAt'] }, + ], + }, + ); + + project_element_defaults.associate = (db) => { + db.project_element_defaults.belongsTo(db.projects, { + as: 'project', + foreignKey: { + name: 'projectId', + allowNull: false, + }, + constraints: true, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); + + db.project_element_defaults.belongsTo(db.element_type_defaults, { + as: 'source_element', + foreignKey: { + name: 'source_element_id', + }, + constraints: true, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }); + + db.project_element_defaults.belongsTo(db.users, { + as: 'createdBy', + }); + + db.project_element_defaults.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return project_element_defaults; +}; diff --git a/backend/src/db/models/projects.js b/backend/src/db/models/projects.js index 19ec91f..5dfd43b 100644 --- a/backend/src/db/models/projects.js +++ b/backend/src/db/models/projects.js @@ -35,25 +35,6 @@ description: { }, -phase: { - type: DataTypes.ENUM, - allowNull: false, - defaultValue: 'dev', - - values: [ - -"dev", - - -"stage", - - -"production" - - ], - - }, - logo_url: { type: DataTypes.TEXT, @@ -85,15 +66,8 @@ custom_css_json: { cdn_base_url: { type: DataTypes.TEXT, - - - }, -entry_page_slug: { - type: DataTypes.TEXT, - - }, @@ -109,7 +83,6 @@ entry_page_slug: { freezeTableName: true, indexes: [ { fields: ['slug'], unique: true }, - { fields: ['phase'] }, { fields: ['deletedAt'] }, ], }, @@ -172,17 +145,6 @@ entry_page_slug: { - db.projects.hasMany(db.transitions, { - as: 'transitions_project', - foreignKey: { - name: 'projectId', - }, - constraints: true, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }); - - db.projects.hasMany(db.project_audio_tracks, { as: 'project_audio_tracks_project', foreignKey: { @@ -227,6 +189,15 @@ entry_page_slug: { }); + db.projects.hasMany(db.project_element_defaults, { + as: 'project_element_defaults_project', + foreignKey: { + name: 'projectId', + }, + constraints: true, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); //end loop diff --git a/backend/src/db/models/tour_pages.js b/backend/src/db/models/tour_pages.js index ab066f2..861ae0b 100644 --- a/backend/src/db/models/tour_pages.js +++ b/backend/src/db/models/tour_pages.js @@ -139,36 +139,6 @@ ui_schema_json: { - db.tour_pages.hasMany(db.page_elements, { - as: 'page_elements_page', - foreignKey: { - name: 'pageId', - }, - constraints: true, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }); - - - db.tour_pages.hasMany(db.page_links, { - as: 'page_links_from_page', - foreignKey: { - name: 'from_pageId', - }, - constraints: true, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }); - - db.tour_pages.hasMany(db.page_links, { - as: 'page_links_to_page', - foreignKey: { - name: 'to_pageId', - }, - constraints: true, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }); diff --git a/backend/src/db/models/transitions.js b/backend/src/db/models/transitions.js deleted file mode 100644 index eafc17e..0000000 --- a/backend/src/db/models/transitions.js +++ /dev/null @@ -1,168 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const transitions = sequelize.define( - 'transitions', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -environment: { - type: DataTypes.ENUM, - allowNull: false, - defaultValue: 'dev', - - values: [ - -"dev", - - -"stage", - - -"production" - - ], - - }, - -source_key: { - type: DataTypes.TEXT, - - - - }, - -name: { - type: DataTypes.TEXT, - allowNull: false, - validate: { - notEmpty: { msg: 'Transition name is required' }, - len: { args: [1, 255], msg: 'Transition name must be between 1 and 255 characters' }, - }, - }, - -slug: { - type: DataTypes.TEXT, - allowNull: false, - validate: { - notEmpty: { msg: 'Slug is required' }, - is: { args: /^[a-z0-9_-]+$/i, msg: 'Slug can only contain letters, numbers, dashes, and underscores' }, - len: { args: [1, 255], msg: 'Slug must be between 1 and 255 characters' }, - }, - }, - -video_url: { - type: DataTypes.TEXT, - - - - }, - -audio_url: { - type: DataTypes.TEXT, - - - - }, - -supports_reverse: { - type: DataTypes.BOOLEAN, - - allowNull: false, - defaultValue: false, - - - - }, - -duration_sec: { - type: DataTypes.DECIMAL, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - indexes: [ - { fields: ['projectId'] }, - { fields: ['projectId', 'environment', 'slug'], unique: true }, - { fields: ['deletedAt'] }, - ], - }, - ); - - transitions.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - db.transitions.hasMany(db.page_links, { - as: 'page_links_transition', - foreignKey: { - name: 'transitionId', - }, - constraints: true, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }); - - - - - - - - -//end loop - - - - db.transitions.belongsTo(db.projects, { - as: 'project', - foreignKey: { - name: 'projectId', - }, - constraints: true, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }); - - - - - db.transitions.belongsTo(db.users, { - as: 'createdBy', - }); - - db.transitions.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return transitions; -}; - diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js index 4241c83..df4cdc3 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.js +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -61,7 +61,7 @@ module.exports = { } const entities = [ - "users","roles","permissions","projects","project_memberships","assets","asset_variants","presigned_url_requests","tour_pages","page_elements","page_links","transitions","project_audio_tracks","publish_events","pwa_caches","access_logs", + "users","roles","permissions","projects","project_memberships","assets","asset_variants","presigned_url_requests","tour_pages","project_audio_tracks","publish_events","pwa_caches","access_logs", ]; await queryInterface.bulkInsert("permissions", entities.flatMap(createPermissions)); await queryInterface.bulkInsert("permissions", [{ id: getId(`READ_API_DOCS`), createdAt, updatedAt, name: `READ_API_DOCS` }]); diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index 8a658e0..7213266 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -40,12 +40,6 @@ const PresignedUrlRequests = db.presigned_url_requests; const TourPages = db.tour_pages; -const PageElements = db.page_elements; - -const PageLinks = db.page_links; - -const Transitions = db.transitions; - const ProjectAudioTracks = db.project_audio_tracks; const PublishEvents = db.publish_events; @@ -85,16 +79,9 @@ const ProjectsData = [ - - - - "phase": "stage", - - - - - - + + + "logo_url": "https://cdn.platform.com/cardiff/logo.png", @@ -137,13 +124,6 @@ const ProjectsData = [ - "entry_page_slug": "welcome", - - - - - - "is_deleted": true, @@ -180,16 +160,9 @@ const ProjectsData = [ - - - - "phase": "stage", - - - - - - + + + "logo_url": "https://cdn.platform.com/riverside/logo.png", @@ -232,13 +205,6 @@ const ProjectsData = [ - "entry_page_slug": "start", - - - - - - "is_deleted": false, @@ -275,16 +241,8 @@ const ProjectsData = [ - - - - "phase": "production", - - - - - - + + "logo_url": "https://cdn.platform.com/mall/logo.png", @@ -324,12 +282,6 @@ const ProjectsData = [ - - - - "entry_page_slug": "home", - - @@ -1426,650 +1378,6 @@ const TourPagesData = [ ]; - - -const PageElementsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - "element_type": "video_player", - - - - - - - "name": "Go to arena floor", - - - - - - - "sort_order": 1, - - - - - - - "is_visible": true, - - - - - - - "x_percent": 82.5, - - - - - - - "y_percent": 74.0, - - - - - - - "width_percent": 12.0, - - - - - - - "height_percent": 8.0, - - - - - - - "rotation_deg": 0.0, - - - - - - - "style_json": "{bg:rgba(255,255,255,0.12),color:#FFFFFF,radius:14px}", - - - - - - - "content_json": "{direction:forward,label:Enter}", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "element_type": "carousel", - - - - - - - "name": "Intro description", - - - - - - - "sort_order": 2, - - - - - - - "is_visible": true, - - - - - - - "x_percent": 6.0, - - - - - - - "y_percent": 68.0, - - - - - - - "width_percent": 42.0, - - - - - - - "height_percent": 22.0, - - - - - - - "rotation_deg": 0.0, - - - - - - - "style_json": "{fontSize:18px,fontWeight:600,background:rgba(0,0,0,0.45),color:#FFFFFF}", - - - - - - - "content_json": "{text:Explore the venue areas and amenities.}", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "element_type": "description", - - - - - - - "name": "Seating info popup", - - - - - - - "sort_order": 1, - - - - - - - "is_visible": true, - - - - - - - "x_percent": 60.0, - - - - - - - "y_percent": 20.0, - - - - - - - "width_percent": 30.0, - - - - - - - "height_percent": 35.0, - - - - - - - "rotation_deg": 0.0, - - - - - - - "style_json": "{background:rgba(17,24,39,0.92),radius:16px}", - - - - - - - "content_json": "{title:Seating,description:Capacity and seating layouts available on request.,imageUrl:https://cdn.platform.com/cardiff/images/seating.webp}", - - - - }, - -]; - - - -const PageLinksData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "direction": "external", - - - - - - - "external_url": "", - - - - - - - // type code here for "relation_one" field - - - - - - - "is_active": true, - - - - - - - "trigger_selector": "nav:forward:1", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "direction": "external", - - - - - - - "external_url": "", - - - - - - - // type code here for "relation_one" field - - - - - - - "is_active": true, - - - - - - - "trigger_selector": "nav:back:1", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "direction": "forward", - - - - - - - "external_url": "https://riverside.city.example/visit", - - - - - - - // type code here for "relation_one" field - - - - - - - "is_active": true, - - - - - - - "trigger_selector": "link:official", - - - - }, - -]; - - - -const TransitionsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - "environment": "stage", - - - - - - - "source_key": "cardiff_hallway_stage", - - - - - - - "name": "Hallway Transition", - - - - - - - "slug": "hallway", - - - - - - - "video_url": "https://cdn.platform.com/cardiff/video/transition-hallway.mp4", - - - - - - - "audio_url": "", - - - - - - - "supports_reverse": true, - - - - - - - "duration_sec": 4.8, - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "environment": "stage", - - - - - - - "source_key": "cardiff_gate_stage", - - - - - - - "name": "Gate Transition", - - - - - - - "slug": "gate", - - - - - - - "video_url": "https://cdn.platform.com/cardiff/video/transition-gate.mp4", - - - - - - - "audio_url": "", - - - - - - - "supports_reverse": true, - - - - - - - "duration_sec": 5.2, - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "environment": "dev", - - - - - - - "source_key": "riverside_fade_prod", - - - - - - - "name": "Soft Fade", - - - - - - - "slug": "soft-fade", - - - - - - - "video_url": "https://cdn.platform.com/riverside/video/fade.mp4", - - - - - - - "audio_url": "", - - - - - - - "supports_reverse": true, - - - - - - - "duration_sec": 1.2, - - - - }, - -]; - - - const ProjectAudioTracksData = [ { @@ -3394,51 +2702,6 @@ const AccessLogsData = [ - async function associatePageElementWithPage() { - - const relatedPage0 = await TourPages.findOne({ - offset: Math.floor(Math.random() * (await TourPages.count())), - }); - const PageElement0 = await PageElements.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (PageElement0?.setPage) - { - await - PageElement0. - setPage(relatedPage0); - } - - const relatedPage1 = await TourPages.findOne({ - offset: Math.floor(Math.random() * (await TourPages.count())), - }); - const PageElement1 = await PageElements.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (PageElement1?.setPage) - { - await - PageElement1. - setPage(relatedPage1); - } - - const relatedPage2 = await TourPages.findOne({ - offset: Math.floor(Math.random() * (await TourPages.count())), - }); - const PageElement2 = await PageElements.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (PageElement2?.setPage) - { - await - PageElement2. - setPage(relatedPage2); - } - - } @@ -3469,153 +2732,16 @@ const AccessLogsData = [ - async function associatePageLinkWithFrom_page() { - - const relatedFrom_page0 = await TourPages.findOne({ - offset: Math.floor(Math.random() * (await TourPages.count())), - }); - const PageLink0 = await PageLinks.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (PageLink0?.setFrom_page) - { - await - PageLink0. - setFrom_page(relatedFrom_page0); - } - - const relatedFrom_page1 = await TourPages.findOne({ - offset: Math.floor(Math.random() * (await TourPages.count())), - }); - const PageLink1 = await PageLinks.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (PageLink1?.setFrom_page) - { - await - PageLink1. - setFrom_page(relatedFrom_page1); - } - - const relatedFrom_page2 = await TourPages.findOne({ - offset: Math.floor(Math.random() * (await TourPages.count())), - }); - const PageLink2 = await PageLinks.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (PageLink2?.setFrom_page) - { - await - PageLink2. - setFrom_page(relatedFrom_page2); - } - - } - - - async function associatePageLinkWithTo_page() { - - const relatedTo_page0 = await TourPages.findOne({ - offset: Math.floor(Math.random() * (await TourPages.count())), - }); - const PageLink0 = await PageLinks.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (PageLink0?.setTo_page) - { - await - PageLink0. - setTo_page(relatedTo_page0); - } - - const relatedTo_page1 = await TourPages.findOne({ - offset: Math.floor(Math.random() * (await TourPages.count())), - }); - const PageLink1 = await PageLinks.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (PageLink1?.setTo_page) - { - await - PageLink1. - setTo_page(relatedTo_page1); - } - - const relatedTo_page2 = await TourPages.findOne({ - offset: Math.floor(Math.random() * (await TourPages.count())), - }); - const PageLink2 = await PageLinks.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (PageLink2?.setTo_page) - { - await - PageLink2. - setTo_page(relatedTo_page2); - } - - } + - - - async function associatePageLinkWithTransition() { - - const relatedTransition0 = await Transitions.findOne({ - offset: Math.floor(Math.random() * (await Transitions.count())), - }); - const PageLink0 = await PageLinks.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (PageLink0?.setTransition) - { - await - PageLink0. - setTransition(relatedTransition0); - } - - const relatedTransition1 = await Transitions.findOne({ - offset: Math.floor(Math.random() * (await Transitions.count())), - }); - const PageLink1 = await PageLinks.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (PageLink1?.setTransition) - { - await - PageLink1. - setTransition(relatedTransition1); - } - - const relatedTransition2 = await Transitions.findOne({ - offset: Math.floor(Math.random() * (await Transitions.count())), - }); - const PageLink2 = await PageLinks.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (PageLink2?.setTransition) - { - await - PageLink2. - setTransition(relatedTransition2); - } - - } + @@ -3626,53 +2752,7 @@ const AccessLogsData = [ - - - async function associateTransitionWithProject() { - - const relatedProject0 = await Projects.findOne({ - offset: Math.floor(Math.random() * (await Projects.count())), - }); - const Transition0 = await Transitions.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Transition0?.setProject) - { - await - Transition0. - setProject(relatedProject0); - } - - const relatedProject1 = await Projects.findOne({ - offset: Math.floor(Math.random() * (await Projects.count())), - }); - const Transition1 = await Transitions.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Transition1?.setProject) - { - await - Transition1. - setProject(relatedProject1); - } - - const relatedProject2 = await Projects.findOne({ - offset: Math.floor(Math.random() * (await Projects.count())), - }); - const Transition2 = await Transitions.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Transition2?.setProject) - { - await - Transition2. - setProject(relatedProject2); - } - - } + @@ -4098,27 +3178,12 @@ module.exports = { - + await TourPages.bulkCreate(TourPagesData); - - - - - await PageElements.bulkCreate(PageElementsData); - - - - - await PageLinks.bulkCreate(PageLinksData); - - - - - await Transitions.bulkCreate(TransitionsData); - - - - + + + + await ProjectAudioTracks.bulkCreate(ProjectAudioTracksData); @@ -4339,7 +3404,6 @@ module.exports = { - await associatePageElementWithPage(), @@ -4369,12 +3433,10 @@ module.exports = { - await associatePageLinkWithFrom_page(), - await associatePageLinkWithTo_page(), @@ -4383,7 +3445,6 @@ module.exports = { - await associatePageLinkWithTransition(), @@ -4395,7 +3456,6 @@ module.exports = { - await associateTransitionWithProject(), @@ -4546,16 +3606,7 @@ module.exports = { await queryInterface.bulkDelete('tour_pages', null, {}); - - await queryInterface.bulkDelete('page_elements', null, {}); - - - await queryInterface.bulkDelete('page_links', null, {}); - - - await queryInterface.bulkDelete('transitions', null, {}); - - + await queryInterface.bulkDelete('project_audio_tracks', null, {}); diff --git a/backend/src/helpers.js b/backend/src/helpers.js index 85442de..183e2eb 100644 --- a/backend/src/helpers.js +++ b/backend/src/helpers.js @@ -8,7 +8,7 @@ module.exports = class Helpers { }; } - static commonErrorHandler(error, req, res) { + static commonErrorHandler(error, req, res, _next) { const statusCode = error.code || error.status; if ([400, 401, 403, 404, 409, 422].includes(statusCode)) { diff --git a/backend/src/index.js b/backend/src/index.js index f38bc95..d7907b7 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -38,12 +38,6 @@ const presigned_url_requestsRoutes = require('./routes/presigned_url_requests'); const tour_pagesRoutes = require('./routes/tour_pages'); -const page_elementsRoutes = require('./routes/page_elements'); - -const page_linksRoutes = require('./routes/page_links'); - -const transitionsRoutes = require('./routes/transitions'); - const project_audio_tracksRoutes = require('./routes/project_audio_tracks'); const publish_eventsRoutes = require('./routes/publish_events'); @@ -51,7 +45,8 @@ const publish_eventsRoutes = require('./routes/publish_events'); const pwa_cachesRoutes = require('./routes/pwa_caches'); const access_logsRoutes = require('./routes/access_logs'); -const ui_elementsRoutes = require('./routes/ui_elements'); +const element_type_defaultsRoutes = require('./routes/element_type_defaults'); +const project_element_defaultsRoutes = require('./routes/project_element_defaults'); const publishRoutes = require('./routes/publish'); const runtimeContextRoutes = require('./routes/runtime-context'); @@ -134,11 +129,14 @@ app.use(bodyParser.urlencoded({ extended: true, limit: '1mb' })); app.use(runtimeContextMiddleware); const requireRuntimeReadOrAuth = (req, res, next) => { - const runtimeMode = req.runtimeContext?.mode; + const headerEnvironment = req.runtimeContext?.headerEnvironment; const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method); const hasAuthHeader = Boolean(req.headers.authorization); - if (runtimeMode === 'production' && isReadOnlyRequest && !hasAuthHeader) { + // Only production is public. Stage requires authentication (workspace for review). + const isPublicEnvironment = headerEnvironment === 'production'; + + if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) { req.isRuntimePublicRequest = true; return next(); } @@ -202,12 +200,6 @@ app.use('/api/presigned_url_requests', jwtAuth, presigned_url_requestsRoutes); mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes); -mountRuntimeEntityRoute('/api/page_elements', 'page_elements', page_elementsRoutes); - -mountRuntimeEntityRoute('/api/page_links', 'page_links', page_linksRoutes); - -mountRuntimeEntityRoute('/api/transitions', 'transitions', transitionsRoutes); - mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', project_audio_tracksRoutes); app.use('/api/publish_events', jwtAuth, publish_eventsRoutes); @@ -215,7 +207,10 @@ 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/ui-elements', jwtAuth, ui_elementsRoutes); +app.use('/api/element-type-defaults', jwtAuth, element_type_defaultsRoutes); +// Backwards compatibility alias for old API endpoint +app.use('/api/ui-elements', jwtAuth, element_type_defaultsRoutes); +app.use('/api/project-element-defaults', jwtAuth, project_element_defaultsRoutes); app.use('/api/publish', jwtAuth, publishRoutes); diff --git a/backend/src/middlewares/runtime-context.js b/backend/src/middlewares/runtime-context.js index 4f6b4af..9f483e2 100644 --- a/backend/src/middlewares/runtime-context.js +++ b/backend/src/middlewares/runtime-context.js @@ -1,106 +1,31 @@ -const DEFAULT_ROOT_DOMAIN = process.env.PLATFORM_ROOT_DOMAIN || 'platform.com'; -const DEFAULT_ADMIN_SUBDOMAIN = process.env.ADMIN_SUBDOMAIN || 'admin'; -const DEFAULT_STAGE_SUBDOMAIN = process.env.STAGE_SUBDOMAIN || 'stage'; - -function stripPort(hostname = '') { - return hostname.split(':')[0].toLowerCase(); -} - -function getRequestHost(req) { - const forwardedHost = req.headers['x-forwarded-host']; - const hostHeader = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost || req.headers.host; - return stripPort(hostHeader || ''); -} - -function detectRuntimeContext(hostname) { - const host = stripPort(hostname); - - if (!host) { - return { - mode: 'unknown', - projectSlug: null, - host, - rootDomain: DEFAULT_ROOT_DOMAIN, - }; - } - - if (host === 'localhost' || host === '127.0.0.1') { - return { - mode: 'admin', - projectSlug: null, - host, - rootDomain: DEFAULT_ROOT_DOMAIN, - }; - } - - const rootParts = DEFAULT_ROOT_DOMAIN.split('.').filter(Boolean); - const hostParts = host.split('.').filter(Boolean); - - if (hostParts.length > rootParts.length) { - const suffix = hostParts.slice(-rootParts.length).join('.'); - - if (suffix === DEFAULT_ROOT_DOMAIN) { - const prefixParts = hostParts.slice(0, -rootParts.length); - - if (prefixParts.length === 1 && prefixParts[0] === DEFAULT_ADMIN_SUBDOMAIN) { - return { - mode: 'admin', - projectSlug: null, - host, - rootDomain: DEFAULT_ROOT_DOMAIN, - }; - } - - if ( - prefixParts.length === 2 && - prefixParts[0] === DEFAULT_STAGE_SUBDOMAIN && - prefixParts[1] !== DEFAULT_ADMIN_SUBDOMAIN - ) { - return { - mode: 'stage', - projectSlug: prefixParts[1], - host, - rootDomain: DEFAULT_ROOT_DOMAIN, - }; - } - - if ( - prefixParts.length === 1 && - ![DEFAULT_ADMIN_SUBDOMAIN, DEFAULT_STAGE_SUBDOMAIN].includes(prefixParts[0]) - ) { - return { - mode: 'production', - projectSlug: prefixParts[0], - host, - rootDomain: DEFAULT_ROOT_DOMAIN, - }; - } - } - } - - return { - mode: 'unknown', - projectSlug: null, - host, - rootDomain: DEFAULT_ROOT_DOMAIN, - }; -} +/** + * Runtime Context Middleware + * Reads environment and project slug from headers for route-based access. + * Routes: /p/[slug] (production), /p/[slug]/stage (stage), /constructor (dev) + */ function runtimeContextMiddleware(req, res, next) { - const host = getRequestHost(req); - const context = detectRuntimeContext(host); + const context = { + mode: 'admin', + projectSlug: null, + }; - req.runtimeContext = context; - - res.setHeader('x-runtime-mode', context.mode); - if (context.projectSlug) { - res.setHeader('x-runtime-project', context.projectSlug); + // Read environment from header (X-Runtime-Environment) + const headerEnvironment = req.headers['x-runtime-environment']; + if (headerEnvironment && ['production', 'stage', 'dev'].includes(headerEnvironment)) { + context.headerEnvironment = headerEnvironment; } + // Read project slug from header (X-Runtime-Project-Slug) + const headerProjectSlug = req.headers['x-runtime-project-slug']; + if (headerProjectSlug) { + context.headerProjectSlug = headerProjectSlug; + } + + req.runtimeContext = context; next(); } module.exports = { runtimeContextMiddleware, - detectRuntimeContext, }; diff --git a/backend/src/middlewares/runtime-public.js b/backend/src/middlewares/runtime-public.js index a003a52..145b731 100644 --- a/backend/src/middlewares/runtime-public.js +++ b/backend/src/middlewares/runtime-public.js @@ -6,14 +6,12 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = { 'name', 'slug', 'description', - 'phase', 'logo_url', 'favicon_url', 'og_image_url', 'theme_config_json', 'custom_css_json', 'cdn_base_url', - 'entry_page_slug', ], tour_pages: [ 'id', @@ -30,43 +28,6 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = { 'requires_auth', 'ui_schema_json', ], - page_elements: [ - 'id', - 'pageId', - 'element_type', - 'name', - 'sort_order', - 'is_visible', - 'x_percent', - 'y_percent', - 'width_percent', - 'height_percent', - 'rotation_deg', - 'style_json', - 'content_json', - ], - page_links: [ - 'id', - 'from_pageId', - 'to_pageId', - 'transitionId', - 'direction', - 'external_url', - 'is_active', - 'trigger_selector', - ], - transitions: [ - 'id', - 'projectId', - 'environment', - 'source_key', - 'name', - 'slug', - 'video_url', - 'audio_url', - 'supports_reverse', - 'duration_sec', - ], project_audio_tracks: [ 'id', 'projectId', @@ -87,11 +48,13 @@ const pickFields = (record, fields) => { return record; } - return fields.reduce((acc, field) => { - if (Object.prototype.hasOwnProperty.call(record, field)) { - acc[field] = record[field]; - } + // Convert Sequelize instance to plain object if needed + const plainRecord = typeof record.get === 'function' ? record.get({ plain: true }) : record; + return fields.reduce((acc, field) => { + if (field in plainRecord && plainRecord[field] !== undefined) { + acc[field] = plainRecord[field]; + } return acc; }, {}); }; diff --git a/backend/src/routes/element_type_defaults.js b/backend/src/routes/element_type_defaults.js new file mode 100644 index 0000000..e071192 --- /dev/null +++ b/backend/src/routes/element_type_defaults.js @@ -0,0 +1,7 @@ +const Element_type_defaultsService = require('../services/element_type_defaults'); +const Element_type_defaultsDBApi = require('../db/api/element_type_defaults'); +const { createEntityRouter } = require('../factories/router.factory'); + +module.exports = createEntityRouter('element_type_defaults', Element_type_defaultsService, Element_type_defaultsDBApi, { + permissionEntity: 'page_elements', +}); diff --git a/backend/src/routes/page_elements.js b/backend/src/routes/page_elements.js deleted file mode 100644 index eedf2cf..0000000 --- a/backend/src/routes/page_elements.js +++ /dev/null @@ -1,153 +0,0 @@ -const Page_elementsService = require('../services/page_elements'); -const Page_elementsDBApi = require('../db/api/page_elements'); -const { createEntityRouter } = require('../factories/router.factory'); - -/** - * @swagger - * components: - * schemas: - * Page_elements: - * type: object - * properties: - * name: - * type: string - * style_json: - * type: string - * content_json: - * type: string - * sort_order: - * type: integer - * x_percent: - * type: number - * y_percent: - * type: number - * width_percent: - * type: number - * height_percent: - * type: number - * rotation_deg: - * type: number - */ - -/** - * @swagger - * tags: - * name: Page_elements - * description: The Page_elements managing API - */ - -/** - * @swagger - * /api/page_elements: - * post: - * security: - * - bearerAuth: [] - * tags: [Page_elements] - * summary: Add new item - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * type: object - * $ref: "#/components/schemas/Page_elements" - * responses: - * 200: - * description: The item was successfully added - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 500: - * description: Some server error - * get: - * security: - * - bearerAuth: [] - * tags: [Page_elements] - * summary: Get all page_elements - * responses: - * 200: - * description: Page_elements list successfully received - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 500: - * description: Some server error - */ - -/** - * @swagger - * /api/page_elements/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Page_elements] - * summary: Update the selected item - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * type: string - * data: - * type: object - * $ref: "#/components/schemas/Page_elements" - * responses: - * 200: - * description: The item was successfully updated - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - * delete: - * security: - * - bearerAuth: [] - * tags: [Page_elements] - * summary: Delete the selected item - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - * get: - * security: - * - bearerAuth: [] - * tags: [Page_elements] - * summary: Get selected item - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ - -module.exports = createEntityRouter('page_elements', Page_elementsService, Page_elementsDBApi); diff --git a/backend/src/routes/page_links.js b/backend/src/routes/page_links.js deleted file mode 100644 index 14e7245..0000000 --- a/backend/src/routes/page_links.js +++ /dev/null @@ -1,139 +0,0 @@ -const Page_linksService = require('../services/page_links'); -const Page_linksDBApi = require('../db/api/page_links'); -const { createEntityRouter } = require('../factories/router.factory'); - -/** - * @swagger - * components: - * schemas: - * Page_links: - * type: object - * properties: - * external_url: - * type: string - * trigger_selector: - * type: string - */ - -/** - * @swagger - * tags: - * name: Page_links - * description: The Page_links managing API - */ - -/** - * @swagger - * /api/page_links: - * post: - * security: - * - bearerAuth: [] - * tags: [Page_links] - * summary: Add new item - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * type: object - * $ref: "#/components/schemas/Page_links" - * responses: - * 200: - * description: The item was successfully added - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 500: - * description: Some server error - * get: - * security: - * - bearerAuth: [] - * tags: [Page_links] - * summary: Get all page_links - * responses: - * 200: - * description: Page_links list successfully received - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 500: - * description: Some server error - */ - -/** - * @swagger - * /api/page_links/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Page_links] - * summary: Update the selected item - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * type: string - * data: - * type: object - * $ref: "#/components/schemas/Page_links" - * responses: - * 200: - * description: The item was successfully updated - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - * delete: - * security: - * - bearerAuth: [] - * tags: [Page_links] - * summary: Delete the selected item - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - * get: - * security: - * - bearerAuth: [] - * tags: [Page_links] - * summary: Get selected item - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ - -module.exports = createEntityRouter('page_links', Page_linksService, Page_linksDBApi); diff --git a/backend/src/routes/project_element_defaults.js b/backend/src/routes/project_element_defaults.js new file mode 100644 index 0000000..9ffe6f6 --- /dev/null +++ b/backend/src/routes/project_element_defaults.js @@ -0,0 +1,81 @@ +const Project_element_defaultsService = require('../services/project_element_defaults'); +const Project_element_defaultsDBApi = require('../db/api/project_element_defaults'); +const { createEntityRouter } = require('../factories/router.factory'); +const wrapAsync = require('../helpers').wrapAsync; + +// Create base router with standard CRUD operations +const baseRouter = createEntityRouter( + 'project_element_defaults', + Project_element_defaultsService, + Project_element_defaultsDBApi, + { + permissionEntity: 'page_elements', + } +); + +/** + * @swagger + * /api/project-element-defaults/{id}/reset: + * post: + * security: + * - bearerAuth: [] + * tags: [Project Element Defaults] + * summary: Reset project element default to global + * description: Resets a project element default to the current global element type default settings + * parameters: + * - in: path + * name: id + * schema: + * type: string + * required: true + * description: Project element default ID + * responses: + * 200: + * description: Successfully reset + * 404: + * description: Project element default not found + */ +baseRouter.post( + '/:id/reset', + wrapAsync(async (req, res) => { + const payload = await Project_element_defaultsService.resetToGlobal( + req.params.id, + { currentUser: req.currentUser } + ); + res.status(200).json(payload); + }) +); + +/** + * @swagger + * /api/project-element-defaults/{id}/diff: + * get: + * security: + * - bearerAuth: [] + * tags: [Project Element Defaults] + * summary: Get diff from global default + * description: Compares project element default with the current global element type default + * parameters: + * - in: path + * name: id + * schema: + * type: string + * required: true + * description: Project element default ID + * responses: + * 200: + * description: Diff result + * 404: + * description: Project element default not found + */ +baseRouter.get( + '/:id/diff', + wrapAsync(async (req, res) => { + const payload = await Project_element_defaultsService.getDiffFromGlobal( + req.params.id + ); + res.status(200).json(payload); + }) +); + +module.exports = baseRouter; diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js index 9ec68ce..ae5dd22 100644 --- a/backend/src/routes/projects.js +++ b/backend/src/routes/projects.js @@ -53,10 +53,7 @@ router.use(checkCrudPermissions('projects')); * cdn_base_url: * type: string * default: cdn_base_url - * entry_page_slug: - * type: string - * default: entry_page_slug - + * @@ -325,7 +322,7 @@ router.get('/', wrapAsync(async (req, res) => { req.query, { currentUser, runtimeContext } ); if (filetype && filetype === 'csv') { - const fields = ['id','name','slug','description','logo_url','favicon_url','og_image_url','theme_config_json','custom_css_json','cdn_base_url','entry_page_slug']; + const fields = ['id','name','slug','description','logo_url','favicon_url','og_image_url','theme_config_json','custom_css_json','cdn_base_url']; const opts = { fields }; try { const csv = parse(payload.rows, opts); diff --git a/backend/src/routes/publish.js b/backend/src/routes/publish.js index 304694d..3355b86 100644 --- a/backend/src/routes/publish.js +++ b/backend/src/routes/publish.js @@ -16,6 +16,42 @@ const publishHandler = wrapAsync(async (req, res) => { router.post('/', publishHandler); router.post('/publish', publishHandler); +/** + * @swagger + * /api/publish/save-to-stage: + * post: + * security: + * - bearerAuth: [] + * tags: [Publish] + * summary: Save dev content to stage + * description: Copies all dev environment content (pages, elements, transitions, audio) to stage environment + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - projectId + * properties: + * projectId: + * type: string + * format: uuid + * responses: + * 200: + * description: Successfully saved to stage + * 400: + * description: Invalid request or publish already in progress + */ +router.post( + '/save-to-stage', + wrapAsync(async (req, res) => { + const { projectId } = req.body; + const result = await PublishService.saveToStage(projectId, req.currentUser); + res.status(200).json(result); + }), +); + router.use('/', require('../helpers').commonErrorHandler); module.exports = router; diff --git a/backend/src/routes/transitions.js b/backend/src/routes/transitions.js deleted file mode 100644 index ecd213a..0000000 --- a/backend/src/routes/transitions.js +++ /dev/null @@ -1,147 +0,0 @@ -const TransitionsService = require('../services/transitions'); -const TransitionsDBApi = require('../db/api/transitions'); -const { createEntityRouter } = require('../factories/router.factory'); - -/** - * @swagger - * components: - * schemas: - * Transitions: - * type: object - * properties: - * source_key: - * type: string - * name: - * type: string - * slug: - * type: string - * video_url: - * type: string - * audio_url: - * type: string - * duration_sec: - * type: number - */ - -/** - * @swagger - * tags: - * name: Transitions - * description: The Transitions managing API - */ - -/** - * @swagger - * /api/transitions: - * post: - * security: - * - bearerAuth: [] - * tags: [Transitions] - * summary: Add new item - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * type: object - * $ref: "#/components/schemas/Transitions" - * responses: - * 200: - * description: The item was successfully added - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 500: - * description: Some server error - * get: - * security: - * - bearerAuth: [] - * tags: [Transitions] - * summary: Get all transitions - * responses: - * 200: - * description: Transitions list successfully received - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 500: - * description: Some server error - */ - -/** - * @swagger - * /api/transitions/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Transitions] - * summary: Update the selected item - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * type: string - * data: - * type: object - * $ref: "#/components/schemas/Transitions" - * responses: - * 200: - * description: The item was successfully updated - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - * delete: - * security: - * - bearerAuth: [] - * tags: [Transitions] - * summary: Delete the selected item - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - * get: - * security: - * - bearerAuth: [] - * tags: [Transitions] - * summary: Get selected item - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ - -module.exports = createEntityRouter('transitions', TransitionsService, TransitionsDBApi); diff --git a/backend/src/routes/ui_elements.js b/backend/src/routes/ui_elements.js deleted file mode 100644 index 318f99a..0000000 --- a/backend/src/routes/ui_elements.js +++ /dev/null @@ -1,7 +0,0 @@ -const Ui_elementsService = require('../services/ui_elements'); -const Ui_elementsDBApi = require('../db/api/ui_elements'); -const { createEntityRouter } = require('../factories/router.factory'); - -module.exports = createEntityRouter('ui_elements', Ui_elementsService, Ui_elementsDBApi, { - permissionEntity: 'page_elements', -}); diff --git a/backend/src/services/element_type_defaults.js b/backend/src/services/element_type_defaults.js new file mode 100644 index 0000000..2429716 --- /dev/null +++ b/backend/src/services/element_type_defaults.js @@ -0,0 +1,6 @@ +const Element_type_defaultsDBApi = require('../db/api/element_type_defaults'); +const { createEntityService } = require('../factories/service.factory'); + +module.exports = createEntityService(Element_type_defaultsDBApi, { + entityName: 'element_type_defaults', +}); diff --git a/backend/src/services/page_elements.js b/backend/src/services/page_elements.js deleted file mode 100644 index a28b445..0000000 --- a/backend/src/services/page_elements.js +++ /dev/null @@ -1,6 +0,0 @@ -const Page_elementsDBApi = require('../db/api/page_elements'); -const { createEntityService } = require('../factories/service.factory'); - -module.exports = createEntityService(Page_elementsDBApi, { - entityName: 'page_elements', -}); diff --git a/backend/src/services/page_links.js b/backend/src/services/page_links.js deleted file mode 100644 index 549836a..0000000 --- a/backend/src/services/page_links.js +++ /dev/null @@ -1,6 +0,0 @@ -const Page_linksDBApi = require('../db/api/page_links'); -const { createEntityService } = require('../factories/service.factory'); - -module.exports = createEntityService(Page_linksDBApi, { - entityName: 'page_links', -}); diff --git a/backend/src/services/project_element_defaults.js b/backend/src/services/project_element_defaults.js new file mode 100644 index 0000000..c67d03c --- /dev/null +++ b/backend/src/services/project_element_defaults.js @@ -0,0 +1,31 @@ +const Project_element_defaultsDBApi = require('../db/api/project_element_defaults'); +const { createEntityService } = require('../factories/service.factory'); + +const BaseService = createEntityService(Project_element_defaultsDBApi, { + entityName: 'project_element_defaults', +}); + +class Project_element_defaultsService extends BaseService { + /** + * Reset a project element default to the current global default + */ + static async resetToGlobal(id, options = {}) { + return Project_element_defaultsDBApi.resetToGlobal(id, options); + } + + /** + * Get diff between project default and current global default + */ + static async getDiffFromGlobal(id) { + return Project_element_defaultsDBApi.getDiffFromGlobal(id); + } + + /** + * Snapshot all global element defaults to a project + */ + static async snapshotGlobalDefaults(projectId, options = {}) { + return Project_element_defaultsDBApi.snapshotGlobalDefaults(projectId, options); + } +} + +module.exports = Project_element_defaultsService; diff --git a/backend/src/services/projects.js b/backend/src/services/projects.js index eeb983a..c5efc57 100644 --- a/backend/src/services/projects.js +++ b/backend/src/services/projects.js @@ -98,14 +98,12 @@ module.exports = class ProjectsService { name: `${sourceProject.name} (Copy)`, slug: uniqueSlug, description: sourceProject.description, - phase: 'dev', logo_url: sourceProject.logo_url, favicon_url: sourceProject.favicon_url, og_image_url: sourceProject.og_image_url, theme_config_json: sourceProject.theme_config_json, custom_css_json: sourceProject.custom_css_json, cdn_base_url: sourceProject.cdn_base_url, - entry_page_slug: sourceProject.entry_page_slug, }, { currentUser, diff --git a/backend/src/services/publish.js b/backend/src/services/publish.js index 6a140a8..1c8139e 100644 --- a/backend/src/services/publish.js +++ b/backend/src/services/publish.js @@ -1,7 +1,5 @@ const db = require('../db/models'); -const { Op } = db.Sequelize; - const EVENT_STATUS = { QUEUED: 'queued', RUNNING: 'running', @@ -10,6 +8,7 @@ const EVENT_STATUS = { }; const ENVIRONMENT = { + DEV: 'dev', STAGE: 'stage', PRODUCTION: 'production', }; @@ -22,6 +21,16 @@ const sanitizeRecordForClone = (modelInstance) => { delete data.deletedAt; delete data.deletedBy; delete data.importHash; + + // 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; }; @@ -112,7 +121,6 @@ module.exports = class PublishService { status: EVENT_STATUS.SUCCESS, finished_at: new Date(), pages_copied: summary.pages_copied, - transitions_copied: summary.transitions_copied, audios_copied: summary.audios_copied, error_message: null, updatedById: currentUser?.id || null, @@ -134,208 +142,167 @@ module.exports = class PublishService { } } - static async copyStageToProduction(projectId, currentUser, transaction) { - const [stagePages, stageTransitions, stageAudioTracks] = await Promise.all([ - db.tour_pages.findAll({ - where: { projectId, environment: ENVIRONMENT.STAGE }, - transaction, - }), - db.transitions.findAll({ - where: { projectId, environment: ENVIRONMENT.STAGE }, - transaction, - }), - db.project_audio_tracks.findAll({ - where: { projectId, environment: ENVIRONMENT.STAGE }, - transaction, - }), - ]); + /** + * Save dev content to stage environment + * This is the first step in the publishing workflow: dev -> stage -> production + */ + static async saveToStage(projectId, currentUser) { + if (!projectId) { + const error = new Error('projectId is required'); + error.code = 400; + throw error; + } - const stagePageIds = stagePages.map((page) => page.id); - const stageTransitionIds = stageTransitions.map((transition) => transition.id); + const publishEvent = await db.publish_events.create({ + projectId, + userId: currentUser?.id || null, + title: 'Save to Stage', + description: 'Copy dev content to stage environment', + from_environment: ENVIRONMENT.DEV, + to_environment: ENVIRONMENT.STAGE, + status: EVENT_STATUS.QUEUED, + createdById: currentUser?.id || null, + updatedById: currentUser?.id || null, + }); - const [stageElements, stageLinks] = await Promise.all([ - stagePageIds.length - ? db.page_elements.findAll({ - where: { pageId: { [Op.in]: stagePageIds } }, - transaction, - }) - : [], - stagePageIds.length || stageTransitionIds.length - ? db.page_links.findAll({ - where: { - [Op.or]: [ - stagePageIds.length ? { from_pageId: { [Op.in]: stagePageIds } } : null, - stagePageIds.length ? { to_pageId: { [Op.in]: stagePageIds } } : null, - stageTransitionIds.length ? { transitionId: { [Op.in]: stageTransitionIds } } : null, - ].filter(Boolean), - }, - transaction, - }) - : [], - ]); - - const productionPages = await db.tour_pages.findAll({ - where: { projectId, environment: ENVIRONMENT.PRODUCTION }, - attributes: ['id'], - transaction, - }); - const productionTransitions = await db.transitions.findAll({ - where: { projectId, environment: ENVIRONMENT.PRODUCTION }, - attributes: ['id'], - transaction, - }); - - const productionPageIds = productionPages.map((page) => page.id); - const productionTransitionIds = productionTransitions.map((transition) => transition.id); - - if (productionPageIds.length || productionTransitionIds.length) { - await db.page_links.destroy({ - where: { - [Op.or]: [ - productionPageIds.length ? { from_pageId: { [Op.in]: productionPageIds } } : null, - productionPageIds.length ? { to_pageId: { [Op.in]: productionPageIds } } : null, - productionTransitionIds.length ? { transitionId: { [Op.in]: productionTransitionIds } } : null, - ].filter(Boolean), + try { + const summary = await this.withProjectPublishLock(projectId, async (transaction) => { + await publishEvent.update( + { + started_at: new Date(), + status: EVENT_STATUS.RUNNING, + error_message: null, + updatedById: currentUser?.id || null, }, - transaction, - }); - } + { transaction }, + ); - if (productionPageIds.length) { - await db.page_elements.destroy({ - where: { pageId: { [Op.in]: productionPageIds } }, - transaction, - }); - } - - await Promise.all([ - db.tour_pages.destroy({ - where: { projectId, environment: ENVIRONMENT.PRODUCTION }, - transaction, - }), - db.transitions.destroy({ - where: { projectId, environment: ENVIRONMENT.PRODUCTION }, - transaction, - }), - db.project_audio_tracks.destroy({ - where: { projectId, environment: ENVIRONMENT.PRODUCTION }, - transaction, - }), - ]); - - const actorId = currentUser?.id || null; - - const productionPagesPayload = stagePages.map((stagePage) => { - const data = sanitizeRecordForClone(stagePage); - return { - ...data, - projectId, - environment: ENVIRONMENT.PRODUCTION, - source_key: stagePage.id, - createdById: actorId, - updatedById: actorId, - }; + return this.copyDevToStage(projectId, currentUser, transaction); }); - const productionTransitionsPayload = stageTransitions.map((stageTransition) => { - const data = sanitizeRecordForClone(stageTransition); - return { - ...data, - projectId, - environment: ENVIRONMENT.PRODUCTION, - source_key: stageTransition.id, - createdById: actorId, - updatedById: actorId, - }; + await publishEvent.update({ + status: EVENT_STATUS.SUCCESS, + finished_at: new Date(), + pages_copied: summary.pages_copied, + audios_copied: summary.audios_copied, + error_message: null, + updatedById: currentUser?.id || null, }); - const productionAudioPayload = stageAudioTracks.map((stageAudio) => { - const data = sanitizeRecordForClone(stageAudio); - return { - ...data, - projectId, - environment: ENVIRONMENT.PRODUCTION, - source_key: stageAudio.id, - createdById: actorId, - updatedById: actorId, - }; - }); - - const [createdPages, createdTransitions] = await Promise.all([ - productionPagesPayload.length - ? db.tour_pages.bulkCreate(productionPagesPayload, { transaction, returning: true }) - : [], - productionTransitionsPayload.length - ? db.transitions.bulkCreate(productionTransitionsPayload, { transaction, returning: true }) - : [], - ]); - - if (productionAudioPayload.length) { - await db.project_audio_tracks.bulkCreate(productionAudioPayload, { - transaction, - returning: false, - }); - } - - const pageMap = new Map(createdPages.map((page) => [page.source_key, page.id])); - const transitionMap = new Map(createdTransitions.map((transition) => [transition.source_key, transition.id])); - - const productionElementsPayload = stageElements.reduce((acc, stageElement) => { - const mappedPageId = pageMap.get(stageElement.pageId); - if (!mappedPageId) { - return acc; - } - - const data = sanitizeRecordForClone(stageElement); - acc.push({ - ...data, - pageId: mappedPageId, - createdById: actorId, - updatedById: actorId, - }); - return acc; - }, []); - - if (productionElementsPayload.length) { - await db.page_elements.bulkCreate(productionElementsPayload, { - transaction, - returning: false, - }); - } - - const productionLinksPayload = stageLinks.reduce((acc, stageLink) => { - const mappedFromPageId = stageLink.from_pageId ? pageMap.get(stageLink.from_pageId) : null; - if (stageLink.from_pageId && !mappedFromPageId) { - return acc; - } - - const data = sanitizeRecordForClone(stageLink); - acc.push({ - ...data, - from_pageId: mappedFromPageId || null, - to_pageId: stageLink.to_pageId ? pageMap.get(stageLink.to_pageId) || null : null, - transitionId: stageLink.transitionId - ? transitionMap.get(stageLink.transitionId) || null - : null, - createdById: actorId, - updatedById: actorId, - }); - return acc; - }, []); - - if (productionLinksPayload.length) { - await db.page_links.bulkCreate(productionLinksPayload, { - transaction, - returning: false, - }); - } - return { - pages_copied: stagePages.length, - transitions_copied: stageTransitions.length, - audios_copied: stageAudioTracks.length, - elements_copied: productionElementsPayload.length, - links_copied: productionLinksPayload.length, + success: true, + publishEventId: publishEvent.id, + summary, }; + } catch (error) { + await publishEvent.update({ + status: EVENT_STATUS.FAILED, + finished_at: new Date(), + error_message: error.message, + updatedById: currentUser?.id || null, + }); + throw error; + } + } + + /** + * Copy dev content to stage environment + */ + static async copyDevToStage(projectId, currentUser, transaction) { + return this.copyEnvironment(projectId, ENVIRONMENT.DEV, ENVIRONMENT.STAGE, currentUser, transaction); + } + + static async copyStageToProduction(projectId, currentUser, transaction) { + return this.copyEnvironment(projectId, ENVIRONMENT.STAGE, ENVIRONMENT.PRODUCTION, currentUser, transaction); + } + + /** + * Generic method to copy content from one environment to another. + * Used for both dev->stage and stage->production flows. + * + * SIMPLIFIED: Now uses targetPageSlug for navigation which is consistent across environments. + * No ID remapping needed since slugs are the same in all environments. + * + * @param {string} projectId - Project ID + * @param {string} fromEnv - Source environment (dev, stage) + * @param {string} toEnv - Target environment (stage, production) + * @param {object} currentUser - Current user for audit fields + * @param {object} transaction - Sequelize transaction + * @returns {object} Summary of copied items + */ + static async copyEnvironment(projectId, fromEnv, toEnv, currentUser, transaction) { + // Get source content + const [sourcePages, sourceAudioTracks] = await Promise.all([ + db.tour_pages.findAll({ + where: { projectId, environment: fromEnv }, + transaction, + }), + db.project_audio_tracks.findAll({ + where: { projectId, environment: fromEnv }, + transaction, + }), + ]); + + // Clean up target environment (hard delete - paranoid models need force: true) + 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, + }), + ]); + + const actorId = currentUser?.id || null; + + // Create target pages - ui_schema_json uses targetPageSlug which is consistent across environments + const targetPagesPayload = sourcePages.map((sourcePage) => { + const data = sanitizeRecordForClone(sourcePage); + return { + ...data, + projectId, + environment: toEnv, + source_key: sourcePage.id, + createdById: actorId, + updatedById: actorId, + }; + }); + + // Create target audio tracks + const targetAudioPayload = sourceAudioTracks.map((sourceAudio) => { + const data = sanitizeRecordForClone(sourceAudio); + return { + ...data, + projectId, + environment: toEnv, + source_key: sourceAudio.id, + createdById: actorId, + updatedById: actorId, + }; + }); + + // Bulk create pages and audio tracks + if (targetPagesPayload.length) { + await db.tour_pages.bulkCreate(targetPagesPayload, { + transaction, + returning: false, + }); + } + + if (targetAudioPayload.length) { + await db.project_audio_tracks.bulkCreate(targetAudioPayload, { + transaction, + returning: false, + }); + } + + return { + pages_copied: sourcePages.length, + audios_copied: sourceAudioTracks.length, + }; } }; diff --git a/backend/src/services/pwa_manifest.js b/backend/src/services/pwa_manifest.js index 4b20cf2..2f7b0a1 100644 --- a/backend/src/services/pwa_manifest.js +++ b/backend/src/services/pwa_manifest.js @@ -2,13 +2,15 @@ * PWA Manifest Service * * Generates offline manifests for PWA asset downloads. + * + * SIMPLIFIED: Elements are now stored in ui_schema_json within tour_pages, + * and transitions are stored as transitionVideoUrl in element content. + * No need to query separate page_elements or transitions tables. */ const AssetsDBApi = require('../db/api/assets'); const AssetVariantsDBApi = require('../db/api/asset_variants'); const TourPagesDBApi = require('../db/api/tour_pages'); -const PageElementsDBApi = require('../db/api/page_elements'); -const TransitionsDBApi = require('../db/api/transitions'); /** * Get asset type from MIME type or filename @@ -93,21 +95,13 @@ class PWAManifestService { */ static async generateManifest(projectId, deviceType = 'desktop') { // Fetch all project data - const [assetsResult, pagesResult, elementsResult, transitionsResult] = - await Promise.all([ - AssetsDBApi.findAll({ project: projectId }, {}), - TourPagesDBApi.findAll({ project: projectId }, {}), - PageElementsDBApi.findAll({}, {}), // Filter by page IDs after - TransitionsDBApi.findAll({}, {}), - ]); + const [assetsResult, pagesResult] = await Promise.all([ + AssetsDBApi.findAll({ project: projectId }, {}), + TourPagesDBApi.findAll({ project: projectId }, {}), + ]); const assets = assetsResult?.rows || []; const pages = pagesResult?.rows || []; - const pageIds = pages.map((p) => p.id); - const elements = (elementsResult?.rows || []).filter((e) => - pageIds.includes(e.page || e.pageId) - ); - const transitions = transitionsResult?.rows || []; // Build asset manifest entries const manifestAssets = []; @@ -173,7 +167,7 @@ class PWAManifestService { } } - // Add page background images/videos + // Add page background images/videos and extract element URLs from ui_schema_json for (const page of pages) { if (page.background_image_url) { addAsset( @@ -199,40 +193,39 @@ class PWAManifestService { [page.id] ); } - } - // Add transition videos - for (const transition of transitions) { - if (transition.video_url) { - addAsset( - `transition-${transition.id}`, - transition.video_url, - `transition-${transition.slug || transition.id}.mp4`, - 'original', - 'transition', - 'video/mp4', - 0, - [] - ); - } - } + // Extract URLs from ui_schema_json elements + try { + const uiSchema = + typeof page.ui_schema_json === 'string' + ? JSON.parse(page.ui_schema_json) + : page.ui_schema_json; - // Extract URLs from element content - for (const element of elements) { - const contentUrls = extractUrlsFromContent(element.content_json); - for (const { url, fieldType } of contentUrls) { - const assetType = - fieldType.includes('video') ? 'video' : fieldType.includes('audio') ? 'audio' : 'image'; - addAsset( - `element-${element.id}-${fieldType}`, - url, - url.split('/').pop() || 'unknown', - 'original', - assetType, - null, - 0, - [element.page || element.pageId] - ); + const elements = Array.isArray(uiSchema?.elements) ? uiSchema.elements : []; + + for (const element of elements) { + const contentUrls = extractUrlsFromContent(element); + for (const { url, fieldType } of contentUrls) { + const assetType = + fieldType.includes('video') || fieldType.includes('transition') + ? 'video' + : fieldType.includes('audio') + ? 'audio' + : 'image'; + addAsset( + `element-${page.id}-${element.id || fieldType}`, + url, + url.split('/').pop() || 'unknown', + 'original', + assetType, + null, + 0, + [page.id] + ); + } + } + } catch { + // Skip pages with invalid ui_schema_json } } diff --git a/backend/src/services/search.js b/backend/src/services/search.js index cdd748f..c076374 100644 --- a/backend/src/services/search.js +++ b/backend/src/services/search.js @@ -72,27 +72,25 @@ module.exports = class SearchService { "projects": [ - + "name", - + "slug", - + "description", - + "logo_url", - + "favicon_url", - + "og_image_url", - + "theme_config_json", - + "custom_css_json", - + "cdn_base_url", - - "entry_page_slug", - + ], @@ -172,54 +170,7 @@ module.exports = class SearchService { - - "page_elements": [ - - "name", - - "style_json", - - "content_json", - - ], - - - - - - "page_links": [ - - "external_url", - - "trigger_selector", - - ], - - - - - - - "transitions": [ - - "source_key", - - "name", - - "slug", - - "video_url", - - "audio_url", - - ], - - - - - - "project_audio_tracks": [ "source_key", @@ -341,43 +292,7 @@ module.exports = class SearchService { ], - - - - "page_elements": [ - - "sort_order", - - "x_percent", - - "y_percent", - - "width_percent", - - "height_percent", - - "rotation_deg", - - ], - - - - - - - - - - "transitions": [ - - "duration_sec", - - ], - - - - - + "project_audio_tracks": [ "volume", diff --git a/backend/src/services/transitions.js b/backend/src/services/transitions.js deleted file mode 100644 index 3d4e0e2..0000000 --- a/backend/src/services/transitions.js +++ /dev/null @@ -1,6 +0,0 @@ -const TransitionsDBApi = require('../db/api/transitions'); -const { createEntityService } = require('../factories/service.factory'); - -module.exports = createEntityService(TransitionsDBApi, { - entityName: 'transitions', -}); diff --git a/backend/src/services/ui_elements.js b/backend/src/services/ui_elements.js deleted file mode 100644 index 614519a..0000000 --- a/backend/src/services/ui_elements.js +++ /dev/null @@ -1,6 +0,0 @@ -const Ui_elementsDBApi = require('../db/api/ui_elements'); -const { createEntityService } = require('../factories/service.factory'); - -module.exports = createEntityService(Ui_elementsDBApi, { - entityName: 'ui_elements', -}); diff --git a/frontend/README.md b/frontend/README.md index fd4e81e..e18b4a1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,92 +1,439 @@ -# Tour Builder Platform +# Tour Builder Platform - Frontend -## This project was generated by Flatlogic Platform. -## Install +Next.js 15 application with React 19, TypeScript, Redux Toolkit, and Tailwind CSS for the Tour Builder Platform. -`cd` to project's dir and run `npm install` +## Tech Stack -### Builds +- **Framework**: Next.js 15 with Turbopack +- **UI Library**: React 19 +- **Language**: TypeScript 5.4 +- **State Management**: Redux Toolkit +- **Styling**: Tailwind CSS 3.4 + MUI 6 +- **Forms**: Formik +- **Data Grid**: MUI X Data Grid v7 +- **Charts**: ApexCharts, Chart.js +- **Drag & Drop**: react-dnd +- **PWA**: Serwist (Service Worker) +- **i18n**: i18next +- **Offline Storage**: Dexie (IndexedDB) -Build are handled by Next.js CLI — [Info](https://nextjs.org/docs/api-reference/cli) +## Prerequisites -### Hot-reloads for development +- Node.js 18+ +- npm or yarn -``` +## Quick Start + +```bash +# Install dependencies +npm install + +# Start development server (port 3000) npm run dev -``` -### Builds and minifies for production - -``` +# Production build npm run build +npm run start ``` -### Exports build for static hosts +The app runs on **port 3000** by default (configurable via `FRONT_PORT` env var). -``` -npm run export +## Available Commands + +```bash +npm run dev # Start dev server with Turbopack +npm run build # Production build +npm run start # Start production server +npm run lint # ESLint check (.ts, .tsx files) +npm run format # Format code with Prettier ``` -### Lint +## Project Structure ``` -npm run lint +frontend/src/ +├── pages/ # Next.js pages (file-based routing) +│ ├── _app.tsx # App wrapper (Redux, i18n, toast providers) +│ ├── index.tsx # Landing/redirect page +│ ├── login.tsx # Login page +│ ├── register.tsx # Registration page +│ ├── dashboard.tsx # Main dashboard +│ ├── constructor.tsx # Tour builder/editor (drag-drop, elements) +│ ├── runtime.tsx # Tour playback viewer +│ ├── search.tsx # Global search results +│ ├── p/[slug].tsx # Public tour pages (PWA-enabled) +│ ├── projects/ # Project CRUD pages +│ ├── tour_pages/ # Tour page management +│ ├── assets/ # Asset library +│ └── ... # Other entity pages +│ +├── components/ # React components (PascalCase) +│ ├── Assets/ # Asset management components +│ ├── Constructor/ # Tour builder components +│ ├── UiElements/ # Element type components +│ ├── Generic/ # Generic CRUD components +│ ├── CardBox.tsx # Card container +│ ├── NavBar.tsx # Top navigation +│ ├── AsideMenu*.tsx # Sidebar menu components +│ ├── FormField.tsx # Form input wrapper +│ ├── DataGridMultiSelect.tsx # Enhanced data grid +│ └── ... # UI components +│ +├── stores/ # Redux Toolkit +│ ├── store.ts # Store configuration +│ ├── hooks.ts # useAppSelector, useAppDispatch +│ ├── authSlice.ts # Authentication state +│ ├── mainSlice.ts # UI state (sidebar, modals) +│ ├── styleSlice.ts # Theme/styling state +│ ├── createEntitySlice.ts # Factory for entity slices +│ └── {entity}/ # Entity-specific slices +│ └── {entity}Slice.ts +│ +├── hooks/ # Custom React hooks +│ ├── useFormSync.ts # Form state synchronization +│ ├── useEntityTable.ts # Data grid with CRUD +│ ├── usePreloadOrchestrator.ts # Asset preloading with S3 presigned URLs +│ ├── usePageSwitch.ts # Page navigation with preloaded blob URLs +│ ├── useNeighborGraph.ts # Page navigation graph (maxDepth: 1) +│ ├── useTransitionPlayback.ts # Video transitions +│ ├── usePageNavigation.ts # Runtime page history +│ ├── useReversePlayback.ts # Reverse video playback +│ ├── useOfflineMode.ts # PWA offline detection +│ └── ... # Other hooks +│ +├── types/ # TypeScript definitions +│ ├── constructor.ts # Tour builder types +│ ├── runtime.ts # Runtime playback types +│ ├── preload.ts # Asset preloading types +│ ├── entities.ts # Entity interfaces +│ ├── permissions.ts # RBAC types +│ └── ... # Other types +│ +├── lib/ # Utility libraries +│ ├── elementStyles.ts # Element styling utilities +│ ├── imagePreDecode.ts # Image pre-decoding +│ ├── mediaDuration.ts # Video/audio duration +│ ├── assetUrl.ts # CDN URL resolution +│ ├── extractPageLinks.ts # Extract navigation links from pages +│ ├── StorageManager.ts # Cache API storage for assets +│ ├── parseJson.ts # Safe JSON parsing +│ ├── logger.ts # Client-side logging +│ ├── offline/ # Offline utilities +│ └── offlineDb/ # IndexedDB (Dexie) setup +│ +├── layouts/ # Page layouts +│ ├── Authenticated.tsx # Logged-in users layout +│ └── Guest.tsx # Public pages layout +│ +├── css/ # Stylesheets +│ └── _theme.css # Tailwind theme overrides +│ +├── config/ # Configuration files +│ └── preload.config.ts # Preload priorities and settings +├── schemas/ # Validation schemas (Zod) +├── factories/ # Component/hook factories +├── context/ # React contexts +├── sw.ts # Service Worker (Serwist) +├── menuAside.ts # Sidebar menu definition +├── menuNavBar.ts # Navbar menu definition +├── colors.ts # Color palette +└── styles.ts # Style constants ``` -### Format with prettier +## Key Pages -``` -npm run format +### Constructor (`/constructor?projectId=`) + +Visual tour builder with: +- Drag-and-drop element positioning +- Canvas-based page editing +- Element property panels +- Page thumbnail navigation +- Live preview +- **Always shows `dev` environment content** +- "Save to Stage" button copies dev → stage + +### Runtime (`/runtime`) + +Tour playback viewer with: +- Full-screen presentation mode +- Video transitions between pages +- Forward/reverse playback +- Background audio +- Keyboard/touch navigation +- Asset preloading with S3 presigned URLs + +### Public Tours (`/p/[slug]`) + +PWA-enabled public tour access: +- **`/p/[slug]`** - Shows `production` environment (published content) +- **`/p/[slug]/stage`** - Shows `stage` environment (preview) +- Offline support via Service Worker +- Asset caching in Cache API and IndexedDB +- Direct S3 downloads via presigned URLs + +## State Management + +### Redux Slices + +| Slice | Purpose | +|-------|---------| +| `authSlice` | User authentication, JWT tokens | +| `mainSlice` | UI state (sidebar, dark mode) | +| `styleSlice` | Theme configuration | +| `{entity}Slice` | Entity CRUD state | + +### Usage Pattern + +```typescript +import { useAppSelector, useAppDispatch } from '../stores/hooks'; +import { fetch, create, update, deleteItem } from '../stores/projects/projectsSlice'; + +const dispatch = useAppDispatch(); +const { rows, loading } = useAppSelector((state) => state.projects); + +// Fetch with pagination +dispatch(fetch({ query: '?limit=100&offset=0' })); + +// Create +dispatch(create({ data: newProject })); ``` -## Support -For any additional information please refer to [Flatlogic homepage](https://flatlogic.com). +## Custom Hooks +| Hook | Purpose | +|------|---------| +| `useFormSync` | Sync form state with Redux | +| `useEntityTable` | Data grid with sorting, filtering, pagination | +| `usePreloadOrchestrator` | Asset preloading with S3 presigned URLs and ready blob URLs | +| `usePageSwitch` | Page navigation using preloaded blob URLs (O(1) instant lookup) | +| `useNeighborGraph` | Build page navigation graph (maxDepth: 1) | +| `useTransitionPlayback` | Video transition playback coordination | +| `usePageNavigation` | Runtime page history management | +| `useReversePlayback` | Reverse video playback | +| `useOfflineMode` | Detect offline/online status | +| `usePWAPreload` | Preload assets for offline | +| `useStorageQuota` | Monitor IndexedDB usage | -## To start the project with Docker: -### Description: +## Element Types -The project contains the **docker folder** and the `Dockerfile`. +The tour builder supports these UI elements: -The `Dockerfile` is used to Deploy the project to Google Cloud. +| Type | Description | +|------|-------------| +| `navigation_next` | Forward navigation button | +| `navigation_prev` | Back navigation button | +| `spot` | Hotspot/clickable area | +| `description` | Text description | +| `tooltip` | Hover tooltip | +| `gallery` | Image gallery | +| `carousel` | Image carousel | +| `logo` | Logo element | +| `video_player` | Video player | +| `audio_player` | Audio player | +| `popup` | Popup/modal | -The **docker folder** contains a couple of helper scripts: +## Content Environments -- `docker-compose.yml` (all our services: web, backend, db are described here) -- `start-backend.sh` (starts backend, but only after the database) -- `wait-for-it.sh` (imported from https://github.com/vishnubob/wait-for-it) +Tour pages have a three-tier environment model: - > To avoid breaking the application, we recommend you don't edit the following files: everything that includes the **docker folder** and `Dokerfile`. +| Environment | Route | Description | +|-------------|-------|-------------| +| `dev` | `/constructor?projectId=` | Editing/draft content | +| `stage` | `/p/[slug]/stage` | Pre-production review | +| `production` | `/p/[slug]` | Published public content | +**Publishing flow:** `dev` → `stage` → `production` +Pages are filtered by environment before display: +```typescript +const filteredPages = pages.filter(p => p.environment === environment); +const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages); +``` -### Run services: +The `X-Runtime-Environment` header tells the backend which environment to query. -1. Install docker compose (https://docs.docker.com/compose/install/) +## Environment Variables -2. Move to `docker` folder. All next steps should be done from this folder. +```env +# API URL (defaults to localhost:8080) +NEXT_PUBLIC_API_URL=http://localhost:8080 - ``` cd docker ``` +# Frontend port (optional, default 3000) +FRONT_PORT=3000 +``` -3. Make executables from `wait-for-it.sh` and `start-backend.sh`: +## API Integration - ``` chmod +x start-backend.sh && chmod +x wait-for-it.sh ``` +API calls are made via Axios to the backend: -4. Download dependend projects for services. +```typescript +// src/pages/api/ contains API route handlers +// Most data fetching goes through Redux thunks -5. Review the docker-compose.yml file. Make sure that all services have Dockerfiles. Only db service doesn't require a Dockerfile. +import axios from 'axios'; -6. Make sure you have needed ports (see them in `ports`) available on your local machine. +const response = await axios.get('/api/projects', { + headers: { Authorization: `Bearer ${token}` } +}); +``` -7. Start services: +## Styling - 7.1. With an empty database `rm -rf data && docker-compose up` +### Tailwind CSS - 7.2. With a stored (from previus runs) database data `docker-compose up` +Primary styling via Tailwind with custom theme in `css/_theme.css`: -8. Check http://localhost:3000 +```css +/* Theme overrides use @apply */ +.btn-primary { + @apply bg-blue-500 hover:bg-blue-600 text-white; +} +``` -9. Stop services: +### MUI Components - 9.1. Just press `Ctr+C` +MUI is used for complex components (Data Grid, dialogs): +```typescript +import { DataGrid } from '@mui/x-data-grid'; +``` + +### Sidebar Styling + +Target `#asideMenu` for sidebar customization: + +```css +#asideMenu { + /* Sidebar styles */ +} +``` + +## PWA & Offline Support + +### Service Worker + +Generated by Serwist from `src/sw.ts`: + +- Precaches static assets +- Runtime caching for API responses +- Background sync for offline changes + +### Asset Preloading (S3 Direct Download) + +Assets are preloaded directly from S3 for better performance: + +```typescript +// 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 +// 3. Create blob URL → Decode image → Store in readyBlobUrlsRef + +// 4. Instant lookup during navigation (O(1)) +const blobUrl = preloadOrchestrator.getReadyBlobUrl(originalUrl); +``` + +**Preload Priority:** +| Type | Priority | Notes | +|------|----------|-------| +| Transition video | +150 | Needed immediately on click | +| Image | +100 | Backgrounds | +| Audio | +50 | Background audio | +| Video | +30 | Can stream progressively | + +### Storage Layers + +| Layer | Size Limit | Purpose | +|-------|------------|---------| +| Cache API | < 5MB | Fast asset storage | +| IndexedDB (Dexie) | ≥ 5MB | Large assets, offline data | + +```typescript +import { offlineDb } from '../lib/offlineDb'; + +// Store assets for offline access +await offlineDb.assets.put({ id, data }); +``` + +## Internationalization + +Uses i18next with browser language detection: + +```typescript +import { useTranslation } from 'react-i18next'; + +const { t } = useTranslation(); +return {t('common.save')}; +``` + +Translation files in `public/locales/{lang}/`. + +## MUI X Data Grid v7 + +Use the new `valueGetter` signature: + +```typescript +// Value transformation +valueGetter: (value) => value?.id ?? value + +// Row access (for computed values) +valueGetter: (_value, row) => new Date(row.created_at) +``` + +## Docker + +The project includes Docker support: + +```bash +cd docker +chmod +x start-backend.sh wait-for-it.sh +docker-compose up +``` + +Access at `http://localhost:3000` + +### Docker Files + +- `Dockerfile` - Production build for cloud deployment +- `docker/docker-compose.yml` - Full stack (frontend, backend, db) +- `docker/start-backend.sh` - Backend startup script +- `docker/wait-for-it.sh` - Service dependency waiter + +## Development Tips + +### Adding a New Entity Page + +1. Create Redux slice in `stores/{entity}/{entity}Slice.ts` +2. Create pages: `{entity}.tsx`, `{entity}/[id].tsx` +3. Add to sidebar menu in `menuAside.ts` +4. Add permissions check in page wrapper + +### Creating Custom Hooks + +Place in `src/hooks/` with `use` prefix: + +```typescript +// src/hooks/useMyHook.ts +export function useMyHook() { + // Hook logic +} + +// Export from index +// src/hooks/index.ts +export * from './useMyHook'; +``` + +### Type Safety + +All entities should have TypeScript interfaces in `src/types/`: + +```typescript +// src/types/entities.ts +export interface Project { + id: string; + name: string; + slug: string; + // ... +} +``` diff --git a/frontend/src/components/Page_elements/CardPage_elements.tsx b/frontend/src/components/Page_elements/CardPage_elements.tsx deleted file mode 100644 index f8d9d1f..0000000 --- a/frontend/src/components/Page_elements/CardPage_elements.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import React from 'react'; -import ImageField from '../ImageField'; -import ListActionsPopover from '../ListActionsPopover'; -import { useAppSelector } from '../../stores/hooks'; -import dataFormatter from '../../helpers/dataFormatter'; -import { Pagination } from '../Pagination'; -import { saveFile } from '../../helpers/fileSaver'; -import LoadingSpinner from '../LoadingSpinner'; -import Link from 'next/link'; - -import { hasPermission } from '../../helpers/userPermissions'; - -type Props = { - page_elements: any[]; - loading: boolean; - onDelete: (id: string) => void; - currentPage: number; - numPages: number; - onPageChange: (page: number) => void; -}; - -const CardPage_elements = ({ - page_elements, - loading, - onDelete, - currentPage, - numPages, - onPageChange, -}: Props) => { - const asideScrollbarsStyle = useAppSelector( - (state) => state.style.asideScrollbarsStyle, - ); - const bgColor = useAppSelector((state) => state.style.cardsColor); - const darkMode = useAppSelector((state) => state.style.darkMode); - const corners = useAppSelector((state) => state.style.corners); - const focusRing = useAppSelector((state) => state.style.focusRingColor); - - const currentUser = useAppSelector((state) => state.auth.currentUser); - const hasUpdatePermission = hasPermission( - currentUser, - 'UPDATE_PAGE_ELEMENTS', - ); - - return ( -
- {loading && } -
    - {!loading && - page_elements.map((item, index) => ( -
  • -
    - - {item.name} - - -
    - -
    -
    -
    -
    -
    Page
    -
    -
    - {dataFormatter.tour_pagesOneListFormatter(item.page)} -
    -
    -
    - -
    -
    - Elementtype -
    -
    -
    - {item.element_type} -
    -
    -
    - -
    -
    Name
    -
    -
    {item.name}
    -
    -
    - -
    -
    - Sortorder -
    -
    -
    - {item.sort_order} -
    -
    -
    - -
    -
    - Isvisible -
    -
    -
    - {dataFormatter.booleanFormatter(item.is_visible)} -
    -
    -
    - -
    -
    X(%)
    -
    -
    - {item.x_percent} -
    -
    -
    - -
    -
    Y(%)
    -
    -
    - {item.y_percent} -
    -
    -
    - -
    -
    - Width(%) -
    -
    -
    - {item.width_percent} -
    -
    -
    - -
    -
    - Height(%) -
    -
    -
    - {item.height_percent} -
    -
    -
    - -
    -
    - Rotation(deg) -
    -
    -
    - {item.rotation_deg} -
    -
    -
    - -
    -
    - StyleJSON -
    -
    -
    - {item.style_json} -
    -
    -
    - -
    -
    - ContentJSON -
    -
    -
    - {item.content_json} -
    -
    -
    -
    -
  • - ))} - {!loading && page_elements.length === 0 && ( -
    -

    No data to display

    -
    - )} -
-
- -
-
- ); -}; - -export default CardPage_elements; diff --git a/frontend/src/components/Page_elements/ListPage_elements.tsx b/frontend/src/components/Page_elements/ListPage_elements.tsx deleted file mode 100644 index 6f8bc11..0000000 --- a/frontend/src/components/Page_elements/ListPage_elements.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import React from 'react'; -import CardBox from '../CardBox'; -import ImageField from '../ImageField'; -import dataFormatter from '../../helpers/dataFormatter'; -import { saveFile } from '../../helpers/fileSaver'; -import ListActionsPopover from '../ListActionsPopover'; -import { useAppSelector } from '../../stores/hooks'; -import { Pagination } from '../Pagination'; -import LoadingSpinner from '../LoadingSpinner'; -import Link from 'next/link'; - -import { hasPermission } from '../../helpers/userPermissions'; - -type Props = { - page_elements: any[]; - loading: boolean; - onDelete: (id: string) => void; - currentPage: number; - numPages: number; - onPageChange: (page: number) => void; -}; - -const ListPage_elements = ({ - page_elements, - loading, - onDelete, - currentPage, - numPages, - onPageChange, -}: Props) => { - const currentUser = useAppSelector((state) => state.auth.currentUser); - const hasUpdatePermission = hasPermission( - currentUser, - 'UPDATE_PAGE_ELEMENTS', - ); - - const corners = useAppSelector((state) => state.style.corners); - const bgColor = useAppSelector((state) => state.style.cardsColor); - - return ( - <> -
- {loading && } - {!loading && - page_elements.map((item) => ( -
- -
- dark:divide-dark-700 overflow-x-auto' - } - > -
-

Page

-

- {dataFormatter.tour_pagesOneListFormatter(item.page)} -

-
- -
-

Elementtype

-

{item.element_type}

-
- -
-

Name

-

{item.name}

-
- -
-

Sortorder

-

{item.sort_order}

-
- -
-

Isvisible

-

- {dataFormatter.booleanFormatter(item.is_visible)} -

-
- -
-

X(%)

-

{item.x_percent}

-
- -
-

Y(%)

-

{item.y_percent}

-
- -
-

Width(%)

-

{item.width_percent}

-
- -
-

Height(%)

-

{item.height_percent}

-
- -
-

- Rotation(deg) -

-

{item.rotation_deg}

-
- -
-

StyleJSON

-

{item.style_json}

-
- -
-

ContentJSON

-

{item.content_json}

-
- - -
-
-
- ))} - {!loading && page_elements.length === 0 && ( -
-

No data to display

-
- )} -
-
- -
- - ); -}; - -export default ListPage_elements; diff --git a/frontend/src/components/Page_elements/TablePage_elements.tsx b/frontend/src/components/Page_elements/TablePage_elements.tsx deleted file mode 100644 index f7326cf..0000000 --- a/frontend/src/components/Page_elements/TablePage_elements.tsx +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Page Elements Table Component - */ - -import React, { useEffect, useState, useMemo } from 'react'; -import { ToastContainer, toast } from 'react-toastify'; -import CardBox from '../CardBox'; -import BaseButton from '../BaseButton'; -import GenericTable from '../Generic/GenericTable'; -import KanbanBoard from '../KanbanBoard/KanbanBoard'; -import { - fetch, - update, - deleteItem, - setRefetch, - deleteItemsByIds, -} from '../../stores/page_elements/page_elementsSlice'; -import { loadColumns } from './configurePage_elementsCols'; -import { useAppSelector } from '../../stores/hooks'; -import { Field, Form, Formik } from 'formik'; -import type { PageElement } from '../../types/entities'; -import type { RootState } from '../../stores/store'; -import type { Filter, FilterItem } from '../../types/filters'; - -interface TablePage_elementsProps { - filterItems: FilterItem[]; - setFilterItems: (items: FilterItem[]) => void; - filters: Filter[]; - showGrid?: boolean; - extraQuery?: string; -} - -const KANBAN_COLUMNS = [ - { id: 'nav_button', label: 'nav_button' }, - { id: 'spot', label: 'spot' }, - { id: 'description', label: 'description' }, - { id: 'tooltip', label: 'tooltip' }, - { id: 'gallery', label: 'gallery' }, - { id: 'carousel', label: 'carousel' }, - { id: 'logo', label: 'logo' }, - { id: 'video_player', label: 'video_player' }, - { id: 'popup', label: 'popup' }, -]; - -const TablePage_elements: React.FC = ({ - filterItems, - setFilterItems, - filters, - showGrid = false, - extraQuery = '', -}) => { - const [kanbanFilters, setKanbanFilters] = useState(''); - const focusRing = useAppSelector((state) => state.style.focusRingColor); - const bgColor = useAppSelector((state) => state.style.bgLayoutColor); - const corners = useAppSelector((state) => state.style.corners); - - const controlClasses = - 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + - ` ${bgColor} ${focusRing} ${corners} ` + - 'dark:bg-slate-800 border'; - - const generateFilterRequests = useMemo(() => { - let request = '&'; - filterItems.forEach((item) => { - const isRangeFilter = filters.find( - (filter) => - filter.title === item.fields.selectedField && - (filter.number || filter.date), - ); - if (isRangeFilter) { - const from = item.fields.filterValueFrom; - const to = item.fields.filterValueTo; - if (from) request += `${item.fields.selectedField}Range=${from}&`; - if (to) request += `${item.fields.selectedField}Range=${to}&`; - } else { - const value = item.fields.filterValue; - if (value) request += `${item.fields.selectedField}=${value}&`; - } - }); - return request; - }, [filterItems, filters]); - - const handleChange = - (id: string) => - (e: React.ChangeEvent) => { - const value = e.target.value; - const name = e.target.name; - setFilterItems( - filterItems.map((item) => { - if (item.id !== id) return item; - if (name === 'selectedField') - return { - id, - fields: { - selectedField: value, - filterValue: '', - filterValueFrom: '', - filterValueTo: '', - }, - }; - return { id, fields: { ...item.fields, [name]: value } }; - }), - ); - }; - - const deleteFilter = (value: string) => { - const newItems = filterItems.filter((item) => item.id !== value); - if (!newItems.length) { - setKanbanFilters(''); - } - setFilterItems(newItems); - }; - - const handleSubmit = () => { - setKanbanFilters(generateFilterRequests); - }; - - const handleReset = () => { - setFilterItems([]); - setKanbanFilters(''); - }; - - // Show grid view using GenericTable - if (showGrid) { - return ( - - entityName='page_elements' - sliceSelector={(state: RootState) => state.page_elements} - fetchAction={fetch} - updateAction={update} - deleteAction={deleteItem} - deleteByIdsAction={deleteItemsByIds} - setRefetchAction={setRefetch} - loadColumnsFunction={loadColumns} - filters={filters} - filterItems={filterItems} - setFilterItems={setFilterItems} - extraQuery={extraQuery} - /> - ); - } - - // Show kanban view with custom filter handling - return ( - <> - {filterItems && Array.isArray(filterItems) && filterItems.length > 0 && ( - - null} - > -
- {filterItems.map((filterItem) => ( -
-
-
Filter
- - {filters.map((selectOption) => ( - - ))} - -
- {filters.find( - (f) => f.title === filterItem?.fields?.selectedField, - )?.type === 'enum' ? ( -
-
Value
- - - {filters - .find( - (f) => - f.title === filterItem?.fields?.selectedField, - ) - ?.options?.map((option) => ( - - ))} - -
- ) : ( -
-
Contains
- -
- )} -
-
Action
- deleteFilter(filterItem.id)} - /> -
-
- ))} -
- - -
-
-
-
- )} - - - - ); -}; - -export default TablePage_elements; diff --git a/frontend/src/components/Page_elements/configurePage_elementsCols.tsx b/frontend/src/components/Page_elements/configurePage_elementsCols.tsx deleted file mode 100644 index 4709851..0000000 --- a/frontend/src/components/Page_elements/configurePage_elementsCols.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React from 'react'; -import axios from 'axios'; -import { GridColDef, GridRowParams } from '@mui/x-data-grid'; -import ListActionsPopover from '../ListActionsPopover'; - -import { hasPermission } from '../../helpers/userPermissions'; -import { logger } from '../../lib/logger'; - -type Params = (id: string) => void; - -export const loadColumns = async ( - onDelete: Params, - entityName: string, - - user: unknown, -): Promise => { - async function callOptionsApi(entityName: string) { - if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; - - try { - const data = await axios(`/${entityName}/autocomplete?limit=100`); - return data.data; - } catch (error) { - logger.error( - 'Failed to fetch options', - error instanceof Error ? error : { error }, - ); - return []; - } - } - - const hasUpdatePermission = hasPermission(user, 'UPDATE_PAGE_ELEMENTS'); - - return [ - { - field: 'page', - headerName: 'Page', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - sortable: false, - type: 'singleSelect' as const, - getOptionValue: (value: any) => value?.id, - getOptionLabel: (value: any) => value?.label, - valueOptions: await callOptionsApi('tour_pages'), - valueGetter: (value: { id?: string } | string | null) => - (typeof value === 'object' && value !== null ? value?.id : value) ?? - value, - }, - - { - field: 'element_type', - headerName: 'Elementtype', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - }, - - { - field: 'name', - headerName: 'Name', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - }, - - { - field: 'sort_order', - headerName: 'Sortorder', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - type: 'number', - }, - - { - field: 'is_visible', - headerName: 'Isvisible', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - type: 'boolean', - }, - - { - field: 'x_percent', - headerName: 'X(%)', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - type: 'number', - }, - - { - field: 'y_percent', - headerName: 'Y(%)', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - type: 'number', - }, - - { - field: 'width_percent', - headerName: 'Width(%)', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - type: 'number', - }, - - { - field: 'height_percent', - headerName: 'Height(%)', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - type: 'number', - }, - - { - field: 'rotation_deg', - headerName: 'Rotation(deg)', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - type: 'number', - }, - - { - field: 'style_json', - headerName: 'StyleJSON', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - }, - - { - field: 'content_json', - headerName: 'ContentJSON', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - }, - - { - field: 'actions', - type: 'actions' as const, - minWidth: 30, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - getActions: (params: GridRowParams) => { - return [ -
- -
, - ]; - }, - }, - ]; -}; diff --git a/frontend/src/components/Page_links/CardPage_links.tsx b/frontend/src/components/Page_links/CardPage_links.tsx deleted file mode 100644 index 58f0d5d..0000000 --- a/frontend/src/components/Page_links/CardPage_links.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import React from 'react'; -import ImageField from '../ImageField'; -import ListActionsPopover from '../ListActionsPopover'; -import { useAppSelector } from '../../stores/hooks'; -import dataFormatter from '../../helpers/dataFormatter'; -import { Pagination } from '../Pagination'; -import { saveFile } from '../../helpers/fileSaver'; -import LoadingSpinner from '../LoadingSpinner'; -import Link from 'next/link'; - -import { hasPermission } from '../../helpers/userPermissions'; - -type Props = { - page_links: any[]; - loading: boolean; - onDelete: (id: string) => void; - currentPage: number; - numPages: number; - onPageChange: (page: number) => void; -}; - -const CardPage_links = ({ - page_links, - loading, - onDelete, - currentPage, - numPages, - onPageChange, -}: Props) => { - const asideScrollbarsStyle = useAppSelector( - (state) => state.style.asideScrollbarsStyle, - ); - const bgColor = useAppSelector((state) => state.style.cardsColor); - const darkMode = useAppSelector((state) => state.style.darkMode); - const corners = useAppSelector((state) => state.style.corners); - const focusRing = useAppSelector((state) => state.style.focusRingColor); - - const currentUser = useAppSelector((state) => state.auth.currentUser); - const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PAGE_LINKS'); - - return ( -
- {loading && } -
    - {!loading && - page_links.map((item, index) => ( -
  • -
    - - {item.direction} - - -
    - -
    -
    -
    -
    -
    - Frompage -
    -
    -
    - {dataFormatter.tour_pagesOneListFormatter(item.from_page)} -
    -
    -
    - -
    -
    - Topage -
    -
    -
    - {dataFormatter.tour_pagesOneListFormatter(item.to_page)} -
    -
    -
    - -
    -
    - Direction -
    -
    -
    - {item.direction} -
    -
    -
    - -
    -
    - ExternalURL -
    -
    -
    - {item.external_url} -
    -
    -
    - -
    -
    - Transition -
    -
    -
    - {dataFormatter.transitionsOneListFormatter( - item.transition, - )} -
    -
    -
    - -
    -
    - Isactive -
    -
    -
    - {dataFormatter.booleanFormatter(item.is_active)} -
    -
    -
    - -
    -
    - Triggerselector -
    -
    -
    - {item.trigger_selector} -
    -
    -
    -
    -
  • - ))} - {!loading && page_links.length === 0 && ( -
    -

    No data to display

    -
    - )} -
-
- -
-
- ); -}; - -export default CardPage_links; diff --git a/frontend/src/components/Page_links/ListPage_links.tsx b/frontend/src/components/Page_links/ListPage_links.tsx deleted file mode 100644 index f6adf58..0000000 --- a/frontend/src/components/Page_links/ListPage_links.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React from 'react'; -import CardBox from '../CardBox'; -import ImageField from '../ImageField'; -import dataFormatter from '../../helpers/dataFormatter'; -import { saveFile } from '../../helpers/fileSaver'; -import ListActionsPopover from '../ListActionsPopover'; -import { useAppSelector } from '../../stores/hooks'; -import { Pagination } from '../Pagination'; -import LoadingSpinner from '../LoadingSpinner'; -import Link from 'next/link'; - -import { hasPermission } from '../../helpers/userPermissions'; - -type Props = { - page_links: any[]; - loading: boolean; - onDelete: (id: string) => void; - currentPage: number; - numPages: number; - onPageChange: (page: number) => void; -}; - -const ListPage_links = ({ - page_links, - loading, - onDelete, - currentPage, - numPages, - onPageChange, -}: Props) => { - const currentUser = useAppSelector((state) => state.auth.currentUser); - const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PAGE_LINKS'); - - const corners = useAppSelector((state) => state.style.corners); - const bgColor = useAppSelector((state) => state.style.cardsColor); - - return ( - <> -
- {loading && } - {!loading && - page_links.map((item) => ( -
- -
- dark:divide-dark-700 overflow-x-auto' - } - > -
-

Frompage

-

- {dataFormatter.tour_pagesOneListFormatter( - item.from_page, - )} -

-
- -
-

Topage

-

- {dataFormatter.tour_pagesOneListFormatter(item.to_page)} -

-
- -
-

Direction

-

{item.direction}

-
- -
-

ExternalURL

-

{item.external_url}

-
- -
-

Transition

-

- {dataFormatter.transitionsOneListFormatter( - item.transition, - )} -

-
- -
-

Isactive

-

- {dataFormatter.booleanFormatter(item.is_active)} -

-
- -
-

- Triggerselector -

-

{item.trigger_selector}

-
- - -
-
-
- ))} - {!loading && page_links.length === 0 && ( -
-

No data to display

-
- )} -
-
- -
- - ); -}; - -export default ListPage_links; diff --git a/frontend/src/components/Page_links/TablePage_links.tsx b/frontend/src/components/Page_links/TablePage_links.tsx deleted file mode 100644 index 270652a..0000000 --- a/frontend/src/components/Page_links/TablePage_links.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Page Links Table Component - */ - -import React from 'react'; -import GenericTable from '../Generic/GenericTable'; -import { - fetch, - update, - deleteItem, - setRefetch, - deleteItemsByIds, -} from '../../stores/page_links/page_linksSlice'; -import { loadColumns } from './configurePage_linksCols'; -import type { PageLink } from '../../types/entities'; -import type { RootState } from '../../stores/store'; -import type { Filter, FilterItem } from '../../types/filters'; - -interface TablePage_linksProps { - filterItems: FilterItem[]; - setFilterItems: (items: FilterItem[]) => void; - filters: Filter[]; - showGrid?: boolean; -} - -const TablePage_links: React.FC = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - entityName='page_links' - sliceSelector={(state: RootState) => state.page_links} - fetchAction={fetch} - updateAction={update} - deleteAction={deleteItem} - deleteByIdsAction={deleteItemsByIds} - setRefetchAction={setRefetch} - loadColumnsFunction={loadColumns} - filters={filters} - filterItems={filterItems} - setFilterItems={setFilterItems} - /> - ); -}; - -export default TablePage_links; diff --git a/frontend/src/components/Page_links/configurePage_linksCols.tsx b/frontend/src/components/Page_links/configurePage_linksCols.tsx deleted file mode 100644 index 0c942a2..0000000 --- a/frontend/src/components/Page_links/configurePage_linksCols.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import React from 'react'; -import axios from 'axios'; -import { GridColDef, GridRowParams } from '@mui/x-data-grid'; -import ListActionsPopover from '../ListActionsPopover'; - -import { hasPermission } from '../../helpers/userPermissions'; -import { logger } from '../../lib/logger'; - -type Params = (id: string) => void; - -export const loadColumns = async ( - onDelete: Params, - entityName: string, - - user: unknown, -): Promise => { - async function callOptionsApi(entityName: string) { - if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; - - try { - const data = await axios(`/${entityName}/autocomplete?limit=100`); - return data.data; - } catch (error) { - logger.error( - 'Failed to fetch options', - error instanceof Error ? error : { error }, - ); - return []; - } - } - - const hasUpdatePermission = hasPermission(user, 'UPDATE_PAGE_LINKS'); - - return [ - { - field: 'from_page', - headerName: 'Frompage', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - sortable: false, - type: 'singleSelect' as const, - getOptionValue: (value: any) => value?.id, - getOptionLabel: (value: any) => value?.label, - valueOptions: await callOptionsApi('tour_pages'), - valueGetter: (value: { id?: string } | string | null) => - (typeof value === 'object' && value !== null ? value?.id : value) ?? - value, - }, - - { - field: 'to_page', - headerName: 'Topage', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - sortable: false, - type: 'singleSelect' as const, - getOptionValue: (value: any) => value?.id, - getOptionLabel: (value: any) => value?.label, - valueOptions: await callOptionsApi('tour_pages'), - valueGetter: (value: { id?: string } | string | null) => - (typeof value === 'object' && value !== null ? value?.id : value) ?? - value, - }, - - { - field: 'direction', - headerName: 'Direction', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - }, - - { - field: 'external_url', - headerName: 'ExternalURL', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - }, - - { - field: 'transition', - headerName: 'Transition', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - sortable: false, - type: 'singleSelect' as const, - getOptionValue: (value: any) => value?.id, - getOptionLabel: (value: any) => value?.label, - valueOptions: await callOptionsApi('transitions'), - valueGetter: (value: { id?: string } | string | null) => - (typeof value === 'object' && value !== null ? value?.id : value) ?? - value, - }, - - { - field: 'is_active', - headerName: 'Isactive', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - type: 'boolean', - }, - - { - field: 'trigger_selector', - headerName: 'Triggerselector', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - }, - - { - field: 'actions', - type: 'actions' as const, - minWidth: 30, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - getActions: (params: GridRowParams) => { - return [ -
- -
, - ]; - }, - }, - ]; -}; diff --git a/frontend/src/components/Projects/CardProjects.tsx b/frontend/src/components/Projects/CardProjects.tsx index bffc544..b4e9a2e 100644 --- a/frontend/src/components/Projects/CardProjects.tsx +++ b/frontend/src/components/Projects/CardProjects.tsx @@ -99,13 +99,6 @@ const CardProjects = ({ -
-
Phase
-
-
{item.phase}
-
-
-
LogoURL @@ -172,17 +165,6 @@ const CardProjects = ({
-
-
- Entrypageslug -
-
-
- {item.entry_page_slug} -
-
-
-
Isdeleted diff --git a/frontend/src/components/Projects/ListProjects.tsx b/frontend/src/components/Projects/ListProjects.tsx index 2a20bbc..dcf3010 100644 --- a/frontend/src/components/Projects/ListProjects.tsx +++ b/frontend/src/components/Projects/ListProjects.tsx @@ -66,11 +66,6 @@ const ListProjects = ({

{item.description}

-
-

Phase

-

{item.phase}

-
-

LogoURL

{item.logo_url}

@@ -105,13 +100,6 @@ const ListProjects = ({

{item.cdn_base_url}

-
-

- Entrypageslug -

-

{item.entry_page_slug}

-
-

Isdeleted

diff --git a/frontend/src/components/Projects/configureProjectsCols.tsx b/frontend/src/components/Projects/configureProjectsCols.tsx index fa6999e..b4e0a34 100644 --- a/frontend/src/components/Projects/configureProjectsCols.tsx +++ b/frontend/src/components/Projects/configureProjectsCols.tsx @@ -68,18 +68,6 @@ export const loadColumns = async ( editable: hasUpdatePermission, }, - { - field: 'phase', - headerName: 'Phase', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - }, - { field: 'logo_url', headerName: 'LogoURL', @@ -152,18 +140,6 @@ export const loadColumns = async ( editable: hasUpdatePermission, }, - { - field: 'entry_page_slug', - headerName: 'Entrypageslug', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - }, - { field: 'is_deleted', headerName: 'Isdeleted', diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index 6ce8170..ebebe26 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -21,22 +21,15 @@ import BaseButton from './BaseButton'; import CardBox from './CardBox'; import { OfflineToggle } from './Offline/OfflineToggle'; import LayoutGuest from '../layouts/Guest'; -import { getPageTitle, baseURLApi } from '../config'; -import { PRELOAD_CONFIG } from '../config/preload.config'; +import { getPageTitle } from '../config'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; +import { extractPageLinksAndElements } from '../lib/extractPageLinks'; +import { usePageSwitch } from '../hooks/usePageSwitch'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; +import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { logger } from '../lib/logger'; -import { - resolveAssetPlaybackUrl, - markPresignedUrlFailed, - isRelativeStoragePath, -} from '../lib/assetUrl'; import { buildElementStyle } from '../lib/elementStyles'; -import type { - RuntimeProject, - RuntimePage, - RuntimePageLink, -} from '../types/runtime'; +import type { RuntimeProject, RuntimePage } from '../types/runtime'; interface RuntimePresentationProps { projectSlug: string; @@ -46,163 +39,12 @@ interface RuntimePresentationProps { const getRows = (response: any) => Array.isArray(response?.data?.rows) ? response.data.rows : []; -/** - * Check if URL is a presigned S3 URL - */ -const isPresignedUrl = (url: string): boolean => { - return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature='); -}; - -/** - * Build proxy URL from storage key - */ -const buildProxyUrl = (storageKey: string): string => { - const normalizedPath = storageKey.replace(/^\/+/, ''); - return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`; -}; - -/** - * Load and decode a single image with presigned URL fallback - */ -const loadImageWithFallback = ( - url: string, - storageKey?: string, -): Promise => { - return new Promise((resolve) => { - const img = new window.Image(); - - const tryLoad = (srcUrl: string, isRetry = false) => { - img.src = srcUrl; - - const handleSuccess = () => { - if (typeof img.decode === 'function') { - img - .decode() - .then(() => resolve()) - .catch(() => resolve()); - } else { - resolve(); - } - }; - - const handleError = () => { - // If this was a presigned URL and we have a storage key, retry with proxy - if (!isRetry && isPresignedUrl(srcUrl) && storageKey) { - logger.info('Image presigned URL failed, retrying with proxy', { - storageKey: storageKey.slice(-50), - }); - markPresignedUrlFailed(storageKey); - const proxyUrl = buildProxyUrl(storageKey); - tryLoad(proxyUrl, true); - } else { - // Give up and resolve anyway to not block navigation - resolve(); - } - }; - - img.onload = handleSuccess; - img.onerror = handleError; - }; - - tryLoad(url); - }); -}; - -/** - * Wait for all images on a page to be decoded before switching. - * Handles presigned URL failures by retrying with proxy URLs. - */ -const waitForPageImages = async ( - page: RuntimePage | null, - timeoutMs = 3000, -): Promise => { - if (!page) return; - - // Collect image URLs with their original storage keys for fallback - const imageEntries: Array<{ url: string; storageKey?: string }> = []; - - if (page.background_image_url) { - const storageKey = page.background_image_url; - const url = resolveAssetPlaybackUrl(storageKey); - if (url) { - imageEntries.push({ - url, - storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined, - }); - } - } - - try { - const uiSchema = - typeof page.ui_schema_json === 'string' - ? JSON.parse(page.ui_schema_json) - : page.ui_schema_json; - - const pageElements = Array.isArray(uiSchema?.elements) - ? uiSchema.elements - : []; - - const { images: imageFields, nested: nestedFields } = - PRELOAD_CONFIG.assetFields; - - pageElements.forEach((el: Record) => { - // Direct image fields - imageFields.forEach((field) => { - const value = el[field]; - if (typeof value === 'string' && value) { - const url = resolveAssetPlaybackUrl(value); - if (url && !imageEntries.some((e) => e.url === url)) { - imageEntries.push({ - url, - storageKey: isRelativeStoragePath(value) ? value : undefined, - }); - } - } - }); - - // Nested arrays (galleryCards, carouselSlides) - nestedFields.forEach((nestedField) => { - const items = el[nestedField]; - if (Array.isArray(items)) { - items.forEach((item: Record) => { - if (typeof item.imageUrl === 'string' && item.imageUrl) { - const url = resolveAssetPlaybackUrl(item.imageUrl); - if (url && !imageEntries.some((e) => e.url === url)) { - imageEntries.push({ - url, - storageKey: isRelativeStoragePath(item.imageUrl) - ? item.imageUrl - : undefined, - }); - } - } - }); - } - }); - }); - } catch { - // Ignore parse errors - } - - if (imageEntries.length === 0) return; - - const decodePromises = imageEntries.map((entry) => - loadImageWithFallback(entry.url, entry.storageKey), - ); - - await Promise.race([ - Promise.all(decodePromises), - new Promise((resolve) => setTimeout(resolve, timeoutMs)), - ]); -}; - export default function RuntimePresentation({ projectSlug, environment, }: RuntimePresentationProps) { const [project, setProject] = useState(null); const [pages, setPages] = useState([]); - const [pageLinks, setPageLinks] = useState([]); const [selectedPageId, setSelectedPageId] = useState(null); const [pageHistory, setPageHistory] = useState([]); const [transitionPreview, setTransitionPreview] = useState<{ @@ -216,8 +58,10 @@ export default function RuntimePresentation({ const [isBackgroundReady, setIsBackgroundReady] = useState(true); const [pendingTransitionComplete, setPendingTransitionComplete] = useState(false); + const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false); const transitionVideoRef = useRef(null); + const lastInitializedPageIdRef = useRef(null); // API request config with custom headers for project/environment const apiConfig = useMemo( @@ -230,53 +74,21 @@ export default function RuntimePresentation({ [projectSlug, environment], ); - // Transform elements from ui_schema_json for preloading - // (Elements in RuntimePresentation come from page's ui_schema_json, not raw API) - const preloadElements = useMemo(() => { - const result: Array<{ - id: string; - pageId: string; - element_type: string; - content_json: string; - }> = []; - - pages.forEach((page) => { - try { - const uiSchema = - typeof page.ui_schema_json === 'string' - ? JSON.parse(page.ui_schema_json) - : page.ui_schema_json; - - const pageElements = Array.isArray(uiSchema?.elements) - ? uiSchema.elements - : []; - - pageElements.forEach((el: Record) => { - // Build content_json from config fields - const contentObj: Record = {}; - PRELOAD_CONFIG.assetFields.all.forEach((field) => { - if (el[field] !== undefined) { - contentObj[field] = el[field]; - } - }); - PRELOAD_CONFIG.assetFields.nested.forEach((field) => { - if (el[field] !== undefined) { - contentObj[field] = el[field]; - } - }); - - result.push({ - id: String(el.id || `${page.id}-${result.length}`), - pageId: page.id, - element_type: String(el.type || ''), - content_json: JSON.stringify(contentObj), - }); - }); - } catch { - // Ignore parse errors - } - }); - + // Extract page links and preload elements from ui_schema_json + // This enables the neighbor graph to find connected pages for preloading + const { pageLinks, preloadElements } = useMemo(() => { + const result = extractPageLinksAndElements(pages); + if (result.pageLinks.length > 0 || result.preloadElements.length > 0) { + logger.info('[PRELOAD] Extracted page links and elements', { + pageLinksCount: result.pageLinks.length, + preloadElementsCount: result.preloadElements.length, + pageLinks: result.pageLinks.map((link) => ({ + from: link.from_pageId?.slice(-8), + to: link.to_pageId?.slice(-8), + hasTransition: !!link.transition?.video_url, + })), + }); + } return result; }, [pages]); @@ -290,6 +102,17 @@ export default function RuntimePresentation({ enabled: !isLoading && !error, }); + // Initialize page switch hook for smooth background transitions + const pageSwitch = usePageSwitch({ + preloadCache: preloadOrchestrator + ? { + getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, + getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, + preloadedUrls: preloadOrchestrator.preloadedUrls, + } + : undefined, + }); + // Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern) const { isBuffering, phase: transitionPhase } = useTransitionPlayback({ videoRef: transitionVideoRef, @@ -301,20 +124,22 @@ export default function RuntimePresentation({ displayName: 'Transition', } : null, - onComplete: (targetPageId) => { - const video = transitionVideoRef.current; + onComplete: async (targetPageId) => { if (targetPageId) { const targetPage = pages.find((p) => p.id === targetPageId); - waitForPageImages(targetPage || null).then(() => { - // Mark background as not ready - new image will need to load - setIsBackgroundReady(false); + // Mark this page as initialized to prevent redundant effect calls + lastInitializedPageIdRef.current = targetPageId; + // Use shared hook to resolve blob URLs and switch page + await pageSwitch.switchToPage(targetPage, () => { setSelectedPageId(targetPageId); setPageHistory((prev) => [...prev, targetPageId]); - // Signal that transition is complete and waiting for background - setPendingTransitionComplete(true); }); + setIsBackgroundReady(false); + // Signal that transition is complete and waiting for Image onLoad + setPendingTransitionComplete(true); } else { // No target page - clean up and remove overlay + const video = transitionVideoRef.current; video?.removeAttribute('src'); video?.load(); setTransitionPreview(null); @@ -323,16 +148,9 @@ export default function RuntimePresentation({ }, features: { useBlobUrl: true, - preDecodeImages: true, - getTargetPageImages: () => { - if (!transitionPreview?.targetPageId) return []; - const targetPage = pages.find( - (p) => p.id === transitionPreview.targetPageId, - ); - if (!targetPage?.background_image_url) return []; - const url = resolveAssetPlaybackUrl(targetPage.background_image_url); - return url ? [url] : []; - }, + // Don't pre-decode images in the hook - we handle it via overlay: + // Overlay shows last transition frame while new page background loads behind it + preDecodeImages: false, }, preload: { preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(), @@ -367,20 +185,44 @@ export default function RuntimePresentation({ document.removeEventListener('fullscreenchange', handleFullscreenChange); }, []); - // Remove transition overlay when background is ready + // Fade out and remove transition overlay when background is ready useEffect(() => { - if (pendingTransitionComplete && isBackgroundReady) { - const video = transitionVideoRef.current; - requestAnimationFrame(() => { - requestAnimationFrame(() => { - video?.removeAttribute('src'); - video?.load(); - setTransitionPreview(null); - setPendingTransitionComplete(false); - }); - }); + if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) { + // Start fade-out animation + setIsOverlayFadingOut(true); + + // After fade completes (300ms), remove the overlay + const fadeTimer = setTimeout(() => { + const video = transitionVideoRef.current; + video?.removeAttribute('src'); + video?.load(); + setTransitionPreview(null); + setPendingTransitionComplete(false); + setIsOverlayFadingOut(false); + // Clear previous background from shared hook + pageSwitch.clearPreviousBackground(); + }, 300); + + return () => clearTimeout(fadeTimer); } - }, [pendingTransitionComplete, isBackgroundReady]); + }, [pendingTransitionComplete, isBackgroundReady, isOverlayFadingOut, pageSwitch.clearPreviousBackground]); + + // Clear previous background overlay when new background is ready (direct navigation) + useEffect(() => { + if ( + pageSwitch.isSwitching && + pageSwitch.isNewBgReady && + pageSwitch.previousBgImageUrl + ) { + // New background is ready - clear the previous background overlay + pageSwitch.clearPreviousBackground(); + } + }, [ + pageSwitch.isSwitching, + pageSwitch.isNewBgReady, + pageSwitch.previousBgImageUrl, + pageSwitch.clearPreviousBackground, + ]); // Load presentation data useEffect(() => { @@ -411,45 +253,29 @@ export default function RuntimePresentation({ setProject(foundProject); - // Fetch pages and links for this project - // (Elements are extracted from ui_schema_json, transitions are eagerly loaded by API) - const [pagesResponse, pageLinksResponse] = await Promise.all([ - axios.get('/tour_pages', { - ...apiConfig, - params: { project: foundProject.id }, - }), - axios.get('/page_links', { - ...apiConfig, - params: { project: foundProject.id }, - }), - ]); + // Fetch pages for this project + // (Elements and navigation are extracted from ui_schema_json) + const pagesResponse = await axios.get('/tour_pages', { + ...apiConfig, + params: { project: foundProject.id }, + }); if (isCancelled) return; const pageRows = getRows(pagesResponse); - const linkRows = getRows(pageLinksResponse); // Filter by environment and sort by sort_order + // STRICT: Only show pages matching the exact environment + // Production = production only, Stage = stage only, Dev = dev only const envFilteredPages = pageRows - .filter( - (p: any) => - !p.environment || - p.environment === environment || - p.environment === 'dev', - ) + .filter((p: any) => p.environment === environment) .sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); setPages(envFilteredPages); - setPageLinks(linkRows); - // Set initial page + // Set initial page (first page by sort_order) if (envFilteredPages.length > 0) { - const entryPage = foundProject.entry_page_slug - ? envFilteredPages.find( - (p: RuntimePage) => p.slug === foundProject.entry_page_slug, - ) - : null; - const firstPage = entryPage || envFilteredPages[0]; + const firstPage = envFilteredPages[0]; setSelectedPageId(firstPage.id); setPageHistory([firstPage.id]); } @@ -494,6 +320,25 @@ export default function RuntimePresentation({ } }, [selectedPage]); + // Set initial backgrounds when page first loads (before preload cache is populated) + // The condition ensures this only runs once on initial load when backgrounds are empty. + // After that, navigateToPage handles all subsequent navigation explicitly. + useEffect(() => { + if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) { + // Only initialize when backgrounds are empty (initial load) + // navigateToPage handles subsequent navigation by calling switchToPage directly + if (!pageSwitch.currentBgImageUrl && !pageSwitch.currentBgVideoUrl) { + lastInitializedPageIdRef.current = selectedPage.id; + pageSwitch.switchToPage(selectedPage); + } + } + }, [ + selectedPage, + pageSwitch.currentBgImageUrl, + pageSwitch.currentBgVideoUrl, + pageSwitch.switchToPage, + ]); + // Handle background ready state for pages without images or with videos useEffect(() => { // If no background image, or if there's a video (video takes over), mark as ready @@ -515,6 +360,10 @@ export default function RuntimePresentation({ if (!targetPage) return; if (transitionVideoUrl) { + // Reset states from previous transition before starting new one + // This prevents the fade-out effect from re-triggering when isOverlayFadingOut resets + setIsOverlayFadingOut(false); + setPendingTransitionComplete(false); // Play transition using useTransitionPlayback hook setTransitionPreview({ targetPageId, @@ -522,28 +371,62 @@ export default function RuntimePresentation({ isReverse: isBack, }); } else { - // Direct navigation - wait for images first, then switch - await waitForPageImages(targetPage); - // Mark background as loading (Image onLoad will set it back to true) + // Direct navigation - use shared hook for smooth transition + // Previous background stays visible until new one is ready setIsBackgroundReady(false); - setSelectedPageId(targetPageId); - setPageHistory((prev) => [...prev, targetPageId]); + // Mark this page as initialized to prevent redundant effect calls + lastInitializedPageIdRef.current = targetPageId; + + await pageSwitch.switchToPage(targetPage, () => { + setSelectedPageId(targetPageId); + setPageHistory((prev) => [...prev, targetPageId]); + }); } }, - [pages], + [pages, pageSwitch], ); const handleElementClick = useCallback( (element: any) => { - if (element.targetPageId) { + // Disable navigation while transition is actively playing or buffering + // Only block during active phases, not during fade-out (completed phase) + const isActivelyPlaying = transitionPhase === 'preparing' || transitionPhase === 'playing' || transitionPhase === 'reversing'; + if (isActivelyPlaying || isBuffering) { + return; + } + + // Support both targetPageSlug (new) and targetPageId (legacy) + const targetPageSlug = element.targetPageSlug; + const legacyTargetPageId = element.targetPageId; + + // Resolve slug to page ID, or use legacy targetPageId + let targetPageId: string | undefined; + if (targetPageSlug) { + const targetPage = pages.find((p) => p.slug === targetPageSlug); + targetPageId = targetPage?.id; + } else if (legacyTargetPageId) { + targetPageId = legacyTargetPageId; + } + + // Debug: log element navigation data + logger.info('Element clicked', { + elementType: element.type, + targetPageSlug, + legacyTargetPageId, + resolvedTargetPageId: targetPageId, + transitionVideoUrl: element.transitionVideoUrl, + hasTransition: Boolean(element.transitionVideoUrl), + }); + + if (targetPageId) { const isBack = element.navType === 'back' || element.type === 'navigation_prev'; // Get transition video URL from element itself const transitionVideoUrl = element.transitionVideoUrl; - navigateToPage(element.targetPageId, transitionVideoUrl, isBack); + navigateToPage(targetPageId, transitionVideoUrl, isBack); } }, - [navigateToPage], + [navigateToPage, pages, transitionPhase, isBuffering], ); // Render element content based on type @@ -750,12 +633,10 @@ export default function RuntimePresentation({ return null; }; - const backgroundImageUrl = selectedPage?.background_image_url - ? resolveAssetPlaybackUrl(selectedPage.background_image_url) - : ''; - const backgroundVideoUrl = selectedPage?.background_video_url - ? resolveAssetPlaybackUrl(selectedPage.background_video_url) - : ''; + // Use resolved URLs from shared hook (blob URLs if cached, otherwise original URLs) + // Blob URLs render instantly since data is local in memory + const backgroundImageUrl = pageSwitch.currentBgImageUrl; + const backgroundVideoUrl = pageSwitch.currentBgVideoUrl; if (isLoading) { return ( @@ -792,7 +673,8 @@ export default function RuntimePresentation({ backgroundPosition: 'center', }} > - {/* Background image element - ensures proper loading for waitForPageImages() */} + {/* Background image element - CSS backgroundImage provides instant display, + Image component enhances with optimized loading. bg-black prevents white flash. */} {backgroundImageUrl && !backgroundVideoUrl && (

setIsBackgroundReady(true)} - onError={() => setIsBackgroundReady(true)} + onLoad={() => { + setIsBackgroundReady(true); + pageSwitch.markBackgroundReady(); + }} + onError={() => { + setIsBackgroundReady(true); + pageSwitch.markBackgroundReady(); + }} />
)} + {/* Previous background overlay - shows during direct navigation until new bg is ready */} + {pageSwitch.previousBgImageUrl && + pageSwitch.isSwitching && + !pageSwitch.isNewBgReady && ( +
+ )} + {/* Background video */} {backgroundVideoUrl && (