wekan/server/migrations/ensureValidSwimlaneIds.js
2025-12-23 08:01:30 +02:00

286 lines
8.2 KiB
JavaScript

/**
* Migration: Ensure all entities have valid swimlaneId
*
* This migration ensures that:
* 1. All cards have a valid swimlaneId
* 2. All lists have a valid swimlaneId (if applicable)
* 3. Orphaned entities (without valid swimlaneId) are moved to a "Rescued Data" swimlane
*
* This is similar to the existing rescue migration but specifically for swimlaneId validation
*/
// Helper collection to track migrations - must be defined first
const Migrations = new Mongo.Collection('migrations');
Meteor.startup(() => {
// Only run on server
if (!Meteor.isServer) return;
const MIGRATION_NAME = 'ensure-valid-swimlane-ids';
const MIGRATION_VERSION = 1;
// Check if migration already ran
const existingMigration = Migrations.findOne({ name: MIGRATION_NAME });
if (existingMigration && existingMigration.version >= MIGRATION_VERSION) {
return;
}
console.log(`Running migration: ${MIGRATION_NAME} v${MIGRATION_VERSION}`);
/**
* Get or create a "Rescued Data" swimlane for a board
*/
function getOrCreateRescuedSwimlane(boardId) {
const board = Boards.findOne(boardId);
if (!board) return null;
// Look for existing rescued data swimlane
let rescuedSwimlane = Swimlanes.findOne({
boardId,
title: { $regex: /rescued.*data/i },
});
if (!rescuedSwimlane) {
// Create a new rescued data swimlane
const swimlaneId = Swimlanes.insert({
title: 'Rescued Data (Missing Swimlane)',
boardId,
archived: false,
sort: 9999999, // Put at the end
type: 'swimlane',
color: 'red',
});
rescuedSwimlane = Swimlanes.findOne(swimlaneId);
Activities.insert({
userId: 'migration',
type: 'swimlane',
activityType: 'createSwimlane',
boardId,
swimlaneId,
title: 'Created rescued data swimlane during migration',
});
}
return rescuedSwimlane;
}
/**
* Validate and fix cards without valid swimlaneId
*/
function fixCardsWithoutSwimlaneId() {
let fixedCount = 0;
let rescuedCount = 0;
const cardsWithoutSwimlane = Cards.find({
$or: [
{ swimlaneId: { $exists: false } },
{ swimlaneId: null },
{ swimlaneId: '' },
],
}).fetch();
console.log(`Found ${cardsWithoutSwimlane.length} cards without swimlaneId`);
cardsWithoutSwimlane.forEach(card => {
const board = Boards.findOne(card.boardId);
if (!board) {
console.warn(`Card ${card._id} has invalid boardId: ${card.boardId}`);
return;
}
// Try to get default swimlane
let defaultSwimlane = Swimlanes.findOne({
boardId: card.boardId,
type: { $ne: 'template-swimlane' },
archived: false,
}, { sort: { sort: 1 } });
if (!defaultSwimlane) {
// No swimlanes at all - create default
const swimlaneId = Swimlanes.insert({
title: 'Default',
boardId: card.boardId,
archived: false,
sort: 0,
type: 'swimlane',
});
defaultSwimlane = Swimlanes.findOne(swimlaneId);
}
if (defaultSwimlane) {
Cards.update(card._id, {
$set: { swimlaneId: defaultSwimlane._id },
});
fixedCount++;
} else {
console.warn(`Could not find or create default swimlane for card ${card._id}`);
}
});
return { fixedCount, rescuedCount };
}
/**
* Validate and fix lists without valid swimlaneId
*/
function fixListsWithoutSwimlaneId() {
let fixedCount = 0;
const listsWithoutSwimlane = Lists.find({
$or: [
{ swimlaneId: { $exists: false } },
{ swimlaneId: null },
],
}).fetch();
console.log(`Found ${listsWithoutSwimlane.length} lists without swimlaneId`);
listsWithoutSwimlane.forEach(list => {
// Set to empty string for backward compatibility
// (lists can be shared across swimlanes)
Lists.update(list._id, {
$set: { swimlaneId: '' },
});
fixedCount++;
});
return { fixedCount };
}
/**
* Find and rescue orphaned cards (swimlaneId points to non-existent swimlane)
*/
function rescueOrphanedCards() {
let rescuedCount = 0;
const allCards = Cards.find({}).fetch();
allCards.forEach(card => {
if (!card.swimlaneId) return; // Handled by fixCardsWithoutSwimlaneId
// Check if swimlane exists
const swimlane = Swimlanes.findOne(card.swimlaneId);
if (!swimlane) {
// Orphaned card - swimlane doesn't exist
const rescuedSwimlane = getOrCreateRescuedSwimlane(card.boardId);
if (rescuedSwimlane) {
Cards.update(card._id, {
$set: { swimlaneId: rescuedSwimlane._id },
});
rescuedCount++;
Activities.insert({
userId: 'migration',
type: 'card',
activityType: 'moveCard',
boardId: card.boardId,
cardId: card._id,
swimlaneId: rescuedSwimlane._id,
listId: card.listId,
title: `Rescued card from deleted swimlane`,
});
}
}
});
return { rescuedCount };
}
/**
* Ensure all swimlaneId references are always saved in all operations
* This adds a global hook to validate swimlaneId before insert/update
*/
function addSwimlaneIdValidationHooks() {
// Card insert hook
Cards.before.insert(function(userId, doc) {
if (!doc.swimlaneId) {
const board = Boards.findOne(doc.boardId);
if (board) {
const defaultSwimlane = Swimlanes.findOne({
boardId: doc.boardId,
type: { $ne: 'template-swimlane' },
archived: false,
}, { sort: { sort: 1 } });
if (defaultSwimlane) {
doc.swimlaneId = defaultSwimlane._id;
} else {
console.warn('No default swimlane found for new card, creating one');
const swimlaneId = Swimlanes.insert({
title: 'Default',
boardId: doc.boardId,
archived: false,
sort: 0,
type: 'swimlane',
});
doc.swimlaneId = swimlaneId;
}
}
}
});
// Card update hook - ensure swimlaneId is never removed
Cards.before.update(function(userId, doc, fieldNames, modifier) {
if (modifier.$unset && modifier.$unset.swimlaneId) {
delete modifier.$unset.swimlaneId;
console.warn('Prevented removal of swimlaneId from card', doc._id);
}
if (modifier.$set && modifier.$set.swimlaneId === null) {
const defaultSwimlane = Swimlanes.findOne({
boardId: doc.boardId,
type: { $ne: 'template-swimlane' },
archived: false,
}, { sort: { sort: 1 } });
if (defaultSwimlane) {
modifier.$set.swimlaneId = defaultSwimlane._id;
}
}
});
}
try {
// Run all fix operations
const cardResults = fixCardsWithoutSwimlaneId();
const listResults = fixListsWithoutSwimlaneId();
const rescueResults = rescueOrphanedCards();
console.log('Migration results:');
console.log(`- Fixed ${cardResults.fixedCount} cards without swimlaneId`);
console.log(`- Fixed ${listResults.fixedCount} lists without swimlaneId`);
console.log(`- Rescued ${rescueResults.rescuedCount} orphaned cards`);
// Record migration completion
Migrations.upsert(
{ name: MIGRATION_NAME },
{
$set: {
name: MIGRATION_NAME,
version: MIGRATION_VERSION,
completedAt: new Date(),
results: {
cardsFixed: cardResults.fixedCount,
listsFixed: listResults.fixedCount,
cardsRescued: rescueResults.rescuedCount,
},
},
}
);
console.log(`Migration ${MIGRATION_NAME} completed successfully`);
} catch (error) {
console.error(`Migration ${MIGRATION_NAME} failed:`, error);
}
// Add validation hooks (outside try-catch to ensure they run even if migration failed)
try {
addSwimlaneIdValidationHooks();
console.log('SwimlaneId validation hooks installed');
} catch (error) {
console.error('Failed to install swimlaneId validation hooks:', error);
}
});