Fix 8.16 Lists with no items are deleted every time when board is opened. Moved migrations to right sidebar.

Thanks to xet7 !

Fixes #5994
This commit is contained in:
Lauri Ojansivu 2025-11-05 18:44:48 +02:00
parent 91a0aa7387
commit 7713e613b4
8 changed files with 1278 additions and 42 deletions

View file

@ -34,7 +34,6 @@ class ComprehensiveBoardMigration {
'fix_orphaned_cards',
'convert_shared_lists',
'ensure_per_swimlane_lists',
'cleanup_empty_lists',
'validate_migration'
];
}
@ -169,7 +168,6 @@ class ComprehensiveBoardMigration {
totalCardsProcessed: 0,
totalListsProcessed: 0,
totalListsCreated: 0,
totalListsRemoved: 0,
errors: []
};
@ -239,15 +237,7 @@ class ComprehensiveBoardMigration {
listsProcessed: results.steps.ensurePerSwimlane.listsProcessed
});
// Step 5: Cleanup empty lists
updateProgress('cleanup_empty_lists', 0, 'Cleaning up empty lists...');
results.steps.cleanupEmpty = await this.cleanupEmptyLists(boardId);
results.totalListsRemoved += results.steps.cleanupEmpty.listsRemoved || 0;
updateProgress('cleanup_empty_lists', 100, 'Empty lists cleaned up', {
listsRemoved: results.steps.cleanupEmpty.listsRemoved
});
// Step 6: Validate migration
// Step 5: Validate migration
updateProgress('validate_migration', 0, 'Validating migration...');
results.steps.validate = await this.validateMigration(boardId);
updateProgress('validate_migration', 100, 'Migration validated', {
@ -256,7 +246,7 @@ class ComprehensiveBoardMigration {
totalLists: results.steps.validate.totalLists
});
// Step 7: Fix avatar URLs
// Step 6: Fix avatar URLs
updateProgress('fix_avatar_urls', 0, 'Fixing avatar URLs...');
results.steps.fixAvatarUrls = await this.fixAvatarUrls(boardId);
updateProgress('fix_avatar_urls', 100, 'Avatar URLs fixed', {

View file

@ -0,0 +1,423 @@
/**
* Delete Duplicate Empty Lists Migration
*
* Safely deletes empty duplicate lists from a board:
* 1. First converts any shared lists to per-swimlane lists
* 2. Only deletes per-swimlane lists that:
* - Have no cards
* - Have another list with the same title on the same board that DOES have cards
* 3. This prevents deleting unique empty lists and only removes redundant duplicates
*/
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { ReactiveCache } from '/imports/reactiveCache';
import Boards from '/models/boards';
import Lists from '/models/lists';
import Cards from '/models/cards';
import Swimlanes from '/models/swimlanes';
class DeleteDuplicateEmptyListsMigration {
constructor() {
this.name = 'deleteDuplicateEmptyLists';
this.version = 1;
}
/**
* Check if migration is needed for a board
*/
needsMigration(boardId) {
try {
const lists = ReactiveCache.getLists({ boardId });
const cards = ReactiveCache.getCards({ boardId });
// Check if there are any empty lists that have a duplicate with the same title containing cards
for (const list of lists) {
// Skip shared lists
if (!list.swimlaneId || list.swimlaneId === '') {
continue;
}
// Check if list is empty
const listCards = cards.filter(card => card.listId === list._id);
if (listCards.length === 0) {
// Check if there's a duplicate list with the same title that has cards
const duplicateListsWithSameTitle = lists.filter(l =>
l._id !== list._id &&
l.title === list.title &&
l.boardId === boardId
);
for (const duplicateList of duplicateListsWithSameTitle) {
const duplicateListCards = cards.filter(card => card.listId === duplicateList._id);
if (duplicateListCards.length > 0) {
return true; // Found an empty list with a duplicate that has cards
}
}
}
}
return false;
} catch (error) {
console.error('Error checking if deleteEmptyLists migration is needed:', error);
return false;
}
}
/**
* Execute the migration
*/
async executeMigration(boardId) {
try {
const results = {
sharedListsConverted: 0,
listsDeleted: 0,
errors: []
};
// Step 1: Convert shared lists to per-swimlane lists first
const conversionResult = await this.convertSharedListsToPerSwimlane(boardId);
results.sharedListsConverted = conversionResult.listsConverted;
// Step 2: Delete empty per-swimlane lists
const deletionResult = await this.deleteEmptyPerSwimlaneLists(boardId);
results.listsDeleted = deletionResult.listsDeleted;
return {
success: true,
changes: [
`Converted ${results.sharedListsConverted} shared lists to per-swimlane lists`,
`Deleted ${results.listsDeleted} empty per-swimlane lists`
],
results
};
} catch (error) {
console.error('Error executing deleteEmptyLists migration:', error);
return {
success: false,
error: error.message
};
}
}
/**
* Convert shared lists (lists without swimlaneId) to per-swimlane lists
*/
async convertSharedListsToPerSwimlane(boardId) {
const lists = ReactiveCache.getLists({ boardId });
const swimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
const cards = ReactiveCache.getCards({ boardId });
let listsConverted = 0;
// Find shared lists (lists without swimlaneId)
const sharedLists = lists.filter(list => !list.swimlaneId || list.swimlaneId === '');
if (sharedLists.length === 0) {
return { listsConverted: 0 };
}
for (const sharedList of sharedLists) {
// Get cards in this shared list
const listCards = cards.filter(card => card.listId === sharedList._id);
// Group cards by swimlane
const cardsBySwimlane = {};
for (const card of listCards) {
const swimlaneId = card.swimlaneId || 'default';
if (!cardsBySwimlane[swimlaneId]) {
cardsBySwimlane[swimlaneId] = [];
}
cardsBySwimlane[swimlaneId].push(card);
}
// Create per-swimlane lists for each swimlane that has cards
for (const swimlane of swimlanes) {
const swimlaneCards = cardsBySwimlane[swimlane._id] || [];
if (swimlaneCards.length > 0) {
// Check if per-swimlane list already exists
const existingList = lists.find(l =>
l.title === sharedList.title &&
l.swimlaneId === swimlane._id &&
l._id !== sharedList._id
);
if (!existingList) {
// Create new per-swimlane list
const newListId = Lists.insert({
title: sharedList.title,
boardId: boardId,
swimlaneId: swimlane._id,
sort: sharedList.sort,
createdAt: new Date(),
updatedAt: new Date(),
archived: false
});
// Move cards to the new list
for (const card of swimlaneCards) {
Cards.update(card._id, {
$set: {
listId: newListId,
swimlaneId: swimlane._id
}
});
}
if (process.env.DEBUG === 'true') {
console.log(`Created per-swimlane list "${sharedList.title}" for swimlane ${swimlane.title || swimlane._id}`);
}
} else {
// Move cards to existing per-swimlane list
for (const card of swimlaneCards) {
Cards.update(card._id, {
$set: {
listId: existingList._id,
swimlaneId: swimlane._id
}
});
}
if (process.env.DEBUG === 'true') {
console.log(`Moved cards to existing per-swimlane list "${sharedList.title}" in swimlane ${swimlane.title || swimlane._id}`);
}
}
}
}
// Remove the shared list (now that all cards are moved)
Lists.remove(sharedList._id);
listsConverted++;
if (process.env.DEBUG === 'true') {
console.log(`Removed shared list "${sharedList.title}"`);
}
}
return { listsConverted };
}
/**
* Delete empty per-swimlane lists
* Only deletes lists that:
* 1. Have a swimlaneId (are per-swimlane, not shared)
* 2. Have no cards
* 3. Have a duplicate list with the same title on the same board that contains cards
*/
async deleteEmptyPerSwimlaneLists(boardId) {
const lists = ReactiveCache.getLists({ boardId });
const cards = ReactiveCache.getCards({ boardId });
let listsDeleted = 0;
for (const list of lists) {
// Safety check 1: List must have a swimlaneId (must be per-swimlane, not shared)
if (!list.swimlaneId || list.swimlaneId === '') {
if (process.env.DEBUG === 'true') {
console.log(`Skipping list "${list.title}" - no swimlaneId (shared list)`);
}
continue;
}
// Safety check 2: List must have no cards
const listCards = cards.filter(card => card.listId === list._id);
if (listCards.length > 0) {
if (process.env.DEBUG === 'true') {
console.log(`Skipping list "${list.title}" - has ${listCards.length} cards`);
}
continue;
}
// Safety check 3: There must be another list with the same title on the same board that has cards
const duplicateListsWithSameTitle = lists.filter(l =>
l._id !== list._id &&
l.title === list.title &&
l.boardId === boardId
);
let hasDuplicateWithCards = false;
for (const duplicateList of duplicateListsWithSameTitle) {
const duplicateListCards = cards.filter(card => card.listId === duplicateList._id);
if (duplicateListCards.length > 0) {
hasDuplicateWithCards = true;
break;
}
}
if (!hasDuplicateWithCards) {
if (process.env.DEBUG === 'true') {
console.log(`Skipping list "${list.title}" - no duplicate list with same title that has cards`);
}
continue;
}
// All safety checks passed - delete the empty per-swimlane list
Lists.remove(list._id);
listsDeleted++;
if (process.env.DEBUG === 'true') {
console.log(`Deleted empty per-swimlane list: "${list.title}" (swimlane: ${list.swimlaneId}) - duplicate with cards exists`);
}
}
return { listsDeleted };
}
/**
* Get detailed status of empty lists
*/
async getStatus(boardId) {
const lists = ReactiveCache.getLists({ boardId });
const cards = ReactiveCache.getCards({ boardId });
const sharedLists = [];
const emptyPerSwimlaneLists = [];
const nonEmptyLists = [];
for (const list of lists) {
const listCards = cards.filter(card => card.listId === list._id);
const isShared = !list.swimlaneId || list.swimlaneId === '';
const isEmpty = listCards.length === 0;
if (isShared) {
sharedLists.push({
id: list._id,
title: list.title,
cardCount: listCards.length
});
} else if (isEmpty) {
emptyPerSwimlaneLists.push({
id: list._id,
title: list.title,
swimlaneId: list.swimlaneId
});
} else {
nonEmptyLists.push({
id: list._id,
title: list.title,
swimlaneId: list.swimlaneId,
cardCount: listCards.length
});
}
}
return {
sharedListsCount: sharedLists.length,
emptyPerSwimlaneLists: emptyPerSwimlaneLists.length,
totalLists: lists.length,
details: {
sharedLists,
emptyPerSwimlaneLists,
nonEmptyLists
}
};
}
}
const deleteDuplicateEmptyListsMigration = new DeleteDuplicateEmptyListsMigration();
// Register Meteor methods
Meteor.methods({
'deleteEmptyLists.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
return deleteDuplicateEmptyListsMigration.needsMigration(boardId);
},
'deleteDuplicateEmptyLists.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
return deleteDuplicateEmptyListsMigration.needsMigration(boardId);
},
'deleteEmptyLists.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
// Check if user is board admin
const board = ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found');
}
const user = ReactiveCache.getUser(this.userId);
if (!user) {
throw new Meteor.Error('user-not-found', 'User not found');
}
// Only board admins can run migrations
const isBoardAdmin = board.members && board.members.some(
member => member.userId === this.userId && member.isAdmin
);
if (!isBoardAdmin && !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
}
return deleteDuplicateEmptyListsMigration.executeMigration(boardId);
},
'deleteDuplicateEmptyLists.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
// Check if user is board admin
const board = ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found');
}
const user = ReactiveCache.getUser(this.userId);
if (!user) {
throw new Meteor.Error('user-not-found', 'User not found');
}
// Only board admins can run migrations
const isBoardAdmin = board.members && board.members.some(
member => member.userId === this.userId && member.isAdmin
);
if (!isBoardAdmin && !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
}
return deleteDuplicateEmptyListsMigration.executeMigration(boardId);
},
'deleteEmptyLists.getStatus'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
return deleteDuplicateEmptyListsMigration.getStatus(boardId);
},
'deleteDuplicateEmptyLists.getStatus'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
return deleteDuplicateEmptyListsMigration.getStatus(boardId);
}
});
export default deleteDuplicateEmptyListsMigration;

