334 lines
8.9 KiB
JavaScript
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,
|
|
};
|
|
}
|
|
};
|