294 lines
8.0 KiB
JavaScript
294 lines
8.0 KiB
JavaScript
const db = require('../db/models');
|
|
const ProjectsDBApi = require('../db/api/projects');
|
|
const { createEntityService } = require('../factories/service.factory');
|
|
const ValidationError = require('./notifications/errors/validation');
|
|
|
|
// Generate base service from factory
|
|
const BaseProjectsService = createEntityService(ProjectsDBApi, {
|
|
entityName: 'Projects',
|
|
});
|
|
|
|
/**
|
|
* Projects service with slug validation and cloning functionality
|
|
* Extends factory-generated service with custom project logic
|
|
*/
|
|
class ProjectsService extends BaseProjectsService {
|
|
/**
|
|
* Normalize slug to URL-safe format
|
|
*/
|
|
static normalizeSlug(value) {
|
|
return (
|
|
String(value || 'project')
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '') || 'project'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generate unique slug for cloning
|
|
*/
|
|
static async generateUniqueSlug(baseSlug, transaction) {
|
|
const normalizedBase = ProjectsService.normalizeSlug(baseSlug);
|
|
let counter = 0;
|
|
let uniqueSlug = null;
|
|
|
|
while (uniqueSlug === null) {
|
|
const suffix = counter === 0 ? '-copy' : `-copy-${counter + 1}`;
|
|
const candidate = `${normalizedBase}${suffix}`;
|
|
|
|
const existing = await db.projects.findOne({
|
|
where: { slug: candidate },
|
|
paranoid: false,
|
|
transaction,
|
|
});
|
|
|
|
if (!existing) {
|
|
uniqueSlug = candidate;
|
|
} else {
|
|
counter += 1;
|
|
}
|
|
}
|
|
|
|
return uniqueSlug;
|
|
}
|
|
|
|
/**
|
|
* Validate slug uniqueness
|
|
*/
|
|
static async validateSlugUniqueness(slug, excludeId, transaction) {
|
|
const normalizedSlug = ProjectsService.normalizeSlug(slug);
|
|
|
|
const whereClause = { slug: normalizedSlug };
|
|
if (excludeId) {
|
|
whereClause.id = { [db.Sequelize.Op.ne]: excludeId };
|
|
}
|
|
|
|
const existing = await db.projects.findOne({
|
|
where: whereClause,
|
|
paranoid: false,
|
|
transaction,
|
|
});
|
|
|
|
if (existing) {
|
|
throw new ValidationError('iam.errors.slugAlreadyExists');
|
|
}
|
|
|
|
return normalizedSlug;
|
|
}
|
|
|
|
/**
|
|
* Create project with slug validation
|
|
*/
|
|
static async create(data, currentUser) {
|
|
const transaction = await db.sequelize.transaction();
|
|
try {
|
|
if (data.slug) {
|
|
data.slug = await ProjectsService.validateSlugUniqueness(
|
|
data.slug,
|
|
null,
|
|
transaction,
|
|
);
|
|
}
|
|
|
|
const project = await ProjectsDBApi.create(data, {
|
|
currentUser,
|
|
transaction,
|
|
});
|
|
|
|
await transaction.commit();
|
|
return project;
|
|
} catch (error) {
|
|
await transaction.rollback();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update project with slug validation
|
|
*/
|
|
static async update(data, id, currentUser) {
|
|
const transaction = await db.sequelize.transaction();
|
|
try {
|
|
const project = await ProjectsDBApi.findBy({ id }, { transaction });
|
|
|
|
if (!project) {
|
|
throw new ValidationError('projectsNotFound');
|
|
}
|
|
|
|
if (data.slug && data.slug !== project.slug) {
|
|
data.slug = await ProjectsService.validateSlugUniqueness(
|
|
data.slug,
|
|
id,
|
|
transaction,
|
|
);
|
|
}
|
|
|
|
const updated = await ProjectsDBApi.update(id, data, {
|
|
currentUser,
|
|
transaction,
|
|
});
|
|
|
|
await transaction.commit();
|
|
return updated;
|
|
} catch (error) {
|
|
await transaction.rollback();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clone project with all assets
|
|
*/
|
|
static async cloneFromProject(sourceProjectId, currentUser) {
|
|
const transaction = await db.sequelize.transaction();
|
|
|
|
try {
|
|
const sourceProject = await db.projects.findByPk(sourceProjectId, {
|
|
include: [
|
|
{
|
|
model: db.assets,
|
|
as: 'assets_project',
|
|
required: false,
|
|
include: [
|
|
{
|
|
model: db.asset_variants,
|
|
as: 'asset_variants_asset',
|
|
required: false,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
transaction,
|
|
});
|
|
|
|
if (!sourceProject) {
|
|
throw new ValidationError('projectsNotFound');
|
|
}
|
|
|
|
const uniqueSlug = await ProjectsService.generateUniqueSlug(
|
|
sourceProject.slug,
|
|
transaction,
|
|
);
|
|
|
|
const clonedProject = await ProjectsDBApi.create(
|
|
{
|
|
name: `${sourceProject.name} (Copy)`,
|
|
slug: uniqueSlug,
|
|
description: sourceProject.description,
|
|
logo_url: sourceProject.logo_url,
|
|
favicon_url: sourceProject.favicon_url,
|
|
og_image_url: sourceProject.og_image_url,
|
|
},
|
|
{ currentUser, transaction },
|
|
);
|
|
|
|
// Clone assets and variants
|
|
for (const sourceAsset of sourceProject.assets_project || []) {
|
|
const clonedAsset = await db.assets.create(
|
|
{
|
|
name: sourceAsset.name,
|
|
asset_type: sourceAsset.asset_type,
|
|
type: sourceAsset.type || 'general',
|
|
cdn_url: sourceAsset.cdn_url,
|
|
storage_key: sourceAsset.storage_key,
|
|
mime_type: sourceAsset.mime_type,
|
|
size_mb: sourceAsset.size_mb,
|
|
width_px: sourceAsset.width_px,
|
|
height_px: sourceAsset.height_px,
|
|
duration_sec: sourceAsset.duration_sec,
|
|
checksum: sourceAsset.checksum,
|
|
is_public: sourceAsset.is_public,
|
|
projectId: clonedProject.id,
|
|
createdById: currentUser.id,
|
|
updatedById: currentUser.id,
|
|
},
|
|
{ transaction },
|
|
);
|
|
|
|
for (const sourceVariant of sourceAsset.asset_variants_asset || []) {
|
|
await db.asset_variants.create(
|
|
{
|
|
variant_type: sourceVariant.variant_type,
|
|
cdn_url: sourceVariant.cdn_url,
|
|
width_px: sourceVariant.width_px,
|
|
height_px: sourceVariant.height_px,
|
|
size_mb: sourceVariant.size_mb,
|
|
assetId: clonedAsset.id,
|
|
createdById: currentUser.id,
|
|
updatedById: currentUser.id,
|
|
},
|
|
{ transaction },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Clone tour pages (dev environment only - stage/production are populated via publishing)
|
|
const sourcePages = await db.tour_pages.findAll({
|
|
where: { projectId: sourceProjectId, environment: 'dev' },
|
|
transaction,
|
|
});
|
|
|
|
for (const sourcePage of sourcePages) {
|
|
const pageData = sourcePage.toJSON();
|
|
// Remove fields that should be regenerated
|
|
delete pageData.id;
|
|
delete pageData.createdAt;
|
|
delete pageData.updatedAt;
|
|
delete pageData.deletedAt;
|
|
delete pageData.deletedBy;
|
|
delete pageData.importHash;
|
|
|
|
await db.tour_pages.create(
|
|
{
|
|
...pageData,
|
|
projectId: clonedProject.id,
|
|
environment: 'dev',
|
|
source_key: sourcePage.id, // Link back to original page
|
|
createdById: currentUser.id,
|
|
updatedById: currentUser.id,
|
|
},
|
|
{ transaction },
|
|
);
|
|
}
|
|
|
|
// Clone audio tracks (dev environment only)
|
|
const sourceAudioTracks = await db.project_audio_tracks.findAll({
|
|
where: { projectId: sourceProjectId, environment: 'dev' },
|
|
transaction,
|
|
});
|
|
|
|
for (const sourceTrack of sourceAudioTracks) {
|
|
const trackData = sourceTrack.toJSON();
|
|
delete trackData.id;
|
|
delete trackData.createdAt;
|
|
delete trackData.updatedAt;
|
|
delete trackData.deletedAt;
|
|
delete trackData.deletedBy;
|
|
delete trackData.importHash;
|
|
|
|
await db.project_audio_tracks.create(
|
|
{
|
|
...trackData,
|
|
projectId: clonedProject.id,
|
|
environment: 'dev',
|
|
source_key: sourceTrack.id,
|
|
createdById: currentUser.id,
|
|
updatedById: currentUser.id,
|
|
},
|
|
{ transaction },
|
|
);
|
|
}
|
|
|
|
await transaction.commit();
|
|
return clonedProject;
|
|
} catch (error) {
|
|
await transaction.rollback();
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = ProjectsService;
|