changed presentations saving functionality

This commit is contained in:
Dmitri 2026-07-30 11:11:45 +02:00
parent c0863f974c
commit 80fe56c6f9
43 changed files with 936 additions and 294 deletions

View File

@ -1228,7 +1228,9 @@ Publish from stage to production.
"publishEventId": "event-uuid",
"summary": {
"pages_copied": 10,
"audios_copied": 3
"audios_copied": 3,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
}
}
```
@ -1258,15 +1260,24 @@ Copy dev content to stage environment.
"publishEventId": "event-uuid",
"summary": {
"pages_copied": 10,
"audios_copied": 3
"audios_copied": 3,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
}
}
```
The response is returned only after the Dev → Stage transaction commits.
`summary` therefore describes the Stage snapshot available to preview.
**Errors:**
- `400`: Publish already in progress
- `400`: Invalid request or publish already in progress
- `401`: Authentication required
- `403`: Missing `CREATE_PUBLISH_EVENTS`
- `404`: Project not found
- `429`: Rate limit exceeded
- `500`: Copy failed
---

View File

@ -286,7 +286,7 @@ const sampleDataSeeder: SequelizeSeeder = {
| Presigned URL Requests | 3 | Upload/download requests |
| Tour Pages | 3 | Sample tour pages |
| Project Audio Tracks | 3 | Background audio |
| Publish Events | 3 | Deployment history |
| Publish Events | 3 | Valid Dev → Stage and Stage → Production lifecycle examples |
| PWA Caches | 3 | Offline cache configs |
| Access Logs | 3 | Visitor tracking |

View File

@ -323,6 +323,25 @@ Publishing workflow for Dev → Stage → Production.
}
```
**Save to Stage Response:**
```json
{
"success": true,
"publishEventId": "uuid",
"summary": {
"pages_copied": 5,
"audios_copied": 2,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
}
}
```
The route validates the UUID and requires publish-event permissions. It returns
after the Dev → Stage transaction commits; failures are handled by the shared
error middleware and do not produce a success response.
---
### 4. search.ts (64 lines)

View File

@ -430,7 +430,7 @@ module.exports = class PublishService {
// Copy stage content to production (blocking)
static async publishToProduction(projectId, currentUser, title, description)
// Copy dev content to stage (non-blocking, returns immediately)
// Copy dev content to stage (completion-confirmed)
static async saveToStage(projectId, currentUser)
// Generic environment copy
@ -438,11 +438,14 @@ module.exports = class PublishService {
}
```
**Non-Blocking vs Blocking:**
**Completion behavior:**
- `saveToStage()` - Returns immediately, copy runs in background via `setImmediate()`
- `saveToStage()` - Waits for the Dev → Stage transaction and returns its copy summary
- `publishToProduction()` - Waits for entire copy operation before returning
Waiting for Save to Stage prevents a subsequent production publish from
overtaking a queued background copy and publishing the previous Stage snapshot.
**Publishing Flow:**
```

View File

@ -23,10 +23,11 @@ function isRuntimeAssetModel(value: unknown): value is ModelStatic<Model> {
);
}
function getRuntimeAssetModel(value: unknown, name: string): ModelStatic<Model> {
if (
!isRuntimeAssetModel(value)
) {
function getRuntimeAssetModel(
value: unknown,
name: string,
): ModelStatic<Model> {
if (!isRuntimeAssetModel(value)) {
throw new Error(`Database model '${name}' is unavailable.`);
}

View File

@ -457,15 +457,19 @@ const PublishEventsData = [
from_environment: 'dev',
to_environment: 'dev',
to_environment: 'stage',
title: 'Save to Stage',
description: 'Copy dev content to stage environment',
started_at: new Date('2026-03-01T10:00:00Z'),
finished_at: new Date('2026-03-01T10:01:10Z'),
status: 'queued',
status: 'success',
error_message: '',
error_message: null,
pages_copied: 6,
@ -479,24 +483,28 @@ const PublishEventsData = [
// type code here for "relation_one" field
from_environment: 'production',
from_environment: 'stage',
to_environment: 'production',
title: 'Spring Release',
description: 'Publish the reviewed stage snapshot',
started_at: new Date('2026-03-15T18:00:00Z'),
finished_at: new Date('2026-03-15T18:02:40Z'),
status: 'queued',
status: 'failed',
error_message:
'Asset preload list generation failed due to missing CDN URL.',
pages_copied: 6,
pages_copied: 0,
transitions_copied: 2,
transitions_copied: 0,
audios_copied: 1,
audios_copied: 0,
},
{
@ -506,21 +514,25 @@ const PublishEventsData = [
from_environment: 'dev',
to_environment: 'production',
to_environment: 'stage',
title: 'Save to Stage',
description: 'Copy dev content to stage environment',
started_at: new Date('2026-02-05T09:00:00Z'),
finished_at: new Date('2026-02-05T09:01:05Z'),
finished_at: null,
status: 'running',
error_message: '',
error_message: null,
pages_copied: 4,
pages_copied: 0,
transitions_copied: 1,
transitions_copied: 0,
audios_copied: 1,
audios_copied: 0,
},
];

View File

@ -918,12 +918,30 @@ const schemas: Record<string, OpenApiSchema> = {
projectId: uuidSchema,
},
},
PublishSummary: {
type: 'object',
required: [
'pages_copied',
'audios_copied',
'transition_settings_copied',
'ui_control_settings_copied',
],
additionalProperties: false,
properties: {
pages_copied: { type: 'integer', minimum: 0 },
audios_copied: { type: 'integer', minimum: 0 },
transition_settings_copied: { type: 'integer', minimum: 0 },
ui_control_settings_copied: { type: 'integer', minimum: 0 },
},
},
PublishResult: {
type: 'object',
additionalProperties: true,
required: ['success', 'publishEventId', 'summary'],
additionalProperties: false,
properties: {
success: { type: 'boolean' },
event: ref('PublishEvent'),
success: { type: 'boolean', enum: [true] },
publishEventId: uuidSchema,
summary: ref('PublishSummary'),
},
},
SearchRequest: {
@ -1810,6 +1828,8 @@ const customPaths: OpenApiPaths = {
post: {
tags: ['Publish'],
summary: 'Publish staged content to production',
description:
'Waits for the Stage-to-Production transaction to commit, then returns the committed copy summary.',
security: bearerSecurity,
requestBody: jsonRequest(ref('PublishRequest')),
responses: {
@ -1822,6 +1842,8 @@ const customPaths: OpenApiPaths = {
post: {
tags: ['Publish'],
summary: 'Copy dev content to stage',
description:
'Waits for the Dev-to-Stage transaction to commit, then returns the committed copy summary.',
security: bearerSecurity,
requestBody: jsonRequest(ref('SaveToStageRequest')),
responses: {

View File

@ -69,27 +69,32 @@ const presignHandler = async (
const currentUser = getCurrentUser(req);
const runtimeContext = getRuntimeContext(req);
const authorization =
await RuntimeAssetAccessService.authorizePresignRequest({
const authorization = await RuntimeAssetAccessService.authorizePresignRequest(
{
currentUser,
runtimeContext,
urls,
});
},
);
if (authorization === 'authentication_required') {
return res.status(401).json(
services.createErrorResponse(
'Authentication or public presentation context is required',
'PRESIGN_AUTH_REQUIRED',
),
);
return res
.status(401)
.json(
services.createErrorResponse(
'Authentication or public presentation context is required',
'PRESIGN_AUTH_REQUIRED',
),
);
}
if (authorization === 'denied') {
return res.status(403).json(
services.createErrorResponse(
'Asset access denied',
'PRESIGN_ACCESS_DENIED',
),
);
return res
.status(403)
.json(
services.createErrorResponse(
'Asset access denied',
'PRESIGN_ACCESS_DENIED',
),
);
}
// Validate paths for security (no traversal, no protocols)

View File

@ -66,7 +66,7 @@ router.post('/', validateRequest(publishSchemas.publish), publishHandler);
* - bearerAuth: []
* tags: [Publish]
* summary: Save dev content to stage
* description: Copies all dev environment content (pages, elements, transitions, audio) to stage environment
* description: Copies Dev pages (including UI schemas), project audio tracks, transition settings, and UI-control settings to Stage. Returns only after the transaction commits.
* requestBody:
* required: true
* content:
@ -82,8 +82,55 @@ router.post('/', validateRequest(publishSchemas.publish), publishHandler);
* responses:
* 200:
* description: Successfully saved to stage
* content:
* application/json:
* schema:
* type: object
* additionalProperties: false
* required:
* - success
* - publishEventId
* - summary
* properties:
* success:
* type: boolean
* enum: [true]
* publishEventId:
* type: string
* format: uuid
* summary:
* type: object
* additionalProperties: false
* required:
* - pages_copied
* - audios_copied
* - transition_settings_copied
* - ui_control_settings_copied
* properties:
* pages_copied:
* type: integer
* minimum: 0
* audios_copied:
* type: integer
* minimum: 0
* transition_settings_copied:
* type: integer
* minimum: 0
* ui_control_settings_copied:
* type: integer
* minimum: 0
* 400:
* description: Invalid request or publish already in progress
* 401:
* description: Authentication required
* 403:
* description: Insufficient publish-events permission
* 404:
* description: Project not found
* 429:
* description: Rate limit exceeded
* 500:
* description: Stage copy failed
*/
router.post(
'/save-to-stage',

View File

@ -1,12 +1,10 @@
import type { Transaction } from 'sequelize';
import db from '../db/models/index.ts';
import { logger } from '../utils/logger.ts';
import type {
PublishCloneData,
PublishClonePayload,
PublishCloneSource,
PublishEventRecord,
PublishEventStatus,
PublishLockCallback,
PublishServiceCurrentUser,
@ -216,26 +214,6 @@ export default class PublishService {
updatedById: actorId,
});
setImmediate(() => {
this.processSaveToStage(projectId, currentUser, publishEvent).catch(
(error: unknown) => {
logger.error(
{ err: error, projectId, publishEventId: publishEvent.id },
'Save to stage background job failed',
);
},
);
});
return { success: true, publishEventId: publishEvent.id };
}
private static async processSaveToStage(
projectId: string,
currentUser: PublishServiceCurrentUser | undefined,
publishEvent: PublishEventRecord,
): Promise<void> {
const actorId = currentUser?.id || null;
try {
const summary = await this.withProjectPublishLock(
projectId,
@ -259,6 +237,12 @@ export default class PublishService {
audios_copied: summary.audios_copied,
updatedById: actorId,
});
return {
success: true,
publishEventId: publishEvent.id,
summary,
};
} catch (error) {
await publishEvent.update({
status: EVENT_STATUS.FAILED,

View File

@ -4,10 +4,7 @@ import type { AccessPolicyUser, RuntimeContext } from '../types/index.ts';
import { UI_SCHEMA_ASSET_FIELDS } from '../utils/ui-schema-assets.ts';
import AccessPolicy from './access-policy.ts';
type PresignAuthorization =
| 'allowed'
| 'authentication_required'
| 'denied';
type PresignAuthorization = 'allowed' | 'authentication_required' | 'denied';
interface AuthorizePresignRequestOptions {
currentUser: AccessPolicyUser;
@ -76,7 +73,10 @@ function normalizeStorageReference(value: string): string | null {
return stripStoragePrefix(trimmed.split(/[?#]/, 1)[0] ?? trimmed);
}
function collectStringReferences(value: unknown, references: Set<string>): void {
function collectStringReferences(
value: unknown,
references: Set<string>,
): void {
if (typeof value === 'string') {
const normalized = normalizeStorageReference(value);
if (normalized) references.add(normalized);
@ -149,10 +149,7 @@ export default class RuntimeAssetAccessService {
const projectSlug = AccessPolicy.normalizeSlug(
runtimeContext?.headerProjectSlug,
);
if (
runtimeContext?.headerEnvironment !== 'production' ||
!projectSlug
) {
if (runtimeContext?.headerEnvironment !== 'production' || !projectSlug) {
return 'authentication_required';
}
@ -192,11 +189,7 @@ export default class RuntimeAssetAccessService {
collectRecordFields(variants, ['storage_key', 'cdn_url'], references);
collectRecordFields(
pages,
[
'background_image_url',
'background_video_url',
'background_audio_url',
],
['background_image_url', 'background_video_url', 'background_audio_url'],
references,
);
for (const page of pages) {

View File

@ -1072,18 +1072,20 @@ class TourPagesService extends BaseService {
const reversedUrl =
await TourPagesService.getExistingReversedVariant(storageKey);
if (reversedUrl && reversedUrl !== element.reverseVideoUrl) {
element.reverseVideoUrl = reversedUrl;
wasModified = true;
logger.info(
{
elementType: element.type,
isBack,
isForward,
storageKey,
},
'Added existing reversed video URL to element',
);
if (reversedUrl) {
if (reversedUrl !== element.reverseVideoUrl) {
element.reverseVideoUrl = reversedUrl;
wasModified = true;
logger.info(
{
elementType: element.type,
isBack,
isForward,
storageKey,
},
'Added existing reversed video URL to element',
);
}
continue;
}

View File

@ -29,6 +29,7 @@ export interface PublishToProductionResult {
export interface SaveToStageResult {
success: true;
publishEventId: string;
summary: PublishSummary;
}
export type PublishEventStatus = 'queued' | 'running' | 'success' | 'failed';

View File

@ -167,11 +167,7 @@ function toValidatedEnvironment(
DB_NAME: readString(values, 'DB_NAME', 'db_tour_builder_platform'),
DB_USER: readString(values, 'DB_USER', 'postgres'),
DB_PASS: readString(values, 'DB_PASS', ''),
SECRET_KEY: readString(
values,
'SECRET_KEY',
'',
),
SECRET_KEY: readString(values, 'SECRET_KEY', ''),
ADMIN_PASS: readString(values, 'ADMIN_PASS', ''),
USER_PASS: readString(values, 'USER_PASS', ''),
ADMIN_EMAIL: readString(values, 'ADMIN_EMAIL', 'admin@flatlogic.com'),

View File

@ -67,6 +67,7 @@ void test('OpenAPI document exposes comprehensive route coverage', () => {
'/api/runtime-access/me',
'/api/project-ui-control-settings/project/{projectId}/env/{environment}',
'/api/tour_pages/reverse-video-status',
'/api/publish/save-to-stage',
];
assert.equal(document.openapi, '3.0.0');
@ -90,6 +91,49 @@ void test('OpenAPI document resolves all internal refs', () => {
);
});
void test('OpenAPI documents the completion-confirmed publishing result', () => {
const document = createTestDocument();
const publishResult = document.components.schemas.PublishResult;
const publishSummary = document.components.schemas.PublishSummary;
const saveToStageResponse =
document.paths['/api/publish/save-to-stage']?.post?.responses;
assert.ok(publishResult);
assert.ok(publishSummary);
assert.deepEqual(publishResult.required, [
'success',
'publishEventId',
'summary',
]);
assert.deepEqual(publishResult.properties, {
success: { type: 'boolean', enum: [true] },
publishEventId: { type: 'string', format: 'uuid' },
summary: { $ref: '#/components/schemas/PublishSummary' },
});
assert.deepEqual(publishSummary.required, [
'pages_copied',
'audios_copied',
'transition_settings_copied',
'ui_control_settings_copied',
]);
assert.deepEqual(saveToStageResponse, {
200: {
description: 'Save-to-stage result',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/PublishResult' },
},
},
},
400: { $ref: '#/components/responses/BadRequestError' },
401: { $ref: '#/components/responses/UnauthorizedError' },
403: { $ref: '#/components/responses/ForbiddenError' },
404: { $ref: '#/components/responses/NotFoundError' },
429: { $ref: '#/components/responses/RateLimitError' },
500: { $ref: '#/components/responses/ServerError' },
});
});
void test('OpenAPI factory CRUD paths are generated consistently', () => {
const document = createTestDocument();
const resourcePath = '/api/assets';

View File

@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import db from '../src/db/models/index.ts';
import PublishService from '../src/services/publish.ts';
import type { PublishEventRecord, PublishSummary } from '../src/types/index.ts';
void test('saveToStage resolves only after the stage copy completes', async () => {
const summary: PublishSummary = {
pages_copied: 3,
audios_copied: 1,
transition_settings_copied: 1,
ui_control_settings_copied: 1,
};
const updates: Array<Record<string, unknown>> = [];
let releaseCopy: (() => void) | undefined;
const copyGate = new Promise<void>((resolve) => {
releaseCopy = resolve;
});
const publishEvent: PublishEventRecord = {
id: 'publish-event-1',
update(data) {
updates.push(data);
return Promise.resolve();
},
};
const originalCreate: unknown = Reflect.get(db.publish_events, 'create');
const originalWithProjectPublishLock: unknown = Reflect.get(
PublishService,
'withProjectPublishLock',
);
Reflect.set(db.publish_events, 'create', () => Promise.resolve(publishEvent));
Reflect.set(PublishService, 'withProjectPublishLock', () =>
copyGate.then(() => summary),
);
try {
let resolved = false;
const savePromise = PublishService.saveToStage('project-1', undefined).then(
(result) => {
resolved = true;
return result;
},
);
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
assert.equal(resolved, false);
releaseCopy?.();
assert.deepEqual(await savePromise, {
success: true,
publishEventId: 'publish-event-1',
summary,
});
assert.deepEqual(
updates.map((update) => update.status),
['success'],
);
} finally {
Reflect.set(db.publish_events, 'create', originalCreate);
Reflect.set(
PublishService,
'withProjectPublishLock',
originalWithProjectPublishLock,
);
}
});
void test('saveToStage records a failed event and rejects when the copy fails', async () => {
const copyError = new Error('Stage transaction failed');
const updates: Array<Record<string, unknown>> = [];
const publishEvent: PublishEventRecord = {
id: 'publish-event-2',
update(data) {
updates.push(data);
return Promise.resolve();
},
};
const originalCreate: unknown = Reflect.get(db.publish_events, 'create');
const originalWithProjectPublishLock: unknown = Reflect.get(
PublishService,
'withProjectPublishLock',
);
Reflect.set(db.publish_events, 'create', () => Promise.resolve(publishEvent));
Reflect.set(PublishService, 'withProjectPublishLock', () =>
Promise.reject(copyError),
);
try {
await assert.rejects(
PublishService.saveToStage('project-1', undefined),
copyError,
);
assert.equal(updates.length, 1);
assert.equal(updates[0]?.status, 'failed');
assert.equal(updates[0]?.error_message, copyError.message);
assert.ok(updates[0]?.finished_at instanceof Date);
} finally {
Reflect.set(db.publish_events, 'create', originalCreate);
Reflect.set(
PublishService,
'withProjectPublishLock',
originalWithProjectPublishLock,
);
}
});

View File

@ -75,26 +75,28 @@ void test('runtime page schemas collect media fields without treating labels as
});
void test('presigning without staff access or public runtime context requires authentication', async () => {
const authorization =
await RuntimeAssetAccessService.authorizePresignRequest({
const authorization = await RuntimeAssetAccessService.authorizePresignRequest(
{
currentUser: undefined,
runtimeContext: undefined,
urls: ['assets/project/image.webp'],
});
},
);
assert.equal(authorization, 'authentication_required');
});
void test('staff permissions authorize presigning without public runtime context', async () => {
const authorization =
await RuntimeAssetAccessService.authorizePresignRequest({
const authorization = await RuntimeAssetAccessService.authorizePresignRequest(
{
currentUser: {
id: 'staff-user',
app_role_permissions: ['READ_ASSETS'],
},
runtimeContext: undefined,
urls: ['assets/project/image.webp'],
});
},
);
assert.equal(authorization, 'allowed');
});

View File

@ -21,6 +21,7 @@ import type {
RuntimeEnvironment,
TourPageCreateOptions,
TourPageRecord,
TourPageReverseGenerationTask,
TourPageUpdateOptions,
} from '../src/types/index.ts';
@ -268,6 +269,25 @@ function replaceAssetsDbApiFindBy(
};
}
function replaceSingleReverseGenerationEnqueue(
value: (task: TourPageReverseGenerationTask) => void,
): () => void {
const original =
TourPagesService.enqueueSingleReverseGeneration.bind(TourPagesService);
Object.defineProperty(TourPagesService, 'enqueueSingleReverseGeneration', {
configurable: true,
value,
});
return () => {
Object.defineProperty(TourPagesService, 'enqueueSingleReverseGeneration', {
configurable: true,
value: original,
});
};
}
function createServiceDbApi(
calls: UpdateContractCalls,
): EntityServiceDbApi<TestEntity, TestEntityData, TestEntityData> {
@ -866,6 +886,58 @@ void test('TourPagesService clears targeted back transition when incoming forwar
});
});
void test('TourPagesService does not enqueue generation when the reversed variant is already linked', async () => {
const enqueuedTasks: TourPageReverseGenerationTask[] = [];
const restoreAssetsFindBy = replaceAssetsDbApiFindBy(() =>
Promise.resolve({
id: 'asset-1',
storage_key: 'assets/transition.mp4',
asset_variants_asset: [
{
id: 'variant-1',
variant_type: 'reversed',
storage_key: 'assets/transition-reversed.mp4',
},
],
}),
);
const restoreEnqueue = replaceSingleReverseGenerationEnqueue((task) => {
enqueuedTasks.push(task);
});
try {
const pageData = {
id: 'page-1',
projectId: 'project-1',
ui_schema_json: {
elements: [
{
id: 'forward',
type: 'navigation_next',
navType: 'forward',
targetPageSlug: 'destination',
transitionVideoUrl: 'assets/transition.mp4',
transitionReverseMode: 'auto_reverse',
reverseVideoUrl: 'assets/transition-reversed.mp4',
},
],
},
};
const result = await TourPagesService.processReversedVideosAndUpdateSchema(
pageData,
undefined,
{ _skipHistoryModeCheck: true },
);
assert.equal(result, pageData);
assert.deepEqual(enqueuedTasks, []);
} finally {
restoreEnqueue();
restoreAssetsFindBy();
}
});
void test('TourPagesService.update clears stale targeted back transition before auto-reverse validation', async () => {
const calls: UpdateContractCalls = {};
const transaction: TestManagedTransaction = {

View File

@ -633,7 +633,7 @@ Clone project with all related entities.
Copy all `dev` environment content to `stage` for preview.
**Auth:** Required
**Auth:** Required. **Permission:** `CREATE_PUBLISH_EVENTS`
**Request:**
```json
@ -649,12 +649,21 @@ Copy all `dev` environment content to `stage` for preview.
"publishEventId": "event-uuid",
"summary": {
"pages_copied": 10,
"audios_copied": 2
"audios_copied": 2,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
}
}
```
**Note:** This is part of the dev → stage → production workflow. Content is edited in `dev` (Constructor), previewed in `stage`, then published to `production`. Page elements, navigation, and transitions are stored in `tour_pages.ui_schema_json` and copied with pages.
**Note:** The response is sent after the Dev → Stage transaction commits, so
the summary describes the Stage snapshot that is ready for preview. Page
elements, navigation, and transitions are stored in
`tour_pages.ui_schema_json` and copied with pages.
**Errors:** `400` invalid/concurrent request, `401` unauthenticated, `403`
permission denied, `404` project missing, `429` rate limited, or `500` copy
failure.
## Tour Pages Endpoints
@ -1187,9 +1196,9 @@ The platform uses a three-tier publishing workflow:
Copy `dev` content to `stage` for preview. See [Projects Endpoints](#post-apipublishsave-to-stage).
### POST /api/publish (or POST /api/publish/publish)
### POST /api/publish
Publish project from `stage` to `production` environment. Both endpoints are aliases and perform the same action.
Publish project from `stage` to `production` environment.
**Auth:** Required
@ -1209,7 +1218,9 @@ Publish project from `stage` to `production` environment. Both endpoints are ali
"publishEventId": "event-uuid",
"summary": {
"pages_copied": 10,
"audios_copied": 2
"audios_copied": 2,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
}
}
```