View file

@ -0,0 +1,266 @@
/**
* Restore All Archived Migration
*
* Restores all archived swimlanes, lists, and cards.
* If any restored items are missing swimlaneId, listId, or cardId,
* creates/assigns proper IDs to make them visible.
*/
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import Boards from '/models/boards';
import Lists from '/models/lists';
import Cards from '/models/cards';
import Swimlanes from '/models/swimlanes';
class RestoreAllArchivedMigration {
constructor() {
this.name = 'restoreAllArchived';
this.version = 1;
}
/**
* Check if migration is needed for a board
*/
needsMigration(boardId) {
try {
const archivedSwimlanes = ReactiveCache.getSwimlanes({ boardId, archived: true });
const archivedLists = ReactiveCache.getLists({ boardId, archived: true });
const archivedCards = ReactiveCache.getCards({ boardId, archived: true });
return archivedSwimlanes.length > 0 || archivedLists.length > 0 || archivedCards.length > 0;
} catch (error) {
console.error('Error checking if restoreAllArchived migration is needed:', error);
return false;
}
}
/**
* Execute the migration
*/
async executeMigration(boardId) {
try {
const results = {
swimlanesRestored: 0,
listsRestored: 0,
cardsRestored: 0,
itemsFixed: 0,
errors: []
};
const board = ReactiveCache.getBoard(boardId);
if (!board) {
throw new Error('Board not found');
}
// Get archived items
const archivedSwimlanes = ReactiveCache.getSwimlanes({ boardId, archived: true });
const archivedLists = ReactiveCache.getLists({ boardId, archived: true });
const archivedCards = ReactiveCache.getCards({ boardId, archived: true });
// Get active items for reference
const activeSwimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
const activeLists = ReactiveCache.getLists({ boardId, archived: false });
// Restore all archived swimlanes
for (const swimlane of archivedSwimlanes) {
Swimlanes.update(swimlane._id, {
$set: {
archived: false,
updatedAt: new Date()
}
});
results.swimlanesRestored++;
if (process.env.DEBUG === 'true') {
console.log(`Restored swimlane: ${swimlane.title}`);
}
}
// Restore all archived lists and fix missing swimlaneId
for (const list of archivedLists) {
const updateFields = {
archived: false,
updatedAt: new Date()
};
// Fix missing swimlaneId
if (!list.swimlaneId) {
// Try to find a suitable swimlane or use default
let targetSwimlane = activeSwimlanes.find(s => !s.archived);
if (!targetSwimlane) {
// No active swimlane found, create default
const swimlaneId = Swimlanes.insert({
title: TAPi18n.__('default'),
boardId: boardId,
sort: 0,
createdAt: new Date(),
updatedAt: new Date(),
archived: false
});
targetSwimlane = ReactiveCache.getSwimlane(swimlaneId);
}
updateFields.swimlaneId = targetSwimlane._id;
results.itemsFixed++;
if (process.env.DEBUG === 'true') {
console.log(`Fixed missing swimlaneId for list: ${list.title}`);
}
}
Lists.update(list._id, {
$set: updateFields
});
results.listsRestored++;
if (process.env.DEBUG === 'true') {
console.log(`Restored list: ${list.title}`);
}
}
// Refresh lists after restoration
const allLists = ReactiveCache.getLists({ boardId, archived: false });
const allSwimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
// Restore all archived cards and fix missing IDs
for (const card of archivedCards) {
const updateFields = {
archived: false,
updatedAt: new Date()
};
let needsFix = false;
// Fix missing listId
if (!card.listId) {
// Find or create a default list
let targetList = allLists.find(l => !l.archived);
if (!targetList) {
// No active list found, create one
const defaultSwimlane = allSwimlanes.find(s => !s.archived) || allSwimlanes[0];
const listId = Lists.insert({
title: TAPi18n.__('default'),
boardId: boardId,
swimlaneId: defaultSwimlane._id,
sort: 0,
createdAt: new Date(),
updatedAt: new Date(),
archived: false
});
targetList = ReactiveCache.getList(listId);
}
updateFields.listId = targetList._id;
needsFix = true;
}
// Fix missing swimlaneId
if (!card.swimlaneId) {
// Try to get swimlaneId from the card's list
if (card.listId || updateFields.listId) {
const cardList = allLists.find(l => l._id === (updateFields.listId || card.listId));
if (cardList && cardList.swimlaneId) {
updateFields.swimlaneId = cardList.swimlaneId;
} else {
// Fall back to first available swimlane
const defaultSwimlane = allSwimlanes.find(s => !s.archived) || allSwimlanes[0];
updateFields.swimlaneId = defaultSwimlane._id;
}
} else {
// Fall back to first available swimlane
const defaultSwimlane = allSwimlanes.find(s => !s.archived) || allSwimlanes[0];
updateFields.swimlaneId = defaultSwimlane._id;
}
needsFix = true;
}
if (needsFix) {
results.itemsFixed++;
if (process.env.DEBUG === 'true') {
console.log(`Fixed missing IDs for card: ${card.title}`);
}
}
Cards.update(card._id, {
$set: updateFields
});
results.cardsRestored++;
if (process.env.DEBUG === 'true') {
console.log(`Restored card: ${card.title}`);
}
}
return {
success: true,
changes: [
`Restored ${results.swimlanesRestored} archived swimlanes`,
`Restored ${results.listsRestored} archived lists`,
`Restored ${results.cardsRestored} archived cards`,
`Fixed ${results.itemsFixed} items with missing IDs`
],
results
};
} catch (error) {
console.error('Error executing restoreAllArchived migration:', error);
return {
success: false,
error: error.message
};
}
}
}
const restoreAllArchivedMigration = new RestoreAllArchivedMigration();
// Register Meteor methods
Meteor.methods({
'restoreAllArchived.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
return restoreAllArchivedMigration.needsMigration(boardId);
},
'restoreAllArchived.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
// Check if user is board admin
const board = ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found');
}
const user = ReactiveCache.getUser(this.userId);
if (!user) {
throw new Meteor.Error('user-not-found', 'User not found');
}
// Only board admins can run migrations
const isBoardAdmin = board.members && board.members.some(
member => member.userId === this.userId && member.isAdmin
);
if (!isBoardAdmin && !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
}
return restoreAllArchivedMigration.executeMigration(boardId);
}
});
export default restoreAllArchivedMigration;

