2026-04-14 18:23:04 +04:00

334 lines
8.9 KiB
JavaScript

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,
};
}
};