diff --git a/client/components/boards/boardBody.js b/client/components/boards/boardBody.js index 7f8883d0b..630a5fa8c 100644 --- a/client/components/boards/boardBody.js +++ b/client/components/boards/boardBody.js @@ -115,6 +115,9 @@ BlazeComponent.extendComponent({ // Convert shared lists to per-swimlane lists if needed await this.convertSharedListsToPerSwimlane(boardId); + // Fix missing lists migration (for cards with wrong listId references) + await this.fixMissingLists(boardId); + // Start attachment migration in background if needed this.startAttachmentMigrationIfNeeded(boardId); } catch (error) { @@ -236,6 +239,61 @@ BlazeComponent.extendComponent({ } }, + async fixMissingLists(boardId) { + try { + const board = ReactiveCache.getBoard(boardId); + if (!board) return; + + // Check if board has already been processed for missing lists fix + if (board.fixMissingListsCompleted) { + if (process.env.DEBUG === 'true') { + console.log(`Board ${boardId} has already been processed for missing lists fix`); + } + return; + } + + // Check if migration is needed + const needsMigration = await new Promise((resolve, reject) => { + Meteor.call('fixMissingListsMigration.needsMigration', boardId, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); + + if (!needsMigration) { + if (process.env.DEBUG === 'true') { + console.log(`Board ${boardId} does not need missing lists fix`); + } + return; + } + + if (process.env.DEBUG === 'true') { + console.log(`Starting fix missing lists migration for board ${boardId}`); + } + + // Execute the migration + const result = await new Promise((resolve, reject) => { + Meteor.call('fixMissingListsMigration.execute', boardId, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); + + if (result && result.success) { + console.log(`Successfully fixed missing lists for board ${boardId}: created ${result.createdLists} lists, updated ${result.updatedCards} cards`); + } + + } catch (error) { + console.error('Error fixing missing lists:', error); + } + }, + async startAttachmentMigrationIfNeeded(boardId) { try { // Check if board has already been migrated diff --git a/models/boards.js b/models/boards.js index d08e7fe9e..bcb456c6c 100644 --- a/models/boards.js +++ b/models/boards.js @@ -803,13 +803,6 @@ Boards.helpers({ { boardId: this._id, archived: false, - // Get lists for all swimlanes in this board, plus lists without swimlaneId for backward compatibility - $or: [ - { swimlaneId: { $in: this.swimlanes().map(s => s._id) } }, - { swimlaneId: { $exists: false } }, - { swimlaneId: '' }, - { swimlaneId: null } - ], }, { sort: sortKey }, ); @@ -819,13 +812,6 @@ Boards.helpers({ return ReactiveCache.getLists( { boardId: this._id, - // Get lists for all swimlanes in this board, plus lists without swimlaneId for backward compatibility - $or: [ - { swimlaneId: { $in: this.swimlanes().map(s => s._id) } }, - { swimlaneId: { $exists: false } }, - { swimlaneId: '' }, - { swimlaneId: null } - ] }, { sort: { sort: 1 } } ); diff --git a/models/swimlanes.js b/models/swimlanes.js index 5b379a603..012e56f21 100644 --- a/models/swimlanes.js +++ b/models/swimlanes.js @@ -211,33 +211,20 @@ Swimlanes.helpers({ return this.draggableLists(); }, newestLists() { - // sorted lists from newest to the oldest, by its creation date or its cards' last modification date - // Include lists without swimlaneId for backward compatibility (they belong to default swimlane) + // Revert to shared lists across swimlanes: filter by board only return ReactiveCache.getLists( { boardId: this.boardId, - $or: [ - { swimlaneId: this._id }, - { swimlaneId: { $exists: false } }, - { swimlaneId: '' }, - { swimlaneId: null } - ], archived: false, }, { sort: { modifiedAt: -1 } }, ); }, draggableLists() { - // Include lists without swimlaneId for backward compatibility (they belong to default swimlane) + // Revert to shared lists across swimlanes: filter by board only return ReactiveCache.getLists( { boardId: this.boardId, - $or: [ - { swimlaneId: this._id }, - { swimlaneId: { $exists: false } }, - { swimlaneId: '' }, - { swimlaneId: null } - ], //archived: false, }, { sort: ['sort'] }, @@ -245,7 +232,8 @@ Swimlanes.helpers({ }, myLists() { - return ReactiveCache.getLists({ swimlaneId: this._id }); + // Revert to shared lists: provide lists by board for this swimlane's board + return ReactiveCache.getLists({ boardId: this.boardId }); }, allCards() { diff --git a/server/00checkStartup.js b/server/00checkStartup.js index 2ff0f2bcc..d7035dca8 100644 --- a/server/00checkStartup.js +++ b/server/00checkStartup.js @@ -40,6 +40,9 @@ if (errors.length > 0) { // Import cron job storage for persistent job tracking import './cronJobStorage'; +// Import migrations +import './migrations/fixMissingListsMigration'; + // Note: Automatic migrations are disabled - migrations only run when opening boards // import './boardMigrationDetector'; // import './cronMigrationManager'; diff --git a/server/migrations/fixMissingListsMigration.js b/server/migrations/fixMissingListsMigration.js new file mode 100644 index 000000000..392a09c74 --- /dev/null +++ b/server/migrations/fixMissingListsMigration.js @@ -0,0 +1,266 @@ +/** + * Fix Missing Lists Migration + * + * This migration fixes the issue where cards have incorrect listId references + * due to the per-swimlane lists change. It detects cards with mismatched + * listId/swimlaneId and creates the missing lists. + * + * Issue: When upgrading from v7.94 to v8.02, cards that were in different + * swimlanes but shared the same list now have wrong listId references. + * + * Example: + * - Card1: listId: 'HB93dWNnY5bgYdtxc', swimlaneId: 'sK69SseWkh3tMbJvg' + * - Card2: listId: 'HB93dWNnY5bgYdtxc', swimlaneId: 'XeecF9nZxGph4zcT4' + * + * Card2 should have a different listId that corresponds to its swimlane. + */ + +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { ReactiveCache } from '/imports/reactiveCache'; + +class FixMissingListsMigration { + constructor() { + this.name = 'fix-missing-lists'; + this.version = 1; + } + + /** + * Check if migration is needed for a board + */ + needsMigration(boardId) { + try { + const board = ReactiveCache.getBoard(boardId); + if (!board) return false; + + // Check if board has already been processed + if (board.fixMissingListsCompleted) { + return false; + } + + // Check if there are cards with mismatched listId/swimlaneId + const cards = ReactiveCache.getCards({ boardId }); + const lists = ReactiveCache.getLists({ boardId }); + + // Create a map of listId -> swimlaneId for existing lists + const listSwimlaneMap = new Map(); + lists.forEach(list => { + listSwimlaneMap.set(list._id, list.swimlaneId || ''); + }); + + // Check for cards with mismatched listId/swimlaneId + for (const card of cards) { + const expectedSwimlaneId = listSwimlaneMap.get(card.listId); + if (expectedSwimlaneId && expectedSwimlaneId !== card.swimlaneId) { + console.log(`Found mismatched card: ${card._id}, listId: ${card.listId}, card swimlaneId: ${card.swimlaneId}, list swimlaneId: ${expectedSwimlaneId}`); + return true; + } + } + + return false; + } catch (error) { + console.error('Error checking if migration is needed:', error); + return false; + } + } + + /** + * Execute the migration for a board + */ + async executeMigration(boardId) { + try { + console.log(`Starting fix missing lists migration for board ${boardId}`); + + const board = ReactiveCache.getBoard(boardId); + if (!board) { + throw new Error(`Board ${boardId} not found`); + } + + const cards = ReactiveCache.getCards({ boardId }); + const lists = ReactiveCache.getLists({ boardId }); + const swimlanes = ReactiveCache.getSwimlanes({ boardId }); + + // Create maps for efficient lookup + const listSwimlaneMap = new Map(); + const swimlaneListsMap = new Map(); + + lists.forEach(list => { + listSwimlaneMap.set(list._id, list.swimlaneId || ''); + if (!swimlaneListsMap.has(list.swimlaneId || '')) { + swimlaneListsMap.set(list.swimlaneId || '', []); + } + swimlaneListsMap.get(list.swimlaneId || '').push(list); + }); + + // Group cards by swimlaneId + const cardsBySwimlane = new Map(); + cards.forEach(card => { + if (!cardsBySwimlane.has(card.swimlaneId)) { + cardsBySwimlane.set(card.swimlaneId, []); + } + cardsBySwimlane.get(card.swimlaneId).push(card); + }); + + let createdLists = 0; + let updatedCards = 0; + + // Process each swimlane + for (const [swimlaneId, swimlaneCards] of cardsBySwimlane) { + if (!swimlaneId) continue; + + // Get existing lists for this swimlane + const existingLists = swimlaneListsMap.get(swimlaneId) || []; + const existingListTitles = new Set(existingLists.map(list => list.title)); + + // Group cards by their current listId + const cardsByListId = new Map(); + swimlaneCards.forEach(card => { + if (!cardsByListId.has(card.listId)) { + cardsByListId.set(card.listId, []); + } + cardsByListId.get(card.listId).push(card); + }); + + // For each listId used by cards in this swimlane + for (const [listId, cardsInList] of cardsByListId) { + const originalList = lists.find(l => l._id === listId); + if (!originalList) continue; + + // Check if this list's swimlaneId matches the card's swimlaneId + const listSwimlaneId = listSwimlaneMap.get(listId); + if (listSwimlaneId === swimlaneId) { + // List is already correctly assigned to this swimlane + continue; + } + + // Check if we already have a list with the same title in this swimlane + let targetList = existingLists.find(list => list.title === originalList.title); + + if (!targetList) { + // Create a new list for this swimlane + const newListData = { + title: originalList.title, + boardId: boardId, + swimlaneId: swimlaneId, + sort: originalList.sort || 0, + archived: originalList.archived || false, + createdAt: new Date(), + modifiedAt: new Date(), + type: originalList.type || 'list' + }; + + // Copy other properties if they exist + if (originalList.color) newListData.color = originalList.color; + if (originalList.wipLimit) newListData.wipLimit = originalList.wipLimit; + if (originalList.wipLimitEnabled) newListData.wipLimitEnabled = originalList.wipLimitEnabled; + if (originalList.wipLimitSoft) newListData.wipLimitSoft = originalList.wipLimitSoft; + if (originalList.starred) newListData.starred = originalList.starred; + if (originalList.collapsed) newListData.collapsed = originalList.collapsed; + + // Insert the new list + const newListId = ReactiveCache.getCollection('lists').insert(newListData); + targetList = { _id: newListId, ...newListData }; + createdLists++; + + console.log(`Created new list "${originalList.title}" for swimlane ${swimlaneId}`); + } + + // Update all cards in this group to use the correct listId + for (const card of cardsInList) { + ReactiveCache.getCollection('cards').update(card._id, { + $set: { + listId: targetList._id, + modifiedAt: new Date() + } + }); + updatedCards++; + } + } + } + + // Mark board as processed + ReactiveCache.getCollection('boards').update(boardId, { + $set: { + fixMissingListsCompleted: true, + fixMissingListsCompletedAt: new Date() + } + }); + + console.log(`Fix missing lists migration completed for board ${boardId}: created ${createdLists} lists, updated ${updatedCards} cards`); + + return { + success: true, + createdLists, + updatedCards + }; + + } catch (error) { + console.error(`Error executing fix missing lists migration for board ${boardId}:`, error); + throw error; + } + } + + /** + * Get migration status for a board + */ + getMigrationStatus(boardId) { + try { + const board = ReactiveCache.getBoard(boardId); + if (!board) { + return { status: 'board_not_found' }; + } + + if (board.fixMissingListsCompleted) { + return { + status: 'completed', + completedAt: board.fixMissingListsCompletedAt + }; + } + + const needsMigration = this.needsMigration(boardId); + return { + status: needsMigration ? 'needed' : 'not_needed' + }; + + } catch (error) { + console.error('Error getting migration status:', error); + return { status: 'error', error: error.message }; + } + } +} + +// Export singleton instance +export const fixMissingListsMigration = new FixMissingListsMigration(); + +// Meteor methods +Meteor.methods({ + 'fixMissingListsMigration.check'(boardId) { + check(boardId, String); + + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + return fixMissingListsMigration.getMigrationStatus(boardId); + }, + + 'fixMissingListsMigration.execute'(boardId) { + check(boardId, String); + + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + return fixMissingListsMigration.executeMigration(boardId); + }, + + 'fixMissingListsMigration.needsMigration'(boardId) { + check(boardId, String); + + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + return fixMissingListsMigration.needsMigration(boardId); + } +});