View file

@ -0,0 +1,259 @@
/**
* Restore Lost Cards Migration
*
* Finds and restores cards and lists that have missing swimlaneId, listId, or are orphaned.
* Creates a "Lost Cards" swimlane and restores visibility of lost items.
* Only processes non-archived items.
*/
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import Boards from '/models/boards';
import Lists from '/models/lists';
import Cards from '/models/cards';
import Swimlanes from '/models/swimlanes';
class RestoreLostCardsMigration {
constructor() {
this.name = 'restoreLostCards';
this.version = 1;
}
/**
* Check if migration is needed for a board
*/
needsMigration(boardId) {
try {
const cards = ReactiveCache.getCards({ boardId, archived: false });
const lists = ReactiveCache.getLists({ boardId, archived: false });
// Check for cards missing swimlaneId or listId
const lostCards = cards.filter(card => !card.swimlaneId || !card.listId);
if (lostCards.length > 0) {
return true;
}
// Check for lists missing swimlaneId
const lostLists = lists.filter(list => !list.swimlaneId);
if (lostLists.length > 0) {
return true;
}
// Check for orphaned cards (cards whose list doesn't exist)
for (const card of cards) {
if (card.listId) {
const listExists = lists.some(list => list._id === card.listId);
if (!listExists) {
return true;
}
}
}
return false;
} catch (error) {
console.error('Error checking if restoreLostCards migration is needed:', error);
return false;
}
}
/**
* Execute the migration
*/
async executeMigration(boardId) {
try {
const results = {
lostCardsSwimlaneCreated: false,
cardsRestored: 0,
listsRestored: 0,
errors: []
};
const board = ReactiveCache.getBoard(boardId);
if (!board) {
throw new Error('Board not found');
}
// Get all non-archived items
const cards = ReactiveCache.getCards({ boardId, archived: false });
const lists = ReactiveCache.getLists({ boardId, archived: false });
const swimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
// Detect items to restore BEFORE creating anything
const lostLists = lists.filter(list => !list.swimlaneId);
const lostCards = cards.filter(card => !card.swimlaneId || !card.listId);
const orphanedCards = cards.filter(card => card.listId && !lists.some(list => list._id === card.listId));
const hasCardsWork = lostCards.length > 0 || orphanedCards.length > 0;
const hasListsWork = lostLists.length > 0;
const hasAnyWork = hasCardsWork || hasListsWork;
if (!hasAnyWork) {
// Nothing to restore; do not create swimlane or list
return {
success: true,
changes: [
'No lost swimlanes, lists, or cards to restore'
],
results: {
lostCardsSwimlaneCreated: false,
cardsRestored: 0,
listsRestored: 0
}
};
}
// Find or create "Lost Cards" swimlane (only if there is actual work)
let lostCardsSwimlane = swimlanes.find(s => s.title === TAPi18n.__('lost-cards'));
if (!lostCardsSwimlane) {
const swimlaneId = Swimlanes.insert({
title: TAPi18n.__('lost-cards'),
boardId: boardId,
sort: 999999, // Put at the end
color: 'red',
createdAt: new Date(),
updatedAt: new Date(),
archived: false
});
lostCardsSwimlane = ReactiveCache.getSwimlane(swimlaneId);
results.lostCardsSwimlaneCreated = true;
if (process.env.DEBUG === 'true') {
console.log(`Created "Lost Cards" swimlane for board ${boardId}`);
}
}
// Restore lost lists (lists without swimlaneId)
if (hasListsWork) {
for (const list of lostLists) {
Lists.update(list._id, {
$set: {
swimlaneId: lostCardsSwimlane._id,
updatedAt: new Date()
}
});
results.listsRestored++;
if (process.env.DEBUG === 'true') {
console.log(`Restored lost list: ${list.title}`);
}
}
}
// Create default list only if we need to move cards
let defaultList = null;
if (hasCardsWork) {
defaultList = lists.find(l =>
l.swimlaneId === lostCardsSwimlane._id &&
l.title === TAPi18n.__('lost-cards-list')
);
if (!defaultList) {
const listId = Lists.insert({
title: TAPi18n.__('lost-cards-list'),
boardId: boardId,
swimlaneId: lostCardsSwimlane._id,
sort: 0,
createdAt: new Date(),
updatedAt: new Date(),
archived: false
});
defaultList = ReactiveCache.getList(listId);
if (process.env.DEBUG === 'true') {
console.log(`Created default list in Lost Cards swimlane`);
}
}
}
// Restore cards missing swimlaneId or listId
if (hasCardsWork) {
for (const card of lostCards) {
const updateFields = { updatedAt: new Date() };
if (!card.swimlaneId) updateFields.swimlaneId = lostCardsSwimlane._id;
if (!card.listId) updateFields.listId = defaultList._id;
Cards.update(card._id, { $set: updateFields });
results.cardsRestored++;
if (process.env.DEBUG === 'true') {
console.log(`Restored lost card: ${card.title}`);
}
}
// Restore orphaned cards (cards whose list doesn't exist)
for (const card of orphanedCards) {
Cards.update(card._id, {
$set: {
listId: defaultList._id,
swimlaneId: lostCardsSwimlane._id,
updatedAt: new Date()
}
});
results.cardsRestored++;
if (process.env.DEBUG === 'true') {
console.log(`Restored orphaned card: ${card.title}`);
}
}
}
return {
success: true,
changes: [
results.lostCardsSwimlaneCreated ? 'Created "Lost Cards" swimlane' : 'Using existing "Lost Cards" swimlane',
`Restored ${results.listsRestored} lost lists`,
`Restored ${results.cardsRestored} lost cards`
],
results
};
} catch (error) {
console.error('Error executing restoreLostCards migration:', error);
return {
success: false,
error: error.message
};
}
}
}
const restoreLostCardsMigration = new RestoreLostCardsMigration();
// Register Meteor methods
Meteor.methods({
'restoreLostCards.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
return restoreLostCardsMigration.needsMigration(boardId);
},
'restoreLostCards.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
// Check if user is board admin
const board = ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('board-not-found', 'Board not found');
}
const user = ReactiveCache.getUser(this.userId);
if (!user) {
throw new Meteor.Error('user-not-found', 'User not found');
}
// Only board admins can run migrations
const isBoardAdmin = board.members && board.members.some(
member => member.userId === this.userId && member.isAdmin
);
if (!isBoardAdmin && !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
}
return restoreLostCardsMigration.executeMigration(boardId);
}
});
export default restoreLostCardsMigration;