|
@@ -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);
|
|
|
|
+ }
|
|
|
|
+});
|