diff --git a/client/components/boards/boardBody.js b/client/components/boards/boardBody.js index 4e14d8001..b0af16e43 100644 --- a/client/components/boards/boardBody.js +++ b/client/components/boards/boardBody.js @@ -146,7 +146,6 @@ BlazeComponent.extendComponent({ { step: 'fix_orphaned_cards', name: 'Fix Orphaned Cards', duration: 2000 }, { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 3000 }, { step: 'ensure_per_swimlane_lists', name: 'Ensure Per-Swimlane Lists', duration: 1500 }, - { step: 'cleanup_empty_lists', name: 'Cleanup Empty Lists', duration: 1000 }, { step: 'validate_migration', name: 'Validate Migration', duration: 1000 }, { step: 'fix_avatar_urls', name: 'Fix Avatar URLs', duration: 1000 }, { step: 'fix_attachment_urls', name: 'Fix Attachment URLs', duration: 1000 } diff --git a/client/components/sidebar/sidebarMigrations.jade b/client/components/sidebar/sidebarMigrations.jade index 5666b36c1..78da56983 100644 --- a/client/components/sidebar/sidebarMigrations.jade +++ b/client/components/sidebar/sidebarMigrations.jade @@ -28,6 +28,36 @@ template(name='migrationsSidebar') else span.badge.badge-success {{_ 'migration-complete'}} + .migration-item + a.js-run-migration(data-migration="deleteDuplicateEmptyLists") + .migration-name + | {{_ 'delete-duplicate-empty-lists-migration'}} + .migration-status + if deleteDuplicateEmptyListsNeeded + span.badge.badge-warning {{_ 'migration-needed'}} + else + span.badge.badge-success {{_ 'migration-complete'}} + + .migration-item + a.js-run-migration(data-migration="restoreLostCards") + .migration-name + | {{_ 'restore-lost-cards-migration'}} + .migration-status + if restoreLostCardsNeeded + span.badge.badge-warning {{_ 'migration-needed'}} + else + span.badge.badge-success {{_ 'migration-complete'}} + + .migration-item + a.js-run-migration(data-migration="restoreAllArchived") + .migration-name + | {{_ 'restore-all-archived-migration'}} + .migration-status + if restoreAllArchivedNeeded + span.badge.badge-warning {{_ 'migration-needed'}} + else + span.badge.badge-success {{_ 'migration-complete'}} + hr h4 {{_ 'global-migrations'}} .migration-item @@ -60,6 +90,18 @@ template(name='runFixMissingListsMigrationPopup') p {{_ 'run-fix-missing-lists-migration-confirm'}} button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}} +template(name='runDeleteDuplicateEmptyListsMigrationPopup') + p {{_ 'run-delete-duplicate-empty-lists-migration-confirm'}} + button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}} + +template(name='runRestoreLostCardsMigrationPopup') + p {{_ 'run-restore-lost-cards-migration-confirm'}} + button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}} + +template(name='runRestoreAllArchivedMigrationPopup') + p {{_ 'run-restore-all-archived-migration-confirm'}} + button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}} + template(name='runFixAvatarUrlsMigrationPopup') p {{_ 'run-fix-avatar-urls-migration-confirm'}} button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}} diff --git a/client/components/sidebar/sidebarMigrations.js b/client/components/sidebar/sidebarMigrations.js index c8f58a081..cc47b27cf 100644 --- a/client/components/sidebar/sidebarMigrations.js +++ b/client/components/sidebar/sidebarMigrations.js @@ -1,5 +1,6 @@ import { ReactiveCache } from '/imports/reactiveCache'; import { TAPi18n } from '/imports/i18n'; +import { migrationProgressManager } from '/client/components/migrationProgress'; BlazeComponent.extendComponent({ onCreated() { @@ -29,11 +30,38 @@ BlazeComponent.extendComponent({ } }); - // Check fix avatar URLs migration (global) - Meteor.call('fixAvatarUrls.needsMigration', (err, res) => { + // Check delete duplicate empty lists migration + Meteor.call('deleteDuplicateEmptyLists.needsMigration', boardId, (err, res) => { if (!err) { const statuses = this.migrationStatuses.get(); - statuses.fixAvatarUrls = res; + statuses.deleteDuplicateEmptyLists = res; + this.migrationStatuses.set(statuses); + } + }); + + // Check restore lost cards migration + Meteor.call('restoreLostCards.needsMigration', boardId, (err, res) => { + if (!err) { + const statuses = this.migrationStatuses.get(); + statuses.restoreLostCards = res; + this.migrationStatuses.set(statuses); + } + }); + + // Check restore all archived migration + Meteor.call('restoreAllArchived.needsMigration', boardId, (err, res) => { + if (!err) { + const statuses = this.migrationStatuses.get(); + statuses.restoreAllArchived = res; + this.migrationStatuses.set(statuses); + } + }); + + // Check fix avatar URLs migration (global) + Meteor.call('fixAvatarUrls.needsMigration', (err, res) => { + if (!err) { + const statuses = this.migrationStatuses.get(); + statuses.fixAvatarUrls = res; this.migrationStatuses.set(statuses); } }); @@ -56,6 +84,22 @@ BlazeComponent.extendComponent({ return this.migrationStatuses.get().fixMissingLists === true; }, + deleteEmptyListsNeeded() { + return this.migrationStatuses.get().deleteEmptyLists === true; + }, + + deleteDuplicateEmptyListsNeeded() { + return this.migrationStatuses.get().deleteDuplicateEmptyLists === true; + }, + + restoreLostCardsNeeded() { + return this.migrationStatuses.get().restoreLostCards === true; + }, + + restoreAllArchivedNeeded() { + return this.migrationStatuses.get().restoreAllArchived === true; + }, + fixAvatarUrlsNeeded() { return this.migrationStatuses.get().fixAvatarUrls === true; }, @@ -64,6 +108,58 @@ BlazeComponent.extendComponent({ return this.migrationStatuses.get().fixAllFileUrls === true; }, + // Simulate migration progress updates using the global progress popup + async simulateMigrationProgress(progressSteps) { + const totalSteps = progressSteps.length; + for (let i = 0; i < progressSteps.length; i++) { + const step = progressSteps[i]; + const overall = Math.round(((i + 1) / totalSteps) * 100); + + // Start step + migrationProgressManager.updateProgress({ + overallProgress: overall, + currentStep: i + 1, + totalSteps, + stepName: step.step, + stepProgress: 0, + stepStatus: `Starting ${step.name}...`, + stepDetails: null, + boardId: Session.get('currentBoard'), + }); + + const stepDuration = step.duration; + const updateInterval = 100; + const totalUpdates = Math.max(1, Math.floor(stepDuration / updateInterval)); + for (let j = 0; j < totalUpdates; j++) { + const per = Math.round(((j + 1) / totalUpdates) * 100); + migrationProgressManager.updateProgress({ + overallProgress: overall, + currentStep: i + 1, + totalSteps, + stepName: step.step, + stepProgress: per, + stepStatus: `Processing ${step.name}...`, + stepDetails: { progress: `${per}%` }, + boardId: Session.get('currentBoard'), + }); + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setTimeout(r, updateInterval)); + } + + // Complete step + migrationProgressManager.updateProgress({ + overallProgress: overall, + currentStep: i + 1, + totalSteps, + stepName: step.step, + stepProgress: 100, + stepStatus: `${step.name} completed`, + stepDetails: { status: 'completed' }, + boardId: Session.get('currentBoard'), + }); + } + }, + runMigration(migrationType) { const boardId = Session.get('currentBoard'); @@ -81,6 +177,26 @@ BlazeComponent.extendComponent({ methodArgs = [boardId]; break; + case 'deleteEmptyLists': + methodName = 'deleteEmptyLists.execute'; + methodArgs = [boardId]; + break; + + case 'deleteDuplicateEmptyLists': + methodName = 'deleteDuplicateEmptyLists.execute'; + methodArgs = [boardId]; + break; + + case 'restoreLostCards': + methodName = 'restoreLostCards.execute'; + methodArgs = [boardId]; + break; + + case 'restoreAllArchived': + methodName = 'restoreAllArchived.execute'; + methodArgs = [boardId]; + break; + case 'fixAvatarUrls': methodName = 'fixAvatarUrls.execute'; break; @@ -91,17 +207,104 @@ BlazeComponent.extendComponent({ } if (methodName) { - Meteor.call(methodName, ...methodArgs, (err, result) => { - if (err) { - console.error('Migration failed:', err); - // Show error notification - Alert.error(TAPi18n.__('migration-failed') + ': ' + (err.message || err.reason)); + // Define simulated steps per migration type + const stepsByType = { + comprehensive: [ + { step: 'analyze_board_structure', name: 'Analyze Board Structure', duration: 800 }, + { step: 'fix_orphaned_cards', name: 'Fix Orphaned Cards', duration: 1200 }, + { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 1000 }, + { step: 'ensure_per_swimlane_lists', name: 'Ensure Per-Swimlane Lists', duration: 800 }, + { step: 'validate_migration', name: 'Validate Migration', duration: 800 }, + { step: 'fix_avatar_urls', name: 'Fix Avatar URLs', duration: 600 }, + { step: 'fix_attachment_urls', name: 'Fix Attachment URLs', duration: 600 }, + ], + fixMissingLists: [ + { step: 'analyze_lists', name: 'Analyze Lists', duration: 600 }, + { step: 'create_missing_lists', name: 'Create Missing Lists', duration: 900 }, + { step: 'update_cards', name: 'Update Cards', duration: 900 }, + { step: 'finalize', name: 'Finalize', duration: 400 }, + ], + deleteEmptyLists: [ + { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 700 }, + { step: 'delete_empty_lists', name: 'Delete Empty Lists', duration: 800 }, + ], + deleteDuplicateEmptyLists: [ + { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 700 }, + { step: 'delete_duplicate_empty_lists', name: 'Delete Duplicate Empty Lists', duration: 800 }, + ], + restoreLostCards: [ + { step: 'ensure_lost_cards_swimlane', name: 'Ensure Lost Cards Swimlane', duration: 600 }, + { step: 'restore_lists', name: 'Restore Lists', duration: 800 }, + { step: 'restore_cards', name: 'Restore Cards', duration: 1000 }, + ], + restoreAllArchived: [ + { step: 'restore_swimlanes', name: 'Restore Swimlanes', duration: 800 }, + { step: 'restore_lists', name: 'Restore Lists', duration: 900 }, + { step: 'restore_cards', name: 'Restore Cards', duration: 1000 }, + { step: 'fix_missing_ids', name: 'Fix Missing IDs', duration: 600 }, + ], + fixAvatarUrls: [ + { step: 'scan_users', name: 'Scan Users', duration: 500 }, + { step: 'fix_urls', name: 'Fix Avatar URLs', duration: 900 }, + ], + fixAllFileUrls: [ + { step: 'scan_files', name: 'Scan Files', duration: 600 }, + { step: 'fix_urls', name: 'Fix File URLs', duration: 1000 }, + ], + }; + + const steps = stepsByType[migrationType] || [ + { step: 'running', name: 'Running Migration', duration: 1000 }, + ]; + + // Kick off popup and simulated progress + migrationProgressManager.startMigration(); + const progressPromise = this.simulateMigrationProgress(steps); + + // Start migration call + const callPromise = new Promise((resolve, reject) => { + Meteor.call(methodName, ...methodArgs, (err, result) => { + if (err) return reject(err); + return resolve(result); + }); + }); + + Promise.allSettled([callPromise, progressPromise]).then(([callRes]) => { + if (callRes.status === 'rejected') { + migrationProgressManager.failMigration(callRes.reason); } else { - console.log('Migration completed:', result); - // Show success notification - Alert.success(TAPi18n.__('migration-successful')); - - // Reload migration statuses + const result = callRes.value; + // Summarize result details in the popup + let summary = {}; + if (result && result.results) { + // Comprehensive returns {success, results} + const r = result.results; + summary = { + totalCardsProcessed: r.totalCardsProcessed, + totalListsProcessed: r.totalListsProcessed, + totalListsCreated: r.totalListsCreated, + }; + } else if (result && result.changes) { + // Many migrations return a changes string array + summary = { changes: result.changes.join(' | ') }; + } else if (result && typeof result === 'object') { + summary = result; + } + + migrationProgressManager.updateProgress({ + overallProgress: 100, + currentStep: steps.length, + totalSteps: steps.length, + stepName: 'completed', + stepProgress: 100, + stepStatus: 'Migration completed', + stepDetails: summary, + boardId: Session.get('currentBoard'), + }); + + migrationProgressManager.completeMigration(); + + // Refresh status badges slightly after Meteor.setTimeout(() => { this.loadMigrationStatuses(); }, 1000); @@ -111,31 +314,41 @@ BlazeComponent.extendComponent({ }, events() { + const self = this; // Capture component reference + return [ { 'click .js-run-migration[data-migration="comprehensive"]': Popup.afterConfirm('runComprehensiveMigration', function() { - const component = BlazeComponent.getComponentForElement(this); - if (component) { - component.runMigration('comprehensive'); - } + self.runMigration('comprehensive'); + Popup.back(); }), 'click .js-run-migration[data-migration="fixMissingLists"]': Popup.afterConfirm('runFixMissingListsMigration', function() { - const component = BlazeComponent.getComponentForElement(this); - if (component) { - component.runMigration('fixMissingLists'); - } + self.runMigration('fixMissingLists'); + Popup.back(); + }), + 'click .js-run-migration[data-migration="deleteEmptyLists"]': Popup.afterConfirm('runDeleteEmptyListsMigration', function() { + self.runMigration('deleteEmptyLists'); + Popup.back(); + }), + 'click .js-run-migration[data-migration="deleteDuplicateEmptyLists"]': Popup.afterConfirm('runDeleteDuplicateEmptyListsMigration', function() { + self.runMigration('deleteDuplicateEmptyLists'); + Popup.back(); + }), + 'click .js-run-migration[data-migration="restoreLostCards"]': Popup.afterConfirm('runRestoreLostCardsMigration', function() { + self.runMigration('restoreLostCards'); + Popup.back(); + }), + 'click .js-run-migration[data-migration="restoreAllArchived"]': Popup.afterConfirm('runRestoreAllArchivedMigration', function() { + self.runMigration('restoreAllArchived'); + Popup.back(); }), 'click .js-run-migration[data-migration="fixAvatarUrls"]': Popup.afterConfirm('runFixAvatarUrlsMigration', function() { - const component = BlazeComponent.getComponentForElement(this); - if (component) { - component.runMigration('fixAvatarUrls'); - } + self.runMigration('fixAvatarUrls'); + Popup.back(); }), 'click .js-run-migration[data-migration="fixAllFileUrls"]': Popup.afterConfirm('runFixAllFileUrlsMigration', function() { - const component = BlazeComponent.getComponentForElement(this); - if (component) { - component.runMigration('fixAllFileUrls'); - } + self.runMigration('fixAllFileUrls'); + Popup.back(); }), }, ]; diff --git a/imports/i18n/data/en.i18n.json b/imports/i18n/data/en.i18n.json index e19bd8ef1..c007af213 100644 --- a/imports/i18n/data/en.i18n.json +++ b/imports/i18n/data/en.i18n.json @@ -1408,6 +1408,16 @@ "card-show-lists-on-minicard": "Show Lists on Minicard", "comprehensive-board-migration": "Comprehensive Board Migration", "comprehensive-board-migration-description": "Performs comprehensive checks and fixes for board data integrity, including list ordering, card positions, and swimlane structure.", + "delete-empty-lists-migration": "Delete Empty Lists", + "delete-empty-lists-migration-description": "Safely deletes empty duplicate lists. Only removes lists that have no cards AND have another list with the same title that contains cards.", + "delete-duplicate-empty-lists-migration": "Delete Duplicate Empty Lists", + "delete-duplicate-empty-lists-migration-description": "Safely deletes empty duplicate lists. Only removes lists that have no cards AND have another list with the same title that contains cards.", + "lost-cards": "Lost Cards", + "lost-cards-list": "Restored Items", + "restore-lost-cards-migration": "Restore Lost Cards", + "restore-lost-cards-migration-description": "Finds and restores cards and lists with missing swimlaneId or listId. Creates a 'Lost Cards' swimlane to make all lost items visible again.", + "restore-all-archived-migration": "Restore All Archived", + "restore-all-archived-migration-description": "Restores all archived swimlanes, lists, and cards. Automatically fixes any missing swimlaneId or listId to make items visible.", "fix-missing-lists-migration": "Fix Missing Lists", "fix-missing-lists-migration-description": "Detects and repairs missing or corrupted lists in the board structure.", "fix-avatar-urls-migration": "Fix Avatar URLs", @@ -1426,9 +1436,43 @@ "no-issues-found": "No issues found", "run-migration": "Run Migration", "run-comprehensive-migration-confirm": "This will perform a comprehensive migration to check and fix board data integrity. This may take a few moments. Continue?", + "run-delete-empty-lists-migration-confirm": "This will first convert any shared lists to per-swimlane lists, then delete empty lists that have a duplicate list with the same title containing cards. Only truly redundant empty lists will be removed. Continue?", + "run-delete-duplicate-empty-lists-migration-confirm": "This will first convert any shared lists to per-swimlane lists, then delete empty lists that have a duplicate list with the same title containing cards. Only truly redundant empty lists will be removed. Continue?", + "run-restore-lost-cards-migration-confirm": "This will create a 'Lost Cards' swimlane and restore all cards and lists with missing swimlaneId or listId. This only affects non-archived items. Continue?", + "run-restore-all-archived-migration-confirm": "This will restore ALL archived swimlanes, lists, and cards, making them visible again. Any items with missing IDs will be automatically fixed. This cannot be easily undone. Continue?", "run-fix-missing-lists-migration-confirm": "This will detect and repair missing or corrupted lists in the board structure. Continue?", "run-fix-avatar-urls-migration-confirm": "This will update avatar URLs across all boards to use the correct storage backend. This is a global operation. Continue?", "run-fix-all-file-urls-migration-confirm": "This will update all file attachment URLs across all boards to use the correct storage backend. This is a global operation. Continue?", + "restore-lost-cards-nothing-to-restore": "No lost swimlanes, lists, or cards to restore", + + "migration-progress-title": "Board Migration in Progress", + "migration-progress-overall": "Overall Progress", + "migration-progress-current-step": "Current Step", + "migration-progress-status": "Status", + "migration-progress-details": "Details", + "migration-progress-note": "Please wait while we migrate your board to the latest structure...", + + "step-analyze-board-structure": "Analyze Board Structure", + "step-fix-orphaned-cards": "Fix Orphaned Cards", + "step-convert-shared-lists": "Convert Shared Lists", + "step-ensure-per-swimlane-lists": "Ensure Per-Swimlane Lists", + "step-validate-migration": "Validate Migration", + "step-fix-avatar-urls": "Fix Avatar URLs", + "step-fix-attachment-urls": "Fix Attachment URLs", + "step-analyze-lists": "Analyze Lists", + "step-create-missing-lists": "Create Missing Lists", + "step-update-cards": "Update Cards", + "step-finalize": "Finalize", + "step-delete-empty-lists": "Delete Empty Lists", + "step-delete-duplicate-empty-lists": "Delete Duplicate Empty Lists", + "step-ensure-lost-cards-swimlane": "Ensure Lost Cards Swimlane", + "step-restore-lists": "Restore Lists", + "step-restore-cards": "Restore Cards", + "step-restore-swimlanes": "Restore Swimlanes", + "step-fix-missing-ids": "Fix Missing IDs", + "step-scan-users": "Scan Users", + "step-scan-files": "Scan Files", + "step-fix-file-urls": "Fix File URLs", "cleanup": "Cleanup", "cleanup-old-jobs": "Cleanup Old Jobs", "completed": "Completed", diff --git a/server/migrations/comprehensiveBoardMigration.js b/server/migrations/comprehensiveBoardMigration.js index f9ea7c523..e8bfc7469 100644 --- a/server/migrations/comprehensiveBoardMigration.js +++ b/server/migrations/comprehensiveBoardMigration.js @@ -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', { diff --git a/server/migrations/deleteDuplicateEmptyLists.js b/server/migrations/deleteDuplicateEmptyLists.js new file mode 100644 index 000000000..c0d455685 --- /dev/null +++ b/server/migrations/deleteDuplicateEmptyLists.js @@ -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; diff --git a/server/migrations/restoreAllArchived.js b/server/migrations/restoreAllArchived.js new file mode 100644 index 000000000..825f9a2f4 --- /dev/null +++ b/server/migrations/restoreAllArchived.js @@ -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; diff --git a/server/migrations/restoreLostCards.js b/server/migrations/restoreLostCards.js new file mode 100644 index 000000000..781caa0fb --- /dev/null +++ b/server/migrations/restoreLostCards.js @@ -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;