2026-04-11 14:32:54 +04:00

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;