mirror of
https://github.com/wekan/wekan.git
synced 2025-12-16 07:20:12 +01:00
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:
parent
91a0aa7387
commit
7713e613b4
8 changed files with 1278 additions and 42 deletions
|
|
@ -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', {
|
||||
|
|
|
|||
423
server/migrations/deleteDuplicateEmptyLists.js
Normal file
423
server/migrations/deleteDuplicateEmptyLists.js
Normal 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;
|
||||
266
server/migrations/restoreAllArchived.js
Normal file
266
server/migrations/restoreAllArchived.js
Normal 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;
|
||||
259
server/migrations/restoreLostCards.js
Normal file
259
server/migrations/restoreLostCards.js
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue