When opening board, add missing lists.

Thanks to xet7 !

Fixes #5926
This commit is contained in:
Lauri Ojansivu 2025-10-20 17:06:42 +03:00
parent 9473c1fe41
commit 80777b4663
5 changed files with 331 additions and 30 deletions

View file

@ -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

View file

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

View file

@ -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() {

View file

@ -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';

View file

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