const db = require('../db/models'); const EVENT_STATUS = { QUEUED: 'queued', RUNNING: 'running', SUCCESS: 'success', FAILED: 'failed', }; const ENVIRONMENT = { DEV: 'dev', STAGE: 'stage', PRODUCTION: 'production', }; const sanitizeRecordForClone = (modelInstance) => { const data = modelInstance.toJSON(); delete data.id; delete data.createdAt; delete data.updatedAt; delete data.deletedAt; delete data.deletedBy; delete data.importHash; // Ensure JSON fields are objects, not strings (avoid double-encoding) if (data.ui_schema_json && typeof data.ui_schema_json === 'string') { try { data.ui_schema_json = JSON.parse(data.ui_schema_json); } catch { // Keep as-is if parsing fails } } return data; }; module.exports = class PublishService { static async withProjectPublishLock(projectId, callback) { return db.sequelize.transaction(async (transaction) => { const project = await db.projects.findByPk(projectId, { transaction, lock: transaction.LOCK.UPDATE, }); if (!project) { const error = new Error('Project not found'); error.code = 404; throw error; } const runningEvent = await db.publish_events.findOne({ where: { projectId, status: EVENT_STATUS.RUNNING, }, order: [['createdAt', 'DESC']], transaction, lock: transaction.LOCK.UPDATE, }); if (runningEvent) { const error = new Error('Publish is already running for this project'); error.code = 400; throw error; } return callback(transaction); }); } static async publishToProduction(projectId, currentUser, title, description) { if (!projectId) { const error = new Error('projectId is required'); error.code = 400; throw error; } const eventTitle = typeof title === 'string' ? title.trim() : ''; const eventDescription = typeof description === 'string' ? description.trim() : ''; if (!eventTitle) { const error = new Error('title is required'); error.code = 400; throw error; } if (!eventDescription) { const error = new Error('description is required'); error.code = 400; throw error; } const publishEvent = await db.publish_events.create({ projectId, userId: currentUser?.id || null, title: eventTitle, description: eventDescription, from_environment: ENVIRONMENT.STAGE, to_environment: ENVIRONMENT.PRODUCTION, status: EVENT_STATUS.QUEUED, createdById: currentUser?.id || null, updatedById: currentUser?.id || null, }); try { const summary = await this.withProjectPublishLock( projectId, async (transaction) => { await publishEvent.update( { started_at: new Date(), status: EVENT_STATUS.RUNNING, error_message: null, updatedById: currentUser?.id || null, }, { transaction }, ); return this.copyStageToProduction( projectId, currentUser, transaction, ); }, ); await publishEvent.update({ status: EVENT_STATUS.SUCCESS, finished_at: new Date(), pages_copied: summary.pages_copied, audios_copied: summary.audios_copied, error_message: null, updatedById: currentUser?.id || null, }); return { success: true, publishEventId: publishEvent.id, summary, }; } catch (error) { await publishEvent.update({ status: EVENT_STATUS.FAILED, finished_at: new Date(), error_message: error.message, updatedById: currentUser?.id || null, }); throw error; } } /** * Save dev content to stage environment (non-blocking) * Returns immediately, processing continues in background. */ static async saveToStage(projectId, currentUser) { if (!projectId) { const error = new Error('projectId is required'); error.code = 400; throw error; } const publishEvent = await db.publish_events.create({ projectId, userId: currentUser?.id || null, title: 'Save to Stage', description: 'Copy dev content to stage environment', from_environment: ENVIRONMENT.DEV, to_environment: ENVIRONMENT.STAGE, status: EVENT_STATUS.QUEUED, createdById: currentUser?.id || null, updatedById: currentUser?.id || null, }); // Process in background setImmediate(async () => { try { const summary = await this.withProjectPublishLock( projectId, async (transaction) => { await publishEvent.update( { started_at: new Date(), status: EVENT_STATUS.RUNNING, updatedById: currentUser?.id || null, }, { transaction }, ); return this.copyDevToStage(projectId, currentUser, transaction); }, ); await publishEvent.update({ status: EVENT_STATUS.SUCCESS, finished_at: new Date(), pages_copied: summary.pages_copied, audios_copied: summary.audios_copied, updatedById: currentUser?.id || null, }); } catch (error) { await publishEvent.update({ status: EVENT_STATUS.FAILED, finished_at: new Date(), error_message: error.message, updatedById: currentUser?.id || null, }); console.error('[SaveToStage] Background error:', error); } }); return { success: true, publishEventId: publishEvent.id }; } /** * Copy dev content to stage environment */ static async copyDevToStage(projectId, currentUser, transaction) { return this.copyEnvironment( projectId, ENVIRONMENT.DEV, ENVIRONMENT.STAGE, currentUser, transaction, ); } static async copyStageToProduction(projectId, currentUser, transaction) { return this.copyEnvironment( projectId, ENVIRONMENT.STAGE, ENVIRONMENT.PRODUCTION, currentUser, transaction, ); } /** * Generic method to copy content from one environment to another. * Used for both dev->stage and stage->production flows. * * SIMPLIFIED: Now uses targetPageSlug for navigation which is consistent across environments. * No ID remapping needed since slugs are the same in all environments. * * @param {string} projectId - Project ID * @param {string} fromEnv - Source environment (dev, stage) * @param {string} toEnv - Target environment (stage, production) * @param {object} currentUser - Current user for audit fields * @param {object} transaction - Sequelize transaction * @returns {object} Summary of copied items */ static async copyEnvironment( projectId, fromEnv, toEnv, currentUser, transaction, ) { // Get source content const [sourcePages, sourceAudioTracks] = await Promise.all([ db.tour_pages.findAll({ where: { projectId, environment: fromEnv }, transaction, }), db.project_audio_tracks.findAll({ where: { projectId, environment: fromEnv }, transaction, }), ]); // Clean up target environment (hard delete - paranoid models need force: true) await Promise.all([ db.tour_pages.destroy({ where: { projectId, environment: toEnv }, transaction, force: true, }), db.project_audio_tracks.destroy({ where: { projectId, environment: toEnv }, transaction, force: true, }), ]); const actorId = currentUser?.id || null; // Create target pages - ui_schema_json uses targetPageSlug which is consistent across environments const targetPagesPayload = sourcePages.map((sourcePage) => { const data = sanitizeRecordForClone(sourcePage); return { ...data, projectId, environment: toEnv, source_key: sourcePage.id, createdById: actorId, updatedById: actorId, }; }); // Create target audio tracks const targetAudioPayload = sourceAudioTracks.map((sourceAudio) => { const data = sanitizeRecordForClone(sourceAudio); return { ...data, projectId, environment: toEnv, source_key: sourceAudio.id, createdById: actorId, updatedById: actorId, }; }); // Bulk create pages and audio tracks if (targetPagesPayload.length) { await db.tour_pages.bulkCreate(targetPagesPayload, { transaction, returning: false, }); } if (targetAudioPayload.length) { await db.project_audio_tracks.bulkCreate(targetAudioPayload, { transaction, returning: false, }); } return { pages_copied: sourcePages.length, audios_copied: sourceAudioTracks.length, }; } };