View File

@ -278,7 +278,9 @@ Key functions:
**Generation Pattern:**
- Reversed videos are always generated for all navigation elements with transitions
- Generated on-demand when page is saved (create/update)
- Checked before generation to avoid duplication
- Checked before generation to avoid duplication. If the stored reversed
variant is already linked from the element, the save path skips the
generation queue entirely.
- Different transition videos are processed sequentially through the global
FFmpeg queue; the backend does not run multiple FFmpeg reversals in parallel
- Background processing keeps save requests fast

View File

@ -267,16 +267,23 @@ Authorization: Bearer {token}
```json
{
"success": true,
"publishEventId": "uuid"
"publishEventId": "uuid",
"summary": {
"pages_copied": 5,
"audios_copied": 2,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
}
}
```
**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`).
**Note:** Save to Stage returns only after the Dev → Stage transaction completes.
The response therefore confirms that Stage contains the saved snapshot and
includes the number of pages and audio tracks copied. Copy failures are returned
to the Constructor instead of being acknowledged as a successful queued job.
**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
@ -296,7 +303,9 @@ Authorization: Bearer {token}
"publishEventId": "uuid",
"summary": {
"pages_copied": 5,
"audios_copied": 2
"audios_copied": 2,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
}
}
```
@ -316,10 +325,12 @@ GET /api/publish_events?project=id - Filter by project
| Operation | Blocking | Behavior |
|-----------|----------|----------|
| **Save to Stage** | No | Returns immediately, copy runs in background via `setImmediate()` |
| **Save to Stage** | Yes | Waits for the Dev → Stage transaction and returns the copy summary |
| **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.
Both operations wait for their database copy to finish. This guarantees that a
subsequent Publish to Production cannot overtake a queued Save to Stage request
after the Constructor reports success.
### Complete Flow (Publish to Production)
@ -566,27 +577,50 @@ The Constructor uses the `useConstructorPageActions` hook which provides the `sa
const saveToStage = useCallback(async () => {
if (!projectId) {
onError?.('Project ID is required to save to stage.');
return;
return false;
}
// First save current state, then copy to stage
await saveConstructor();
setIsSavingToStage(true);
try {
setIsSavingToStage(true);
// First persist current state to Dev, then copy the committed snapshot.
const didSave = await saveConstructor();
if (!didSave) return false;
// 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.');
const response = await axios.post('/publish/save-to-stage', { projectId });
const { pages_copied: pagesCopied } = response.data.summary;
onSuccess?.(
`Saved to stage: ${pagesCopied} pages copied.`,
);
return true;
} catch (error: any) {
onError?.(error?.response?.data?.message || 'Failed to save to stage');
return false;
} 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.
**Note:** The Save to Stage button remains busy until the copy transaction
finishes. Its user-facing success message reports the committed page count; the
API summary retains audio and settings counts, while the publish event persists
page and audio counts for history. A failed copy keeps Stage unchanged and
displays an error.
The Constructor exposes the two sequential phases on the Stage button:
1. `Saving page...` while the current constructor page is committed to Dev.
2. `Copying to Stage...` while the project snapshot transaction is running.
Save, Stage, Exit, and page-level mutation controls are disabled during this
workflow so another request cannot alter or replace the snapshot in progress.
After the page update succeeds, the Stage copy starts immediately. The
successful `PUT` response updates the constructor's TanStack Query page cache
directly, so no page reload sits between persistence and the Stage request. The
publish-status timestamps refresh in the background and do not extend the
button's busy state. Other constructor page mutations refetch page data only;
unchanged project, asset, and element-default queries remain cached.
// constructor.tsx - Hook usage
const {
@ -964,7 +998,7 @@ This ensures smooth transitions regardless of environment (dev preview, stage, o
| **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 |
| **Publish Action** | "Save to Stage" (completion-confirmed) → | "Publish to Production" (completion-confirmed) → | Final destination |
| **PWA Cache** | Not applicable | Can be generated | Primary target |
| **Visibility** | Constructor only | Stage URL | Public URL |
@ -986,8 +1020,10 @@ This ensures smooth transitions regardless of environment (dev preview, stage, o
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
3. Confirm the latest successful Dev → Stage event finished before the latest
Stage → Production event started
4. Clear browser cache and reload presentation
5. Verify correct project slug in URL
### Stage/Production Mismatch

View File

@ -702,6 +702,11 @@ background_embed_url: string;
The constructor background dropdown includes **Background 360**, sourced from `asset_type='embed'` assets. Selecting a 360/embed background clears image and video background URLs; background audio remains independent.
Asset dropdown options are deduplicated by their resolved storage key or embed
URL. If multiple asset records reference the same URL, the constructor shows the
first matching label once because identical select values cannot represent
distinct choices.
Constructor asset selectors load the full project asset list through
`useConstructorData()` and then filter options client-side by `asset_type` and
`type` for image, background image, video, audio, transition, icon, and embed
@ -1173,24 +1178,20 @@ const saveConstructor = async () => {
setSaving(true);
try {
// Serialize elements to JSON
const ui_schema_json = JSON.stringify({
elements: elements,
const payload = buildConstructorPageSavePayload({
activePageId,
activePage,
elementsToSave: elements,
pageBackground,
uiControlsSettings,
project,
});
// Update tour page via API (always saves to dev environment)
await dispatch(tourPagesActions.update({
// The mutation updates both detail and list query caches from the response.
await updatePage({
id: activePageId,
data: {
ui_schema_json,
background_image_url: backgroundImageUrl,
background_video_url: backgroundVideoUrl,
background_audio_url: backgroundAudioUrl,
},
}));
// Reload data to refresh
await loadData();
data: payload.data,
});
setSuccessMessage('Saved successfully');
} catch (error) {
@ -1203,28 +1204,47 @@ const saveConstructor = async () => {
### Save to Stage Function
**Note:** Save to Stage is **non-blocking** - the API returns immediately while the copy operation continues in the background.
**Note:** Save to Stage returns after the Dev → Stage transaction completes, so
the success message confirms the reported pages are available in Stage.
The Stage button reports `Saving page...` during the initial Dev save and
`Copying to Stage...` during the environment copy. Save, Stage, Exit, page
selection, page ordering, page creation/deletion/duplication, and background
actions remain disabled until the operation finishes. This prevents overlapping
requests from changing the snapshot while it is being copied.
The Dev page `PUT` is the persistence boundary. Once it succeeds, the Stage copy
starts without waiting for a constructor data reload. The successful response
updates the TanStack Query page cache directly, including the displayed save
timestamp, without a follow-up GET that could race with the user's next edit.
The latest publish timestamp refreshes in the background, and other page
mutations refetch only the pages query because they do not change project
metadata, assets, or element defaults.
```typescript
const saveToStage = async () => {
if (!projectId) {
onError?.('Project ID is required to save to stage.');
return;
return false;
}
// First save current work to dev
await saveConstructor();
setIsSavingToStage(true);
try {
setIsSavingToStage(true);
// First persist current work to Dev.
const didSave = await saveConstructor();
if (!didSave) return false;
// Non-blocking: returns immediately, copy runs in background
await axios.post('/publish/save-to-stage', { projectId });
const response = await axios.post('/publish/save-to-stage', { projectId });
const pagesCopied = response.data.summary.pages_copied;
onSuccess?.('Saved to stage.');
onSuccess?.(
`Saved to stage: ${pagesCopied} page${pagesCopied === 1 ? '' : 's'} copied.`,
);
return true;
} catch (error) {
const message = error?.response?.data?.message || error?.message || 'Failed to save to stage.';
onError?.(message);
return false;
} finally {
setIsSavingToStage(false);
}
@ -1275,8 +1295,8 @@ selects the next page in display order, or clears the editor when no pages
remain.
**Backend Publish Flow:**
- Save to Stage (non-blocking): `POST /publish/save-to-stage``PublishService.saveToStage()``copyEnvironment(dev, stage)` (runs in background)
- Publish to Prod (blocking): `POST /publish``PublishService.publishToProduction()``copyEnvironment(stage, production)`
- Save to Stage (completion-confirmed): `POST /publish/save-to-stage``PublishService.saveToStage()``copyEnvironment(dev, stage)`
- Publish to Prod (completion-confirmed): `POST /publish``PublishService.publishToProduction()``copyEnvironment(stage, production)`
The `copyEnvironment` method:
1. Fetches all `tour_pages` and `project_audio_tracks` from source environment

View File

@ -1179,6 +1179,17 @@ const { isDragging, onDragStart, onDragEnd } = useCanvasElementDrag({
---
#### useConstructorData
**File:** `useConstructorData.ts`
Loads the constructor's project, Dev pages, assets, and element defaults through
TanStack Query. Its explicit `refetchPages()` callback refreshes only the mutable
page list used by constructor page operations. Project metadata, assets, and
element defaults remain cached until their owning mutations invalidate them.
---
#### useConstructorPageActions
**File:** `useConstructorPageActions.ts` (~361 LOC)
@ -1187,7 +1198,9 @@ Supporting boundary:
- `useConstructorPageActions.helpers.ts`: pending reverse-video key selection, save/create/duplicate payload builders, validation helpers, and API error fallback.
**Purpose:** Page create/save/publish operations in constructor, including page
duplication orchestration. The hook owns React state, API calls, reload callbacks,
duplication orchestration. Page saving uses the TanStack Query mutation so the
successful response updates cached page detail/list data without a follow-up
GET. The hook also owns operation state, reload callbacks for page mutations,
and reverse-video polling.
```typescript
@ -1196,7 +1209,7 @@ interface UseConstructorPageActionsOptions {
elements: CanvasElement[];
getElements?: () => CanvasElement[];
pageBackground: PageBackgroundState;
onReload: () => Promise<void>;
onReload: (preservePageId?: string) => Promise<void>;
}
interface UseConstructorPageActionsResult {
@ -1205,7 +1218,7 @@ interface UseConstructorPageActionsResult {
isCreatingPage: boolean;
isDuplicatingPage: boolean;
saveConstructor: () => Promise<boolean>;
saveToStage: () => Promise<void>;
saveToStage: () => Promise<boolean>;
createPage: (name: string, slug: string) => Promise<void>;
duplicatePage: (sourcePageId: string, name: string, slug: string) => Promise<TourPage | null>;
}
@ -1749,10 +1762,13 @@ const lastProjectSaveAt = useMemo(() => {
}, null as string | null);
}, [pages]);
// Wrap saveToStage to refresh status
// Refresh timestamps only after a successful Stage commit. The status GETs do
// not extend the Stage button's busy state.
const handleSaveToStage = useCallback(async () => {
await saveToStage();
await refreshPublishStatus();
const didSaveToStage = await saveToStage();
if (didSaveToStage) {
void refreshPublishStatus();
}
}, [saveToStage, refreshPublishStatus]);
// Pass timestamps to ConstructorMenu

View File

@ -2042,13 +2042,14 @@ function useConstructorPageActions(
| Option | Type | Description |
|--------|------|-------------|
| projectId | `string` | Current project ID |
| project | `ConstructorProjectDimensions \| null` | Design dimensions used in the page snapshot |
| pages | `TourPage[]` | All pages |
| activePage | `TourPage \| null` | Current page |
| activePageId | `string` | Current page ID |
| elements | `CanvasElement[]` | Current elements |
| backgroundImageUrl | `string` | Background image |
| backgroundVideoUrl | `string` | Background video |
| backgroundAudioUrl | `string` | Background audio |
| getElements | `() => CanvasElement[]` | Read same-tick element state before saving |
| pageBackground | `PageBackgroundState` | Background media and playback settings |
| uiControlsSettings | `UiControlsSettings \| null` | Page-level UI-control overrides |
| onReload | `(preservePageId?) => Promise<void>` | Reload callback |
| onSetActivePageId | `(id) => void` | Set active page |
| onSetMenuOpen | `(open) => void` | Set menu open |
@ -2062,11 +2063,11 @@ function useConstructorPageActions(
| isSaving | `boolean` | Save in progress |
| isSavingToStage | `boolean` | Stage save in progress |
| isCreatingPage | `boolean` | Page creation in progress |
| isCreatingTransition | `boolean` | Transition creation in progress |
| saveConstructor | `() => Promise<void>` | Save current state |
| saveToStage | `() => Promise<void>` | Save dev → stage |
| createPage | `() => Promise<void>` | Create new page |
| createTransition | `(params) => Promise<void>` | Create transition (legacy) |
| isDuplicatingPage | `boolean` | Page duplication in progress |
| saveConstructor | `() => Promise<boolean>` | Save current state and report success |
| saveToStage | `() => Promise<boolean>` | Save Dev, commit Dev → Stage, and report success |
| createPage | `(name, slug) => Promise<void>` | Create a Dev page |
| duplicatePage | `(sourcePageId, name, slug) => Promise<TourPage \| null>` | Duplicate a Dev page |
**Example:**

View File

@ -562,6 +562,7 @@ Visual tour builder with canvas-based element editing.
| Hook | Purpose |
|------|---------|
| `useConstructorElements` | Element CRUD, selection, nested item helpers, and constructor-local element clipboard |
| `useConstructorPageWorkflow` | Composes page actions/management and refreshes publish status after successful Stage commits |
| `useConstructorPageActions` | Page save/create/duplicate and Save to Stage operations |
| `useCanvasElementDrag` | Element positioning with percentage coordinates |
| `useTransitionPreview` | Transition video preview state |
@ -603,18 +604,21 @@ const ConstructorPage = () => {
allowedNavigationTypes,
});
// Page persistence and page creation/duplication
// Page persistence, management, and publish-status orchestration
const {
saveConstructor,
saveToStage,
createPage,
duplicatePage,
} = useConstructorPageActions({
handleSaveToStage,
} = useConstructorPageWorkflow({
projectId,
project,
pages,
activePage,
activePageId,
elements,
getElements,
pageBackground,
onReload: handleReload,
refetchData,
// ... constructor callbacks
});
return (
@ -629,7 +633,7 @@ const ConstructorPage = () => {
canCopyElement={Boolean(selectedElement)}
canPasteElement={canPasteElement}
onSave={saveConstructor}
onSaveToStage={saveToStage}
onSaveToStage={handleSaveToStage}
/>
{/* Center: Canvas */}

View File

@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
getCollapsedToolbarPageName,
getConstructorSaveControlState,
getConstructorToolbarActionState,
getConstructorToolbarMaxWidth,
sortToolbarPages,
@ -46,6 +47,53 @@ test('getCollapsedToolbarPageName returns active page name or fallback', () => {
);
});
test('getConstructorSaveControlState describes direct and staged save phases', () => {
assert.deepEqual(
getConstructorSaveControlState({
isSaving: false,
isSavingToStage: false,
}),
{
isBusy: false,
saveLabel: 'Save',
stageLabel: 'Stage',
},
);
assert.deepEqual(
getConstructorSaveControlState({
isSaving: true,
isSavingToStage: false,
}),
{
isBusy: true,
saveLabel: 'Saving page...',
stageLabel: 'Stage',
},
);
assert.deepEqual(
getConstructorSaveControlState({
isSaving: true,
isSavingToStage: true,
}),
{
isBusy: true,
saveLabel: 'Save',
stageLabel: 'Saving page...',
},
);
assert.deepEqual(
getConstructorSaveControlState({
isSaving: false,
isSavingToStage: true,
}),
{
isBusy: true,
saveLabel: 'Save',
stageLabel: 'Copying to Stage...',
},
);
});
test('getConstructorToolbarActionState derives page and element action flags', () => {
const state = getConstructorToolbarActionState({
pages: [

View File

@ -15,6 +15,28 @@ export interface ConstructorToolbarActionState {
canPasteCurrentElement: boolean;
}
export interface ConstructorSaveControlState {
isBusy: boolean;
saveLabel: string;
stageLabel: string;
}
export const getConstructorSaveControlState = ({
isSaving,
isSavingToStage,
}: {
isSaving: boolean;
isSavingToStage: boolean;
}): ConstructorSaveControlState => ({
isBusy: isSaving || isSavingToStage,
saveLabel: isSaving && !isSavingToStage ? 'Saving page...' : 'Save',
stageLabel: isSavingToStage
? isSaving
? 'Saving page...'
: 'Copying to Stage...'
: 'Stage',
});
export const getConstructorToolbarMaxWidth = (
positionX: number,
viewportMargin = TOOLBAR_VIEWPORT_MARGIN_PX,

View File

@ -158,6 +158,7 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
onSelectMenuItem={onSelectMenuItem}
isReorderingPages={isReorderingPages}
isCreatingPage={isCreatingPage}
isPresentationSaving={isSaving || isSavingToStage}
canMovePageUp={actionState.canMovePageUp}
canMovePageDown={actionState.canMovePageDown}
canDuplicatePage={actionState.canDuplicatePage}

View File

@ -94,6 +94,8 @@ const ConstructorToolbarLayer = ({
return null;
}
const isPresentationSaving = isSaving || isSavingToStage;
return (
<ConstructorToolbar
ref={toolbarRef}
@ -108,10 +110,12 @@ const ConstructorToolbarLayer = ({
onMovePage={onMovePage}
isReorderingPages={isReorderingPages}
onDuplicatePage={onDuplicatePage}
isDuplicatingPage={isSaving || isDuplicatingPage}
isDuplicatingPage={isPresentationSaving || isDuplicatingPage}
onDeletePage={onDeletePage}
canDeletePage={canDeletePage}
isDeletingPage={isDeletingPage || isSaving || isDuplicatingPage}
isDeletingPage={
isDeletingPage || isPresentationSaving || isDuplicatingPage
}
interactionMode={interactionMode}
onModeChange={onModeChange}
onSelectMenuItem={onSelectMenuItem}

View File

@ -28,6 +28,7 @@ interface Props {
onSelectMenuItem: (item: EditorMenuItem) => void;
isReorderingPages: boolean;
isCreatingPage: boolean;
isPresentationSaving: boolean;
canMovePageUp: boolean;
canMovePageDown: boolean;
canDuplicatePage: boolean;
@ -55,6 +56,7 @@ export default function ConstructorToolbarPageActions({
onSelectMenuItem,
isReorderingPages,
isCreatingPage,
isPresentationSaving,
canMovePageUp,
canMovePageDown,
canDuplicatePage,
@ -78,14 +80,14 @@ export default function ConstructorToolbarPageActions({
pages={pages}
activePageId={activePageId}
onPageChange={onPageChange}
disabled={isReorderingPages}
disabled={isReorderingPages || isPresentationSaving}
className='h-10 min-w-[160px] max-w-[210px] flex-1'
/>
<div className='flex items-center gap-1'>
<button
type='button'
onClick={() => onMovePage?.('up')}
disabled={!canMovePageUp}
disabled={!canMovePageUp || isPresentationSaving}
className={iconBtnClass}
title='Move page up'
aria-label='Move page up'
@ -95,7 +97,7 @@ export default function ConstructorToolbarPageActions({
<button
type='button'
onClick={() => onMovePage?.('down')}
disabled={!canMovePageDown}
disabled={!canMovePageDown || isPresentationSaving}
className={iconBtnClass}
title='Move page down'
aria-label='Move page down'
@ -106,9 +108,15 @@ export default function ConstructorToolbarPageActions({
<button
type='button'
onClick={onCreatePage}
disabled={isCreatingPage}
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`}
aria-label={isCreatingPage ? 'Creating page' : 'Create page'}
disabled={isCreatingPage || isPresentationSaving}
className={`${triggerBtnClass} ${isCreatingPage || isPresentationSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
aria-label={
isPresentationSaving
? 'Presentation save in progress'
: isCreatingPage
? 'Creating page'
: 'Create page'
}
>
<BaseIcon path={mdiPlus} size={18} />
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
@ -116,7 +124,7 @@ export default function ConstructorToolbarPageActions({
<button
type='button'
onClick={onDuplicatePage}
disabled={!canDuplicatePage}
disabled={!canDuplicatePage || isPresentationSaving}
className={iconBtnClass}
title='Duplicate page'
aria-label='Duplicate page'
@ -126,7 +134,7 @@ export default function ConstructorToolbarPageActions({
<button
type='button'
onClick={onDeletePage}
disabled={!canDeleteCurrentPage}
disabled={!canDeleteCurrentPage || isPresentationSaving}
className='flex h-10 w-10 items-center justify-center rounded border border-red-300/30 bg-red-500/10 text-red-200 transition-colors hover:bg-red-500/20 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-35'
title='Delete page'
aria-label='Delete page'
@ -138,14 +146,15 @@ export default function ConstructorToolbarPageActions({
ref={backgroundTriggerRef}
type='button'
onClick={onToggleBackgroundDropdown}
className={triggerBtnClass}
disabled={isPresentationSaving}
className={`${triggerBtnClass} ${isPresentationSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
aria-label='Background actions'
>
<BaseIcon path={mdiImageMultiple} size={18} />
<span>BG</span>
<BaseIcon path={mdiChevronDown} size={16} />
</button>
{isBackgroundDropdownActive && (
{isBackgroundDropdownActive && !isPresentationSaving && (
<ClickOutside
onClickOutside={onCloseDropdown}
excludedElements={[backgroundTriggerRef]}

View File

@ -2,6 +2,7 @@ import { mdiChevronLeft, mdiExitToApp } from '@mdi/js';
import dataFormatter from '../../helpers/dataFormatter';
import BaseButton from '../BaseButton';
import BaseIcon from '../BaseIcon';
import { getConstructorSaveControlState } from './ConstructorToolbar.helpers';
interface Props {
isSaving: boolean;
@ -24,40 +25,49 @@ export default function ConstructorToolbarSaveControls({
onExit,
onCollapse,
}: Props) {
const { isBusy, saveLabel, stageLabel } = getConstructorSaveControlState({
isSaving,
isSavingToStage,
});
return (
<div className='flex min-h-[58px] flex-wrap items-center gap-2 border-l border-white/15 pl-3'>
<div
className='flex min-h-[58px] flex-wrap items-center gap-2 border-l border-white/15 pl-3'
aria-busy={isBusy}
>
<BaseButton
small
color='info'
className='h-10 w-[86px]'
label={isSaving ? 'Saving...' : 'Save'}
className='h-10 w-[112px]'
label={saveLabel}
subtitle={
lastSavedAt ? dataFormatter.relativeTimestamp(lastSavedAt) : ' '
}
onClick={onSave}
disabled={isSaving}
disabled={isBusy}
/>
<BaseButton
small
color='success'
className='h-10 w-[86px]'
label={isSavingToStage ? 'Saving...' : 'Stage'}
className='h-10 w-[154px]'
label={stageLabel}
subtitle={
lastSavedToStageAt
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
: ' '
}
onClick={onSaveToStage}
disabled={isSavingToStage}
disabled={isBusy}
/>
<button
type='button'
onClick={onExit}
className='flex h-10 w-10 items-center justify-center rounded text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300'
title='Exit constructor'
aria-label='Exit constructor'
disabled={isBusy}
className='flex h-10 w-10 items-center justify-center rounded text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-35'
title={isBusy ? 'Wait for saving to finish' : 'Exit constructor'}
aria-label={isBusy ? 'Saving in progress' : 'Exit constructor'}
>
<BaseIcon path={mdiExitToApp} size={26} />
</button>

View File

@ -120,8 +120,10 @@ export function useConstructorPageWorkflow({
});
const handleSaveToStage = useCallback(async () => {
await saveToStage();
await refreshPublishStatus();
const didSaveToStage = await saveToStage();
if (didSaveToStage) {
void refreshPublishStatus();
}
}, [saveToStage, refreshPublishStatus]);
return {

View File

@ -14,6 +14,16 @@ interface PagesListResponse {
count: number;
}
type UpdatePageData = Omit<Partial<TourPage>, 'ui_schema_json'> & {
ui_schema_json?: string | Record<string, unknown>;
};
export const replaceTourPageInList = (
pages: TourPage[] | undefined,
updatedPage: TourPage,
): TourPage[] | undefined =>
pages?.map((page) => (page.id === updatedPage.id ? updatedPage : page));
/**
* Fetch tour pages for a project
*/
@ -61,7 +71,7 @@ export function useUpdatePageMutation() {
data,
}: {
id: string;
data: Partial<TourPage>;
data: UpdatePageData;
}): Promise<TourPage> => {
const response = await axios.put<TourPage>(`tour_pages/${id}`, {
id,
@ -70,10 +80,11 @@ export function useUpdatePageMutation() {
return response.data;
},
onSuccess: (data, variables) => {
// Update the single page cache
queryClient.setQueryData(queryKeys.tourPages.detail(variables.id), data);
// Invalidate list queries
queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all });
queryClient.setQueriesData<TourPage[]>(
{ queryKey: queryKeys.tourPages.lists() },
(pages) => replaceTourPageInList(pages, data),
);
},
});
}

View File

@ -12,6 +12,7 @@ import {
buildIconAssetOptions,
buildImageAssetOptions,
buildTransitionVideoOptions,
deduplicateAssetOptions,
getAssetSourceValue,
} from '../lib/constructorHelpers';
import type {
@ -68,19 +69,21 @@ export function useAssetOptions({
// Video assets (excluding transition videos)
const videoOptions = useMemo(
() =>
assets
.filter(
(asset) =>
asset.asset_type === 'video' &&
asset.type !== 'transition' &&
getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: asset.name
? `${asset.name} · ${getAssetSourceValue(asset)}`
: getAssetSourceValue(asset),
})),
deduplicateAssetOptions(
assets
.filter(
(asset) =>
asset.asset_type === 'video' &&
asset.type !== 'transition' &&
getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: asset.name
? `${asset.name} · ${getAssetSourceValue(asset)}`
: getAssetSourceValue(asset),
})),
),
[assets],
);
@ -99,14 +102,17 @@ export function useAssetOptions({
// Embed assets (360° panoramas, iframes) - filter by asset_type='embed'
const embedOptions = useMemo(
() =>
assets
.filter(
(asset) => asset.asset_type === 'embed' && getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: asset.name || getAssetSourceValue(asset),
})),
deduplicateAssetOptions(
assets
.filter(
(asset) =>
asset.asset_type === 'embed' && getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: asset.name || getAssetSourceValue(asset),
})),
),
[assets],
);

View File

@ -5,7 +5,7 @@
* Replaces the manual loadData function with cached, deduplicated queries.
*/
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
import type { CanvasElement, CanvasElementType } from '../types/constructor';
import type { Asset, TourPage } from '../types/entities';
@ -54,8 +54,8 @@ interface UseConstructorDataResult {
isError: boolean;
error: Error | null;
// Refetch function
refetch: () => Promise<void>;
// Refetch mutable constructor page data
refetchPages: () => Promise<void>;
}
export function useConstructorData({
@ -70,6 +70,7 @@ export function useConstructorData({
// Fetch pages (dev environment for constructor)
const pagesQuery = usePagesQuery(enabled ? projectId : undefined, 'dev');
const refetchPageQuery = pagesQuery.refetch;
// Fetch assets
const assetsQuery = useAssetsQuery(enabled ? projectId : undefined);
@ -112,15 +113,12 @@ export function useConstructorData({
assetsQuery.error ||
elementDefaultsQuery.error;
// Refetch all queries
const refetch = async () => {
await Promise.all([
projectQuery.refetch(),
pagesQuery.refetch(),
assetsQuery.refetch(),
elementDefaultsQuery.refetch(),
]);
};
// Constructor mutations in this workflow only change pages. Project,
// asset, and element-default queries remain cached until their own
// mutations invalidate them.
const refetchPages = useCallback(async () => {
await refetchPageQuery();
}, [refetchPageQuery]);
return {
// Project
@ -145,7 +143,7 @@ export function useConstructorData({
error: error instanceof Error ? error : null,
// Refetch
refetch,
refetchPages,
};
}

View File

@ -12,6 +12,7 @@ import type { CanvasElement } from '../types/constructor';
import type { TourPage } from '../types/entities';
import type { PageBackgroundState } from '../types/pageBackground';
import type { UiControlsSettings } from '../types/uiControls';
import { useUpdatePageMutation } from './queries/usePagesQuery';
import {
buildConstructorPageSavePayload,
buildCreateConstructorPagePayload,
@ -29,6 +30,17 @@ interface Project extends ConstructorProjectDimensions {
name?: string;
}
interface SaveToStageResponse {
success: true;
publishEventId: string;
summary: {
pages_copied: number;
audios_copied: number;
transition_settings_copied: number;
ui_control_settings_copied: number;
};
}
interface UseConstructorPageActionsOptions {
/** Current project ID */
projectId: string;
@ -72,7 +84,7 @@ interface UseConstructorPageActionsResult {
/** Save current constructor state */
saveConstructor: () => Promise<boolean>;
/** Save dev content to stage environment */
saveToStage: () => Promise<void>;
saveToStage: () => Promise<boolean>;
/** Create a new page with the given name and slug */
createPage: (pageName: string, slug: string) => Promise<void>;
/** Duplicate an existing page with the given name and slug */
@ -125,6 +137,7 @@ export function useConstructorPageActions({
const [isSavingToStage, setIsSavingToStage] = useState(false);
const [isCreatingPage, setIsCreatingPage] = useState(false);
const [isDuplicatingPage, setIsDuplicatingPage] = useState(false);
const { mutateAsync: updatePage } = useUpdatePageMutation();
// Polling hook for reverse video generation status
const { startPolling } = useReverseVideoPolling({
@ -147,22 +160,19 @@ export function useConstructorPageActions({
// These are elements that will trigger async reversed video generation
const pendingReverseKeys = getPendingReverseVideoKeys(elementsToSave);
await axios.put(
`/tour_pages/${activePageId}`,
buildConstructorPageSavePayload({
activePageId,
activePage,
elementsToSave,
pageBackground,
uiControlsSettings,
project,
}),
);
const payload = buildConstructorPageSavePayload({
activePageId,
activePage,
elementsToSave,
pageBackground,
uiControlsSettings,
project,
});
await updatePage({ id: activePageId, data: payload.data });
onSuccess?.(
'Constructor settings saved. Element positions are stored in percentages.',
);
await onReload(activePageId);
// Start polling for reverse video generation if there are pending keys
// This will automatically reload page data when all videos are ready
@ -196,24 +206,32 @@ export function useConstructorPageActions({
getElements,
project,
onError,
onReload,
onSuccess,
startPolling,
updatePage,
]);
const saveToStage = useCallback(async () => {
if (!projectId) {
onError?.('Project ID is required to save to stage.');
return;
return false;
}
const didSave = await saveConstructor();
if (!didSave) return;
setIsSavingToStage(true);
try {
setIsSavingToStage(true);
await axios.post('/publish/save-to-stage', { projectId });
onSuccess?.('Saved to stage.');
const didSave = await saveConstructor();
if (!didSave) return false;
const response = await axios.post<SaveToStageResponse>(
'/publish/save-to-stage',
{ projectId },
);
const { pages_copied: pagesCopied } = response.data.summary;
onSuccess?.(
`Saved to stage: ${pagesCopied} page${pagesCopied === 1 ? '' : 's'} copied.`,
);
return true;
} catch (error: unknown) {
const message = getConstructorActionErrorMessage(
error,
@ -224,6 +242,7 @@ export function useConstructorPageActions({
error instanceof Error ? error : { error },
);
onError?.(message);
return false;
} finally {
setIsSavingToStage(false);
}

View File

@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { replaceTourPageInList } from './queries/usePagesQuery';
test('replaceTourPageInList updates only the matching cached page', () => {
const unchangedPage = { id: 'page-1', name: 'First' };
const originalPage = { id: 'page-2', name: 'Before' };
const updatedPage = {
id: 'page-2',
name: 'After',
updatedAt: '2026-07-30T10:14:16.022Z',
};
const result = replaceTourPageInList(
[unchangedPage, originalPage],
updatedPage,
);
assert.deepEqual(result, [unchangedPage, updatedPage]);
assert.equal(result?.[0], unchangedPage);
assert.equal(replaceTourPageInList(undefined, updatedPage), undefined);
});

View File

@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
addFallbackAssetOption,
deduplicateAssetOptions,
} from './constructorHelpers';
test('deduplicateAssetOptions keeps one option for duplicate resolved URLs', () => {
const duplicateUrl =
'https://360stories.com/paris/place/eiffel-tower-at-night?mode=2&playerMode=2';
assert.deepEqual(
deduplicateAssetOptions([
{ value: duplicateUrl, label: 'Eiffel Tower' },
{ value: duplicateUrl, label: 'Eiffel Tower duplicate' },
{ value: ' https://example.com/tour ', label: 'Another tour' },
]),
[
{ value: duplicateUrl, label: 'Eiffel Tower' },
{ value: 'https://example.com/tour', label: 'Another tour' },
],
);
});
test('addFallbackAssetOption also normalizes existing duplicate options', () => {
assert.deepEqual(
addFallbackAssetOption(
[
{ value: 'assets/image.jpg', label: 'Image' },
{ value: 'assets/image.jpg', label: 'Duplicate image' },
],
'assets/image.jpg',
),
[{ value: 'assets/image.jpg', label: 'Image' }],
);
});

View File

@ -45,6 +45,27 @@ export const getAssetLabel = (asset: ProjectAsset): string => {
export const getAssetSourceValue = (asset: ProjectAsset): string =>
String(asset.storage_key || asset.cdn_url || '').trim();
/**
* Deduplicate select options by their submitted value.
* Multiple asset records can legitimately resolve to the same storage key or
* embed URL, but a select cannot distinguish options with identical values.
*/
export const deduplicateAssetOptions = (
options: AssetOption[],
): AssetOption[] => {
const seenValues = new Set<string>();
const uniqueOptions: AssetOption[] = [];
for (const option of options) {
const value = option.value.trim();
if (!value || seenValues.has(value)) continue;
seenValues.add(value);
uniqueOptions.push({ ...option, value });
}
return uniqueOptions;
};
/**
* Check if an asset is likely a background image based on name/type.
* Used to filter assets for background image selection.
@ -70,11 +91,12 @@ export const addFallbackAssetOption = (
fallbackLabel?: string,
): AssetOption[] => {
const normalizedValue = String(value || '').trim();
if (!normalizedValue) return options;
if (options.some((option) => option.value === normalizedValue))
return options;
const uniqueOptions = deduplicateAssetOptions(options);
if (!normalizedValue) return uniqueOptions;
if (uniqueOptions.some((option) => option.value === normalizedValue))
return uniqueOptions;
return [
...options,
...uniqueOptions,
{
value: normalizedValue,
label: fallbackLabel || `Custom URL · ${normalizedValue}`,
@ -122,17 +144,19 @@ export const buildAssetOptions = (
assetType: 'image' | 'video' | 'audio',
additionalFilter?: (asset: ProjectAsset) => boolean,
): AssetOption[] => {
return assets
.filter((asset) => {
if (asset.asset_type !== assetType) return false;
if (!getAssetSourceValue(asset)) return false;
if (additionalFilter && !additionalFilter(asset)) return false;
return true;
})
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
}));
return deduplicateAssetOptions(
assets
.filter((asset) => {
if (asset.asset_type !== assetType) return false;
if (!getAssetSourceValue(asset)) return false;
if (additionalFilter && !additionalFilter(asset)) return false;
return true;
})
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
})),
);
};
/**
@ -183,7 +207,7 @@ export const buildTransitionVideoOptions = (
label: getAssetLabel(asset),
}));
if (typedAssets.length > 0) return typedAssets;
if (typedAssets.length > 0) return deduplicateAssetOptions(typedAssets);
// Fall back to assets with [TRANSITION] tag in name
const taggedAssets = assets
@ -198,7 +222,7 @@ export const buildTransitionVideoOptions = (
label: getAssetLabel(asset),
}));
if (taggedAssets.length > 0) return taggedAssets;
if (taggedAssets.length > 0) return deduplicateAssetOptions(taggedAssets);
// Fall back to all video assets
return buildVideoAssetOptions(assets);
@ -211,17 +235,19 @@ export const buildTransitionVideoOptions = (
export const buildIconAssetOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
return assets
.filter(
(asset) =>
asset.type === 'icon' &&
asset.asset_type === 'image' &&
getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
}));
return deduplicateAssetOptions(
assets
.filter(
(asset) =>
asset.type === 'icon' &&
asset.asset_type === 'image' &&
getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
})),
);
};
/**

View File

@ -59,8 +59,9 @@ export const queryKeys = {
// Tour Pages
tourPages: {
all: ['tourPages'] as const,
lists: () => [...queryKeys.tourPages.all, 'list'] as const,
list: (projectId: string, environment?: string) =>
[...queryKeys.tourPages.all, 'list', { projectId, environment }] as const,
[...queryKeys.tourPages.lists(), { projectId, environment }] as const,
detail: (id: string) => [...queryKeys.tourPages.all, 'detail', id] as const,
byProject: (projectId: string) =>
[...queryKeys.tourPages.all, 'byProject', projectId] as const,

View File

@ -131,7 +131,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
isLoading: isDataLoading,
isError: isDataError,
error: dataError,
refetch: refetchData,
refetchPages: refetchData,
} = useConstructorData({
projectId,
isAuthReady,
@ -749,7 +749,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
pagesCount={pages.length}
isElementEditMode={isElementEditMode}
pageElementsListHref={pageElementsListHref}
isSaving={isSaving}
isSaving={isSaving || isSavingToStage}
onSave={saveConstructor}
/>

View File

@ -5,6 +5,7 @@ import {
authenticate,
collectConsoleFailures,
mockFrontendApi,
testPages,
testProject,
} from './fixtures';
@ -96,7 +97,9 @@ test('constructor saves dev page changes and promotes them to stage', async ({
expect(saveToStageRequest.postDataJSON()).toEqual({
projectId: TEST_PROJECT_ID,
});
await expect(page.getByText('Saved to stage.')).toBeVisible();
await expect(
page.getByText(`Saved to stage: ${testPages.length} pages copied.`),
).toBeVisible();
consoleFailures.assertClean();
});

View File

@ -393,19 +393,26 @@ export async function mockFrontendApi(
if (path === '/publish/save-to-stage') {
return fulfillJson(route, {
success: true,
publishEventId: 'publish-event-stage',
status: 'success',
summary: {
pages_copied: testPages.length,
audios_copied: 0,
transition_settings_copied: 1,
ui_control_settings_copied: 1,
},
});
}
if (path === '/publish') {
return fulfillJson(route, {
success: true,
publishEventId: 'publish-event-production',
status: 'success',
summary: {
pages_copied: testPages.length,
transitions_copied: 0,
audios_copied: 0,
transition_settings_copied: 1,
ui_control_settings_copied: 1,
},
});
}