39948-vm/documentation/publishing-workflow.md
2026-07-03 16:11:24 +02:00

997 lines
38 KiB
Markdown

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