diff --git a/backend/src/db/api/project_element_defaults.js b/backend/src/db/api/project_element_defaults.js index b56c316..aaeea2c 100644 --- a/backend/src/db/api/project_element_defaults.js +++ b/backend/src/db/api/project_element_defaults.js @@ -239,12 +239,26 @@ class Project_element_defaultsDBApi extends GenericDBApi { return []; } + // Dedupe by element_type (keep first occurrence) + // Prevents unique constraint violations if global defaults have duplicates + const seenTypes = new Set(); + const dedupedDefaults = globalDefaults.rows.filter((row) => { + if (seenTypes.has(row.element_type)) { + console.warn( + `Duplicate element_type in global defaults: ${row.element_type} (skipping)`, + ); + return false; + } + seenTypes.add(row.element_type); + return true; + }); + const now = new Date(); const currentUserId = options.currentUser?.id || null; // Create project defaults from global defaults const projectDefaults = await this.MODEL.bulkCreate( - globalDefaults.rows.map((globalDefault) => ({ + dedupedDefaults.map((globalDefault) => ({ projectId, element_type: globalDefault.element_type, name: globalDefault.name, diff --git a/backend/src/db/api/projects.js b/backend/src/db/api/projects.js index 9dff528..e57adc9 100644 --- a/backend/src/db/api/projects.js +++ b/backend/src/db/api/projects.js @@ -116,19 +116,12 @@ class ProjectsDBApi extends GenericDBApi { const project = await super.create(data, options); // Auto-snapshot global element defaults to the new project - try { - const Project_element_defaultsDBApi = require('./project_element_defaults'); - await Project_element_defaultsDBApi.snapshotGlobalDefaults(project.id, { - ...options, - transaction, - }); - } catch (error) { - // Log but don't fail project creation if snapshot fails - console.error( - 'Failed to snapshot global element defaults to project:', - error, - ); - } + // Errors propagate to service layer → transaction rollback → proper error to client + const Project_element_defaultsDBApi = require('./project_element_defaults'); + await Project_element_defaultsDBApi.snapshotGlobalDefaults(project.id, { + ...options, + transaction, + }); return project; } diff --git a/backend/src/db/migrations/20260331054340-remove-duplicate-element-type-defaults.js b/backend/src/db/migrations/20260331054340-remove-duplicate-element-type-defaults.js new file mode 100644 index 0000000..94bfb1b --- /dev/null +++ b/backend/src/db/migrations/20260331054340-remove-duplicate-element-type-defaults.js @@ -0,0 +1,65 @@ +'use strict'; + +/** + * Remove duplicate element_type_defaults rows. + * Keeps the oldest entry (by createdAt) for each element_type. + * This fixes the unique constraint violation during project creation. + */ +module.exports = { + async up(queryInterface, Sequelize) { + // Find duplicate element_types + const duplicates = await queryInterface.sequelize.query( + `SELECT element_type, COUNT(*) as count + FROM element_type_defaults + WHERE "deletedAt" IS NULL + GROUP BY element_type + HAVING COUNT(*) > 1`, + { type: Sequelize.QueryTypes.SELECT }, + ); + + if (duplicates.length === 0) { + console.log('No duplicate element_type_defaults found.'); + return; + } + + console.log( + `Found ${duplicates.length} element_types with duplicates:`, + duplicates.map((d) => d.element_type).join(', '), + ); + + // For each duplicate element_type, keep oldest and delete others + for (const dup of duplicates) { + // Get all rows for this element_type, ordered by createdAt + const rows = await queryInterface.sequelize.query( + `SELECT id, "createdAt" + FROM element_type_defaults + WHERE element_type = :element_type AND "deletedAt" IS NULL + ORDER BY "createdAt" ASC`, + { + replacements: { element_type: dup.element_type }, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + // Keep the first (oldest), delete the rest + const idsToDelete = rows.slice(1).map((r) => r.id); + + if (idsToDelete.length > 0) { + await queryInterface.sequelize.query( + `DELETE FROM element_type_defaults WHERE id IN (:ids)`, + { replacements: { ids: idsToDelete } }, + ); + console.log( + `Deleted ${idsToDelete.length} duplicate(s) for element_type: ${dup.element_type}`, + ); + } + } + + console.log('Duplicate removal complete.'); + }, + + async down(_queryInterface, _Sequelize) { + // Cannot restore deleted duplicates + console.log('Down migration not applicable - duplicates cannot be restored.'); + }, +};