diff --git a/backend/docs/api-endpoints.md b/backend/docs/api-endpoints.md index 661c60a..4840d53 100644 --- a/backend/docs/api-endpoints.md +++ b/backend/docs/api-endpoints.md @@ -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 --- diff --git a/backend/docs/modules/db-seeders.md b/backend/docs/modules/db-seeders.md index 7f7ee1a..7c92409 100644 --- a/backend/docs/modules/db-seeders.md +++ b/backend/docs/modules/db-seeders.md @@ -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 | diff --git a/backend/docs/modules/routes.md b/backend/docs/modules/routes.md index 8ee1fb0..9007df3 100644 --- a/backend/docs/modules/routes.md +++ b/backend/docs/modules/routes.md @@ -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) diff --git a/backend/docs/modules/services.md b/backend/docs/modules/services.md index 0f37e6e..2a0b2d2 100644 --- a/backend/docs/modules/services.md +++ b/backend/docs/modules/services.md @@ -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:** ``` diff --git a/backend/src/db/api/runtime-asset-access.ts b/backend/src/db/api/runtime-asset-access.ts index d14c980..54a54d3 100644 --- a/backend/src/db/api/runtime-asset-access.ts +++ b/backend/src/db/api/runtime-asset-access.ts @@ -23,10 +23,11 @@ function isRuntimeAssetModel(value: unknown): value is ModelStatic { ); } -function getRuntimeAssetModel(value: unknown, name: string): ModelStatic { - if ( - !isRuntimeAssetModel(value) - ) { +function getRuntimeAssetModel( + value: unknown, + name: string, +): ModelStatic { + if (!isRuntimeAssetModel(value)) { throw new Error(`Database model '${name}' is unavailable.`); } diff --git a/backend/src/db/seeders/20231127130745-sample-data.ts b/backend/src/db/seeders/20231127130745-sample-data.ts index c1e597d..83f7b88 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.ts +++ b/backend/src/db/seeders/20231127130745-sample-data.ts @@ -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, }, ]; diff --git a/backend/src/openapi/document.ts b/backend/src/openapi/document.ts index b91739d..dfbbd6f 100644 --- a/backend/src/openapi/document.ts +++ b/backend/src/openapi/document.ts @@ -918,12 +918,30 @@ const schemas: Record = { 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: { diff --git a/backend/src/routes/file.ts b/backend/src/routes/file.ts index 6b2e0fb..e43c140 100644 --- a/backend/src/routes/file.ts +++ b/backend/src/routes/file.ts @@ -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) diff --git a/backend/src/routes/publish.ts b/backend/src/routes/publish.ts index 66fd9e5..d5c2281 100644 --- a/backend/src/routes/publish.ts +++ b/backend/src/routes/publish.ts @@ -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', diff --git a/backend/src/services/publish.ts b/backend/src/services/publish.ts index 4afa7d1..4efbd27 100644 --- a/backend/src/services/publish.ts +++ b/backend/src/services/publish.ts @@ -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 { - 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, diff --git a/backend/src/services/runtime-asset-access.ts b/backend/src/services/runtime-asset-access.ts index 874ba4b..1063198 100644 --- a/backend/src/services/runtime-asset-access.ts +++ b/backend/src/services/runtime-asset-access.ts @@ -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): void { +function collectStringReferences( + value: unknown, + references: Set, +): 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) { diff --git a/backend/src/services/tour_pages.ts b/backend/src/services/tour_pages.ts index b50d533..2b695fb 100644 --- a/backend/src/services/tour_pages.ts +++ b/backend/src/services/tour_pages.ts @@ -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; } diff --git a/backend/src/types/publish.ts b/backend/src/types/publish.ts index cb1a334..d3c4ee5 100644 --- a/backend/src/types/publish.ts +++ b/backend/src/types/publish.ts @@ -29,6 +29,7 @@ export interface PublishToProductionResult { export interface SaveToStageResult { success: true; publishEventId: string; + summary: PublishSummary; } export type PublishEventStatus = 'queued' | 'running' | 'success' | 'failed'; diff --git a/backend/src/utils/env-validation.ts b/backend/src/utils/env-validation.ts index 82e9e01..1c454aa 100644 --- a/backend/src/utils/env-validation.ts +++ b/backend/src/utils/env-validation.ts @@ -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'), diff --git a/backend/tests/openapi-document.test.ts b/backend/tests/openapi-document.test.ts index 9157aa0..607df65 100644 --- a/backend/tests/openapi-document.test.ts +++ b/backend/tests/openapi-document.test.ts @@ -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'; diff --git a/backend/tests/publish-service.test.ts b/backend/tests/publish-service.test.ts new file mode 100644 index 0000000..850972a --- /dev/null +++ b/backend/tests/publish-service.test.ts @@ -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> = []; + let releaseCopy: (() => void) | undefined; + const copyGate = new Promise((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((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> = []; + 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, + ); + } +}); diff --git a/backend/tests/security-hardening.test.ts b/backend/tests/security-hardening.test.ts index 1084f73..5db412a 100644 --- a/backend/tests/security-hardening.test.ts +++ b/backend/tests/security-hardening.test.ts @@ -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'); }); diff --git a/backend/tests/update-contracts.test.ts b/backend/tests/update-contracts.test.ts index 436d8d5..689e196 100644 --- a/backend/tests/update-contracts.test.ts +++ b/backend/tests/update-contracts.test.ts @@ -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 { @@ -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 = { diff --git a/documentation/api-reference.md b/documentation/api-reference.md index 7718145..884ca97 100644 --- a/documentation/api-reference.md +++ b/documentation/api-reference.md @@ -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 } } ``` diff --git a/documentation/page-transitions.md b/documentation/page-transitions.md index 66ab945..adb65e4 100644 --- a/documentation/page-transitions.md +++ b/documentation/page-transitions.md @@ -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 diff --git a/documentation/publishing-workflow.md b/documentation/publishing-workflow.md index bbfa4ca..73f3585 100644 --- a/documentation/publishing-workflow.md +++ b/documentation/publishing-workflow.md @@ -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 diff --git a/frontend/docs/constructor-page-editor.md b/frontend/docs/constructor-page-editor.md index 8b2d457..f22112e 100644 --- a/frontend/docs/constructor-page-editor.md +++ b/frontend/docs/constructor-page-editor.md @@ -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 diff --git a/frontend/docs/hooks-module.md b/frontend/docs/hooks-module.md index 92a57c3..fc1d67d 100644 --- a/frontend/docs/hooks-module.md +++ b/frontend/docs/hooks-module.md @@ -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; + onReload: (preservePageId?: string) => Promise; } interface UseConstructorPageActionsResult { @@ -1205,7 +1218,7 @@ interface UseConstructorPageActionsResult { isCreatingPage: boolean; isDuplicatingPage: boolean; saveConstructor: () => Promise; - saveToStage: () => Promise; + saveToStage: () => Promise; createPage: (name: string, slug: string) => Promise; duplicatePage: (sourcePageId: string, name: string, slug: string) => Promise; } @@ -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 diff --git a/frontend/docs/hooks-reference.md b/frontend/docs/hooks-reference.md index cb18fc9..196b461 100644 --- a/frontend/docs/hooks-reference.md +++ b/frontend/docs/hooks-reference.md @@ -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` | 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` | Save current state | -| saveToStage | `() => Promise` | Save dev → stage | -| createPage | `() => Promise` | Create new page | -| createTransition | `(params) => Promise` | Create transition (legacy) | +| isDuplicatingPage | `boolean` | Page duplication in progress | +| saveConstructor | `() => Promise` | Save current state and report success | +| saveToStage | `() => Promise` | Save Dev, commit Dev → Stage, and report success | +| createPage | `(name, slug) => Promise` | Create a Dev page | +| duplicatePage | `(sourcePageId, name, slug) => Promise` | Duplicate a Dev page | **Example:** diff --git a/frontend/docs/pages-module.md b/frontend/docs/pages-module.md index 4db0876..1266479 100644 --- a/frontend/docs/pages-module.md +++ b/frontend/docs/pages-module.md @@ -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 */} diff --git a/frontend/src/components/Constructor/ConstructorToolbar.helpers.test.ts b/frontend/src/components/Constructor/ConstructorToolbar.helpers.test.ts index b1e0b7c..39386ee 100644 --- a/frontend/src/components/Constructor/ConstructorToolbar.helpers.test.ts +++ b/frontend/src/components/Constructor/ConstructorToolbar.helpers.test.ts @@ -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: [ diff --git a/frontend/src/components/Constructor/ConstructorToolbar.helpers.ts b/frontend/src/components/Constructor/ConstructorToolbar.helpers.ts index 0ca67d9..56c55fe 100644 --- a/frontend/src/components/Constructor/ConstructorToolbar.helpers.ts +++ b/frontend/src/components/Constructor/ConstructorToolbar.helpers.ts @@ -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, diff --git a/frontend/src/components/Constructor/ConstructorToolbar.tsx b/frontend/src/components/Constructor/ConstructorToolbar.tsx index 3b6ebdd..fadf666 100644 --- a/frontend/src/components/Constructor/ConstructorToolbar.tsx +++ b/frontend/src/components/Constructor/ConstructorToolbar.tsx @@ -158,6 +158,7 @@ const ConstructorToolbar = forwardRef( onSelectMenuItem={onSelectMenuItem} isReorderingPages={isReorderingPages} isCreatingPage={isCreatingPage} + isPresentationSaving={isSaving || isSavingToStage} canMovePageUp={actionState.canMovePageUp} canMovePageDown={actionState.canMovePageDown} canDuplicatePage={actionState.canDuplicatePage} diff --git a/frontend/src/components/Constructor/ConstructorToolbarLayer.tsx b/frontend/src/components/Constructor/ConstructorToolbarLayer.tsx index 2a4c072..3934097 100644 --- a/frontend/src/components/Constructor/ConstructorToolbarLayer.tsx +++ b/frontend/src/components/Constructor/ConstructorToolbarLayer.tsx @@ -94,6 +94,8 @@ const ConstructorToolbarLayer = ({ return null; } + const isPresentationSaving = isSaving || isSavingToStage; + return ( 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' />
- {isBackgroundDropdownActive && ( + {isBackgroundDropdownActive && !isPresentationSaving && ( +
diff --git a/frontend/src/components/Constructor/useConstructorPageWorkflow.ts b/frontend/src/components/Constructor/useConstructorPageWorkflow.ts index 3fbf385..9cc7e6c 100644 --- a/frontend/src/components/Constructor/useConstructorPageWorkflow.ts +++ b/frontend/src/components/Constructor/useConstructorPageWorkflow.ts @@ -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 { diff --git a/frontend/src/hooks/queries/usePagesQuery.ts b/frontend/src/hooks/queries/usePagesQuery.ts index 068e1cd..b08ee39 100644 --- a/frontend/src/hooks/queries/usePagesQuery.ts +++ b/frontend/src/hooks/queries/usePagesQuery.ts @@ -14,6 +14,16 @@ interface PagesListResponse { count: number; } +type UpdatePageData = Omit, 'ui_schema_json'> & { + ui_schema_json?: string | Record; +}; + +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; + data: UpdatePageData; }): Promise => { const response = await axios.put(`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( + { queryKey: queryKeys.tourPages.lists() }, + (pages) => replaceTourPageInList(pages, data), + ); }, }); } diff --git a/frontend/src/hooks/useAssetOptions.ts b/frontend/src/hooks/useAssetOptions.ts index a5a5df0..39cdfd9 100644 --- a/frontend/src/hooks/useAssetOptions.ts +++ b/frontend/src/hooks/useAssetOptions.ts @@ -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], ); diff --git a/frontend/src/hooks/useConstructorData.ts b/frontend/src/hooks/useConstructorData.ts index 72fe4f4..de66ae2 100644 --- a/frontend/src/hooks/useConstructorData.ts +++ b/frontend/src/hooks/useConstructorData.ts @@ -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; + // Refetch mutable constructor page data + refetchPages: () => Promise; } 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, }; } diff --git a/frontend/src/hooks/useConstructorPageActions.ts b/frontend/src/hooks/useConstructorPageActions.ts index 3df806a..785b3ea 100644 --- a/frontend/src/hooks/useConstructorPageActions.ts +++ b/frontend/src/hooks/useConstructorPageActions.ts @@ -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; /** Save dev content to stage environment */ - saveToStage: () => Promise; + saveToStage: () => Promise; /** Create a new page with the given name and slug */ createPage: (pageName: string, slug: string) => Promise; /** 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( + '/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); } diff --git a/frontend/src/hooks/usePagesQuery.test.ts b/frontend/src/hooks/usePagesQuery.test.ts new file mode 100644 index 0000000..994e79b --- /dev/null +++ b/frontend/src/hooks/usePagesQuery.test.ts @@ -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); +}); diff --git a/frontend/src/lib/constructorHelpers.test.ts b/frontend/src/lib/constructorHelpers.test.ts new file mode 100644 index 0000000..9107277 --- /dev/null +++ b/frontend/src/lib/constructorHelpers.test.ts @@ -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' }], + ); +}); diff --git a/frontend/src/lib/constructorHelpers.ts b/frontend/src/lib/constructorHelpers.ts index 0d521c1..67254b5 100644 --- a/frontend/src/lib/constructorHelpers.ts +++ b/frontend/src/lib/constructorHelpers.ts @@ -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(); + 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), + })), + ); }; /** diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts index 6882205..0c08261 100644 --- a/frontend/src/lib/queryClient.ts +++ b/frontend/src/lib/queryClient.ts @@ -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, diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index c3521ae..f610728 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -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} /> diff --git a/frontend/tests/e2e/constructor.spec.ts b/frontend/tests/e2e/constructor.spec.ts index 26ca18c..ebd70a9 100644 --- a/frontend/tests/e2e/constructor.spec.ts +++ b/frontend/tests/e2e/constructor.spec.ts @@ -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(); }); diff --git a/frontend/tests/e2e/fixtures.ts b/frontend/tests/e2e/fixtures.ts index 882dc92..8ceb066 100644 --- a/frontend/tests/e2e/fixtures.ts +++ b/frontend/tests/e2e/fixtures.ts @@ -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, }, }); }